Skip to content

Safety hardening: unsafe docs, async I/O, recursion and read budgets - #2

Closed
AdityaVG13 wants to merge 24 commits into
mainfrom
fix/safety-hardening
Closed

AdityaVG13 wants to merge 24 commits into
mainfrom
fix/safety-hardening

Conversation

@AdityaVG13

Copy link
Copy Markdown
Owner

Safety hardening: unsafe docs, async I/O, recursion and read budgets

Summary

This PR fixes the first wave of findings from an internal audit scan of main
(c042ea2de). It documents every undocumented unsafe block with a SAFETY
contract, replaces blocking file calls in async code with tokio::fs, bounds
recursion in value walkers, records the lock-poison policy as fail-stop, and
caps unbounded file and stdin reads. No public APIs change and no new
dependencies are added.

Changes by commit

Commit Area What it does
47a3324 docs(unsafe) Adds SAFETY contracts to all undocumented unsafe blocks; adds a set_tui_env helper so TUI env mutation has one reviewed site
41f0c0e fix(async) Replaces blocking std::fs calls in async code with tokio::fs equivalents
3bf7eb4 fix(resource) Adds depth fuel (64/128) to recursive value walkers so hostile input cannot exhaust the stack
6cb5d41 docs(policy) Records the lock-poison posture as fail-stop in docs/ARCHITECTURE.md
017b402 fix(resource) Budgets file and stdin reads with a take(limit+1) plus check pattern: 1 MiB for config and state readers, 16 MiB for sub-agent state and stdin patches, 8 KiB for API-key stdin, per-call cap with pending-line bound for the worker-log drain

Diff: 41 files, +556/-212 across crates/tui, crates/cli, crates/config,
crates/app-server, and docs/ARCHITECTURE.md.

Audit findings before and after

Rescanned with the same analyzer build on clean worktrees of base and branch
(896 groups at base, 852 on this branch). Counts below are finding groups.

Family Base Branch Delta
Correctness 391 391 0
Lifecycle 21 21 0
Maintainability 356 356 0
Performance 29 5 -24
Resilience 40 27 -13
Safety 59 52 -7
Total 896 852 -44
Rule cleared Groups removed
Blocking calls in async code 24
Undocumented unsafe blocks 18
Unbudgeted reads 9
Unbounded recursion 4

The 18 cleared unsafe groups were reclassified to documented-unsafe
inventory (info severity, no action required), which confirms the SAFETY
contracts registered. Remaining added groups are the same sites re-identified
at shifted line numbers after the edits. Net new actionable findings: 0.

Verification

Gate Command Result
Type check, touched crates ferrum check -p codewhale-app-server -p codewhale-cli -p codewhale-config -p codewhale-tui Pass, 2m35s cold cache
Full check, all targets cargo check --all-targets on remote worker Pass
CLI tests cargo test -p codewhale-cli 334 pass
Config tests cargo test -p codewhale-config 627 pass
TUI touched-area suites targeted -p codewhale-tui runs Pass
Audit rescan atlas scan plus atlas compare on clean worktrees 896 to 852, 0 new actionable

No performance benchmarks were run: this change makes no performance claims.
The quantitative evidence is the finding counts and gate results above. The
edits are comments and docs, async-equivalent I/O calls, per-level depth
counters, and read adapters; none touch a hot path.

Notes for reviewers

  • The lock-poison commit is docs only. Call sites still use direct lock()
    handling, so the audit rule keeps firing there. Converting call sites is a
    separate follow-up.
  • The branch adds two .expect() calls in new depth-limit tests only. No new
    panics in production code.
  • 16 test binaries abort with stack overflow both on this branch and on
    unmodified main; proven pre-existing and out of scope for this PR.
  • This branch is based on c042ea2de, which is behind current main. It
    should be rebased before going upstream.

CodeWhale Bot and others added 24 commits September 17, 2026 13:54
…red vocabulary (Hmbown#6290 step 2)

The issue's sequencing step 2 — "the remaining pickers" — with `list_nav` as
the single movement vocabulary (fleet_detail and provider_picker landed in
step 1):

- `mode_picker` and `status_picker` drop their hand-rolled up/down handling and
  gain Home/End and PageUp/PageDown through `list_nav::apply` (Prev/Next wrap;
  paging and Home/End clamp). `j`/`k` keep working: both are surfaces with no
  text input, so `motion()` applies.
- `session_picker` replaces `move_selection`/`page_selection` with the
  vocabulary. Shift+PageUp/PageDown still scroll the history preview — claimed
  before the vocabulary sees the bare keys.
- `file_picker` is a typing surface, so it takes the typing-safe set
  (`motion_while_typing`): arrows, paging and Home/End, never a letter that
  would be eaten from the query. Paging now clamps (a paging key asks to
  travel, not to teleport), matching every other surface.
- Each surface consumes only the motions it can act on, so the horizontal
  motions stay unclaimed on these single-column lists.

Tests: picker filter "304 passed; 0 failed"; new regression
`home_end_and_letter_aliases_come_from_the_shared_vocabulary` on the session
picker; clippy (CI's exact invocation, tui lib) clean.
…odel

`provider_health_requires_observed_success_and_keeps_failure_reason` recorded
its check against the literal "deepseek-v4-pro". Readiness is keyed by the
full route identity (provider + endpoint + auth class + model), and the
DeepSeek default route has since moved to the Flash line, so the recorded
check no longer matched the row and the test failed on main.

The test now reads `row.default_route.logical_model` and records against the
row's own route, which keeps the intent (observed success flips
SavedUnchecked → Ready) without pinning a model literal that the default route
can move away from.

Evidence: `provider_health_requires_observed_success` => "1 passed; 0 failed";
full picker filter green afterwards.
…d happened to use

`/plugin marketplace add <name> <path>` refused an id that already existed, so
re-adding the codewhale marketplace to refresh it was impossible once the name
was taken — and the workaround is visible in this machine's own state: two
snapshots of `codewhale-plugin-marketplace/marketplace.json`, the second one
hand-named `cw2`, both stale, sitting side by side.

This is the identity half of the grokbuild model
(`xai-grok-plugin-marketplace`'s `MarketplaceSource::identity`: the URL or
expanded path is the stable identity; the display name is not):

- `add` now keys on the canonical source document: re-adding the same source
  updates that catalog in place and renames it to the requested id when the
  name differs. A *different* source under a taken name still refuses, so a
  name never silently re-points.
- `load` collapses catalogs that share one source, keeping the entry named
  after the document itself (else the most recently added). Older states
  self-heal on the next read; the projection persists on the next write.

What this does not do: it does not refresh a catalog when its local document
changed on disk — that is the next slice (re-parse on add is a refresh today).
And it does not yet split catalog *entry kinds* (plugin vs skill), which is
what makes the skill-name suppression deletable.

Evidence: marketplace filter "44 passed; 0 failed", including the un-staled
command roundtrip (same-source re-add succeeds; different-source clash is
refused) and new store tests for rename-in-place, clash refusal, and the
two-name collapse; clippy (CI's invocation, tui lib) clean.
…er the plugin pool

The plugin suggestion pool mixed three things under one word: plugins, the
first-party skills the Codewhale marketplace lists as their own entries (each
arriving as a `cw2:*` "plugin"), and imported third-party marketplace plugins.
The observed symptom: typing "test" recommended `cw2:test` — the test *skill* —
and the mitigation was Hmbown#6274's skill-name suppression, a snapshot-based name
check that missed mid-session changes and never applied to the composer toast.

This is the structural half of the grokbuild model
(`xai-grok-plugin-marketplace`: a catalog entry declares what it is — there via
inventory flags on an always-a-plugin entry, here as an explicit
`MarketplaceEntryKind` because our catalogs list skills directly):

- `MarketplaceCandidate` gains `kind` (Plugin | Skill), defaulted so stored
  snapshots keep their meaning. The Codewhale parser derives it from the
  entry's source path (`skills/…`) or an explicit `"kind"` field; the
  kimi/claude/codex parsers declare Plugin (their formats model plugins only).
- Both suggestion pools — `idle_and_catalog_keyword_matches` (toast +
  `<recommended_plugins>` fragment) and `recommend_plugins_for_task`
  (`/plugin suggest`) — admit only Plugin-kind entries.
- Hmbown#6274's skill-name suppression is deleted along with its Engine-side
  snapshot plumbing: with kinds split there is no twin to suppress.

What this does not do: the matcher keeps its current scoring (grokbuild-style
declared keywords/domains with word boundaries is a separate slice), and an
already-stored stale catalog snapshot keeps its entries until it is re-added —
the source-identity collapse gives it a single catalog.

Evidence: marketplace "44 passed", recommend "27 passed" including the new
differential test (same entry as Skill is excluded from the pool and produces
no fragment; as Plugin it matches), plugin_suggestions "8 passed; 0 failed";
clippy (CI's invocation, tui lib) clean.
… verb lists

Founder clarification, 2026-09-09 (docs/design/TUI_DECONSTRUCTION.md): "the
harness should let the model decide when a goal, plan, delegation, further
investigation, or verification is useful." Operate violated that with three
conflicting authorities: `operate_goal_from_prompt` classified instructions
with work-verb and chat-opener lists, `CreateGoalTool::description` said goals
require an explicit request, and the host promoted a prompt before the model
ever ran. The verb list produced exactly the absurdity it invites: "test
respond with hello" became a persistent goal because "test" is a verb.

- `operate_goal_from_prompt` and its vocabularies are deleted (~8.6 KB with
  tests). The engine creates a goal only from an explicit user declaration —
  `/goal` and the natural-language forms `explicit_goal_directive` parses — or
  when the model calls `create_goal`. No wording is classified.
- `create_goal`'s description becomes the one model-facing contract: the model
  decides when a request is a durable objective, and is told what is not one
  (a question, a greeting, a one-shot edit, a conversational probe).
- The create-refusal report is now always the explicit one: a `/goal` that
  `GoalState::create` refuses says so instead of being swallowed.
- docs/MODES.md (Operate) updated to match; the design doc's "conflicting
  authorities" note is resolved by this change.

Tests updated to the contract, not the old behavior:
- `operate_never_promotes_wording_to_a_goal`: ordinary work prompts are
  ordinary turns in Operate and Work; an explicit declaration still creates
  the goal.
- `operate_contract_is_appended_once_and_an_existing_goal_is_never_replaced`
  seeds its goal through the explicit declaration.
- `operate_model_shell_uses_normal_approval_and_workspace_sandbox` declares its
  goal explicitly instead of relying on promotion.

Evidence: goal filter "148 passed; 0 failed"; operate filter "55 passed;
0 failed"; clippy (CI's invocation, tui lib) clean.
…dit batch 1)

From the read-only audit (codewhale-ops/ledger/HOST-DETERMINISM-AUDIT-20260917.md):
host rules that classify meaning where the model — or declared data — should
decide. This batch deletes the three that were pure subtraction.

1. `tools/workflow_trigger.rs` (405 lines) — seven verb/phrase classifiers
   (tiny-work phrases, factual-question prefixes, small-talk list, one-file-edit
   verbs, fan-out language, staged-work language, verification language) that
   decided auto-Workflow. Dormant in production: its only non-test consumer was
   the `debug_assert!(soft_auto_policy_is_linked())` in `with_subagent_tools`
   (Hmbown#4127), whose purpose was to keep the policy *linked*. The prompt carries
   the policy now; module, assertion, and file are gone.

2. `behavioral_tips::looks_like_planning_prompt` + its "switch to Plan mode"
   tip — a hand-tuned word list (`plan`, `roadmap`, `strategy`, `outline`,
   "how should we", "step by step") with a unit test pinning the classifier.
   The tip variant, the nudge call site, and the detector are deleted; the
   model already knows when to suggest plan mode.

3. `plugins/matcher::is_specific_term` — the generic-word stoplist (`mcp`,
   `agent`, `model`, `data`, `code`, …) that made a catalog author's *declared*
   keywords unmatchable: the same failure mode as the deleted Hmbown#6274 name
   suppression, one layer down. Matching is now purely declared data with
   mechanical admissibility (>= 3 chars, no control characters). The
   code-hosting homepage exclusion stays — a github homepage names where the
   plugin lives, not what it is — with a comment naming the real fix (catalogs
   declaring match domains explicitly).

Deferred from batch 1, with reasons in the audit file: the subagent status
sniffer (Hmbown#9) needs the progress kind threaded from the event payload, and the
tool-name noise list (Hmbown#7) needs a declared verbosity field on tool specs.

Evidence: matcher "11 passed", recommend "27 passed", plugin_suggestions
"8 passed", behavioral_tips "3 passed", tools::workflow "142 passed",
tools::registry "56 passed"; clippy (CI's invocation, tui lib) clean.
…assification (determinism audit batch 2, #1)

The auto_reasoning keyword classifier (debug/error -> Max, search/lookup ->
Low, plus CJK/JP lists) made cost and answer quality depend on vocabulary.
auto now resolves the declared High default everywhere: turn loop,
route planner, CLI/exec paths, runtime threads, and subagent assignment.
The prompt plumbing through the spawn-route functions is deleted with it
(-375/+106); select() is the seam a model-declared escalation hint lands in.

Tests: auto_reasoning/resolve_auto_effort/cli_auto/model_routing filters
47 passed, 0 failed; tools::subagent 672 passed, 1 failed where the single
failure (issue_5305 untethered fail-closed) also fails on clean HEAD and is
unrelated to this change. clippy --all-targets clean, fmt clean.
…nt heuristic (determinism audit batch 2, #2)

Without the flash classifier the router guessed cheap-vs-big from request
wording (COMPLEX_KEYWORDS plus char-length thresholds) — host-side semantic
determinism that made cost and quality depend on vocabulary. The fallback is
now declared: the configured default model, with the explicit [auto]
cost_saving opt-in pinning the runnable fast sibling. Request text is never
inspected. The flash-classifier path is untouched.

Receipts stay honest: new routes record LocalFallback(DeclaredDefault);
content-derived reasons are never constructed again but retained so saved
sessions deserialize (plus a serde alias on the renamed wrapper and a test
pinning the pre-rework shape). TurnRoutingSource gains auto-local-fallback;
the now-unused display_text planner input is deleted with its 4 call sites.

Tests: model_routing 32 passed 0 failed; planner/receipt/preview neighbors
44 passed 0 failed; tools::subagent 672 passed with only the known
pre-existing 5305 failure (fails on clean HEAD too). Clippy --all-targets,
fmt, dead-code budget clean.
…minism audit batch 2, Hmbown#3)

The host parsed ten prose phrasings plus a clause allow-list ("make it your
/goal to ...") into durable goals before the provider call. Per the founder
direction the model decides when a goal is useful: prose asks now reach the
model, which calls create_goal; the deterministic user path is the leading
/goals <objective> command, unchanged. Same philosophy as 4de9e9e, which
deleted the Operate verb-list promotion.

Tests: tools::goal 29 passed 0 failed; engine goal suites (rewritten: prose
never activates, seeded-goal flows intact) 12 passed 0 failed. Clippy
--all-targets, fmt, dead-code budget clean.
…audit batch 2, Hmbown#5)

PROACTIVE_MIN_SCORE and RecommendOptions::proactive() had no production
callers: the proactive toast and the <recommended_plugins> fragment are
driven by the declared-keyword matcher, and the score rubric only ranks the
user-invoked /plugin suggest list. The gate gated nothing. Tests now pin the
real default options; the description-floor test pinned only the dead gate
and goes with it.

Tests: plugins::recommend + plugin_suggestions + skills::recommend 20 passed
0 failed; suggest 74 passed 0 failed. Clippy --all-targets, fmt clean.
…age text (determinism audit batch 2, Hmbown#9)

The footer rewrote subagent progress by sniffing the literal "requesting
model response", against the codebase contract that UI consumers must never
recover state by parsing the message. The producer now marks the routine
per-step heartbeat with AgentProgressEventMeta.routine_wait; retry/timeout
waits share ModelWait status but stay informative, so the status alone could
not carry this and the flag is set only at the heartbeat site. The event
handler threads it into friendly_subagent_progress and the store decision;
is_noisy_subagent_progress is deleted.

agent_card keeps its text match deliberately (documented beside it): the
mailbox is a stable cross-crate surface that may carry foreign payloads,
and no in-crate producer sends routine waits there.

Tests: footer progress 2 passed 0 failed (incl. new informative-wait pin);
progress suites 30 passed 0 failed; tools::subagent 672 passed with only
the known pre-existing 5305 failure. Clippy --all-targets, fmt clean.
…mbown#6283)

A 638k-token transcript died because a child read a huge file blind: no
size up front, no truncation flag, no line count. The canonical read tool
now reports all three on every response (metadata plus size in the
truncation footers), so paging is deliberate. Whole-file reads keep their
exact footer-free shape; budgets are unchanged (100KB default / 500KB max
are deliberate context policy, not the bug).

The grep half already existed: grep_files is model-visible, read-only,
and in the child surface (pinned by an_explicit_parent_tool_scope...),
with File/search_content as the action spelling. Added the grep-then-read
flow test to pin it.

Tests: tools::file 173 passed 0 failed (incl. 4 new: >10MiB page-one,
bounded paging to completion, ordinary-read metadata, grep-then-read);
tools::search + compactor neighbor 23 passed 0 failed. Clippy
--all-targets, fmt clean.
…Hmbown#6210)

Completion path: finish_terminal_result no longer runs the git trio +
fingerprints synchronously. ensure_worker_delivery_verified snapshots
inputs under a read lock, computes in spawn_blocking, and stores under a
follow-up write lock (idempotent via DeliveryEvidence.checked). The
natural/panic epilogues ensure before the lock so fan-in completions and
the terminal persist stay fresh; Stop/interrupt/close/stale commits heal
on the next detail projection.

Spawn path: spawn_subagent_from_input fingerprints the baseline pre-lock
(assuming write; registration discards it when the resolved spec is
read-only, and skips capture when the parent ceiling denies writes).
Resume/Fleet/test paths keep the inline capture.

Tests: deferred-semantics updates plus pending/ensure-idempotence/
no-op/heal/adopt/discard/fallback coverage.
Agent Mail is the durable submit-while-running queue: nothing ever
produces a Queued turn record (POST /turns rejects a busy thread), so
the executable slice is withdrawing a queued envelope, not turn routes.

- protocol: AgentMailStatus::Canceled + agent_mail.canceled event with
  validation (no delivery fields, like queued).
- runtime_threads: cancel_agent_mail mirrors mark_agent_mail_read
  (ownership gate, mail_mutation lock); Queued -> Canceled, re-cancel
  idempotent, post-delivery states an explicit conflict. Deliver treats
  Canceled as terminal (envelope back, no turn); the wake pump's
  Queued-only match skips it structurally.
- runtime_api: POST /v1/threads/{id}/agent-mail/{message_id}/cancel;
  cancel-after-delivery maps to 409. Unknown message ids now map to 404
  via a typed io::NotFound chain check (also repairs deliver/read/mark
  of unknown ids, which fell through to 400).

Tests: cancel route test (list/cancel/re-cancel/deliver-after-cancel/
409/403/404), 409 mapping unit test, protocol validation test.
…eet views (Hmbown#6290)

Steps 2+3: model picker, slash menu, fleet list/roster/setup now resolve
movement through list_nav instead of per-surface key tables. list_nav.rs
itself untouched.

- model_picker: two-pane apply_motion (typing-safe set for the live
  filter); pages keep the 5-row distance but clamp instead of wrapping.
- slash_menu: selection helpers collapse onto move_slash_menu_selection;
  PageUp/PageDown navigate the popup (previously scrolled the
  transcript); Home/End deliberately stay cursor keys since the composer
  is still the focused input.
- fleet_list: full axis incl. j/k, new paging; single-column.
- fleet_roster: bare keys drive rows, Shift+PageUp/Down/Home keep detail
  scroll (Hmbown#6014-style split); region declined so Tab keeps opening
  workers.
- fleet_setup: choice steps page/clamp; Review step keeps scroll on the
  same keys (no row list there); region declined so Tab/Left/Right keep
  wizard arms; filter mode gains paging.

Tests: roster bare/shift paging split, slash paging clamp; updated the
roster detail-scroll test to the Shift+ chord. Note: the mention menu
still clamps Up/Down (slash wraps) — left for a follow-up UX call.
…ng (Hmbown#6211 R7b)

Each fleet SSE viewer reopened the manager and replayed from disk every
250ms. Ledger managers open per operation, so a process-wide registry
joins them by canonicalized ledger path with one Notify each; the
append funnel notifies after fsync, and streams wait on wake-or-5s-
fallback instead of sleeping. The cursor stays the source of truth, so
a missed/spurious wake costs one extra poll, never lost events.

All appends are in-process (the executor maps worker stdout to ledger
records), so wakes cover every writer; the fallback heals missed wakes,
future out-of-process writers, and compaction (which replaces history
rather than appending).

Tests: append-wakes-subscriber + per-file scoping. Note: fleet suite has
one pre-existing red (resolved_config_mints_secret_free_fleet_route_
snapshot expects chat_completions, gets responses) failing identically
on clean HEAD, unrelated to this slice.
…d of failing closed (Hmbown#6285)

AC3: plan_pr_review no longer errors when a file fits no pass or passes
exceed max_passes. Oversized files are skipped whole (never truncated)
and passes beyond the budget are cut in diff order; the plan reviews
what fits. Only a plan covering nothing still errors, and it names
every skipped file.

AC4: the manifest carries skipped_files (file, reason, budgeted chars)
into prompts, receipts, and tool metadata. finish() says 'Partial
review coverage' with the skip list instead of claiming completeness,
pass prompts tell the model the review is partial, the budget-stop
note names plan-time skips, and --check-receipt fails on partial
coverage with the skip list.

Gate decision: the partial review is printed and posted, but the CLI
still exits non-zero naming the skips and the remedy — the workflow
gates on the exit code, so exit 0 on unread files would silently turn
the red gate green. The failure reads as limits, not as a verdict.
Hmbown#6213 T7)

convert_anthropic_sse_data built a Value DOM per token and converted it
with from_value. The per-token path now deserializes directly into the
tagged StreamEvent; a borrow-only tag peek routes the two usage-bearing
events (message_start/message_delta) to the exact legacy path, since
their wire-to-normalized usage mapping reads fields the normalized
Usage cannot represent. Decode failures keep their legacy outcomes
(invalid SSE JSON vs unrecognized event vs tolerated-unknown), and the
local-only tool_projection_warning still never decodes from SSE.

This closes the last hot T7 item. The T7 chat.rs sub-item is declined:
inspect_prompt_for_request runs only on explicit cache-warmup calls
and the /debug cache command — not a hot path — and the catalog
string is load-bearing layer content, not just hash input.
… executor (Hmbown#6211 R4/R7a)

R4: Drop for Connection grace-waited up to 500ms on the dropping thread
(manager reload, pool rebuild). Drops now hand the child to one shared
reaper thread; the grace/kill/wait sequence is unchanged, only relocated,
with inline reaping as the degenerate fallback if the reaper is gone.
The Mutex half stays: request() rendezvouses on a shared responses
channel and drops non-matching ids, so serialization is load-bearing
for correctness until an in-flight dispatch map exists (the issue's own
longer-term item; sequenced with the Hmbown#6142 stack reconciliation).

R7a: the per-connection 50ms authority watch ran synchronous state fs
on the executor. The check now runs under spawn_blocking with the 50ms
revocation cadence unchanged. The watch stays per-connection by design:
it covers the pre-insertion connect window, and a pool-level task would
need the pool lock — held across in-flight calls — regressing the
mid-call trip this exists for.
Atlas UNSAFE-001: every production unsafe block now carries a terse
justification at the site. The 16 bare set_var blocks in apply_tui_env
are centralized into one documented set_tui_env helper; all other
changes are comment-only.
Atlas ASYNC-002: convert std fs calls lexically inside async fns to
their tokio::fs equivalents (read/write/rename/metadata/remove_file/
create_dir_all/OpenOptions/try_exists). Coherent same-function twins
converted too; search.rs:215 falsified (already behind spawn_blocking
via run_blocking_grep). Sync helpers shared with sync callers
(write_atomic*, streaming readers, staging) intentionally untouched.
Atlas RESOURCE-002: export walkers (TOML, cap 64), JSON redactor and
approval canonicalizer (cap 128, serde-parse-aligned) now carry depth
fuel and fail closed past it. canonicalize_json_keys proven bounded
(sole input is McpConfig-shaped, no Value fields) — no change. Adds
deep-input tests per walker.
Atlas PANIC-002 is one policy decision, not 21 patches. Production
locks already fail-stop with lock-naming messages (user registry,
session index, coordination slot); 15 of 21 findings are test-support
code. Document the rule: expect by default, into_inner only where
stale state is safe.
Atlas RESOURCE-001: take(limit+1)+check pattern mirroring the
credential store's existing limit. Config/state readers capped at
1 MiB, sub-agent state and stdin patches at 16 MiB, API-key stdin at
8 KiB; worker-log drain capped per call with a pending-line bound.
The 5 oauth findings already flow through the budgeted store reader.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant