Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Opus 5.5 3bc66bf979 chore(web): regenerate agent docs for the integrated 0.10.3 batch
Ten independently reviewed slices (SEAM-2, REF-04, REF-08, first-run,
quota error, RU-15, release checklist, telemetry collector, Pet lockfile,
Rust 1.100.0 pin) merge onto main without conflict. The combined crate
surface changes the generated agent-docs index.

Validation: web check-facts and check-release-docs OK;
scripts/preflight.sh --check OK. Exact-SHA CI and RC dispatched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 04:56:42 -08:00
CodeWhale Bot 453a840e71 Merge commit 'e94fbf6e11' into coord-integrate-3 2026-11-13 04:56:31 -08:00
CodeWhale Bot d865a7eaf1 Merge commit '00d3962085' into coord-integrate-3 2026-11-13 04:56:31 -08:00
CodeWhale Bot 41e6a30894 Merge commit '705417fccd' into coord-integrate-3 2026-11-13 04:56:31 -08:00
CodeWhale Bot 3471f45398 Merge commit '224764dc08' into coord-integrate-3 2026-11-13 04:56:30 -08:00
CodeWhale Bot b0566ad706 Merge commit '64b7bfa4af' into coord-integrate-3 2026-11-13 04:56:30 -08:00
CodeWhale Bot 4fbc0d394d Merge commit '4b1e691f3b' into coord-integrate-3 2026-11-13 04:56:30 -08:00
CodeWhale Bot 995c39efae Merge commit '346cde38bd' into coord-integrate-3 2026-11-13 04:56:30 -08:00
CodeWhale Bot af54e4e51e Merge commit '61d068842b' into coord-integrate-3 2026-11-13 04:56:29 -08:00
CodeWhale Bot 94406cd358 Merge commit 'db73296af6' into coord-integrate-3 2026-11-13 04:56:29 -08:00
CodeWhale Bot 0ed103ad5c Merge commit '0d8db3eb6b' into coord-integrate-3 2026-11-13 04:56:29 -08:00
CodeWhale BotandClaude Opus 5.5 e94fbf6e11 fix(toolchain): install rustfmt and clippy with the pinned 1.100.0 toolchain
Pinning channel = "1.100.0" made rustup install the pinned release with the
default component set on hosted runners, so rustfmt was absent in the Test jobs.
Five engine tests that format Rust through rustfmt then failed on all three OSes
with "rustfmt must be on PATH for the formatting tests" (CI 43387758446:
19419/19424 Linux and macOS, 18627/18632 Windows). The workflow contract forbids
per-workflow component adds, so the toolchain file owns the components.

Validation: .github/scripts/release-workflows.test.js OK; rustfmt resolves
under the pinned toolchain locally; scripts/preflight.sh --check OK. Exact-SHA
CI and RC dispatched. Refs CW-8205.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-13 04:54:52 -08:00
CodeWhale BotandClaude Sonnet 5.5 705417fccd chore(telemetry): qualify schema 6 collector compatibility
Preserve schema1 through5 validation and append bounded schema6 fields without changing the existing model-use columns. Schema6 remains first-party only. This prepares collector source; client emission stays schema4 until founder deployment and live v4/v6 endpoint acceptance.

Checks:225/225 tests in8files, TypeScript typecheck, localWrangler bundle dry-run passed. Independent correctness and privacy reviews approved. Actual Codewhale/OpenCodeGo deepseek-v4.1-flash prepared read-only source audit; coordinator rederived source and ordering. No deployment or live endpoint proof.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-13 00:14:03 -08:00
CodeWhale BotandClaude Sonnet 5.5 2d1ba71ed7 build: pin source toolchains and container recipes to Rust1.100.0
Carry the saved RU01 and RU08 work together so local builds, workflows,
Docker, Dev Containers and CNB agree. Keep the minimum supported Rust1.89
job isolated for its entire execution. Retain existing release source
pinning, macOS timeout and optional compiler-cache guards.

Harden the existing contract test against misplaced MSRV selectors,
unknown or expression-valued toolchains, and quoted stale image tags.
The old Tencent example now follows the same version.

Checks: full Node workflow contract passed (211 included Rust inputs,
11 wrapper event cases across12 steps,13 hermetic invocation checks);
22/22 dev-cache checks;13/13 independent negative probes rejected;
prebuild, generated-data freshness, formatting and preflight passed.
No native/image/hosted qualification is claimed by these source checks.

Original saved changes:09431b60c593 and2c12736202b2.
Worker: Codewhale/OpenCode Go deepseek-v4.1-flash, medium effort.
Independent Codex review approved the final25-path source.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:43:09 -08:00
CodeWhale BotandClaude Sonnet 5.5 00d3962085 Bring pet/tui/Cargo.lock in line with the workspace protocol crate versions
The lock still named codewhale-app-server-protocol 0.10.2 without its chrono
dependency, so cargo fetch --locked in the Pet conformance workflow refused.
Regenerated with cargo update --workspace; no other version changed.

Checks: current integrated cargo metadata --locked --offline passed;
201 packages resolved. Historical dedicated Pet workflow43216899528
passed4/4 jobs on the unchanged lockfile; current-parent Pet workflow
will be dispatched before landing. No native rebuild claimed here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:25:20 -08:00
CodeWhale Bot 64b7bfa4af test(web): keep installer fixture directories private across umasks
Create the happy-path install and home directories with mode0700 so a
host umask0002 does not turn them into destinations that the installer
correctly refuses. Production ownership and permission checks are unchanged.

Checks on Linux with the exact two-line fixture patch:33/33 installer
contracts at umask0002 and33/33 at0022;7/7 Node tests;1306/1306 Vitest
tests across92files. OpenNext/Cloudflare packaging completed and Wrangler
local dry-run exited0. TypeScript passed after the normal prebuild step.
These are source/local packaging receipts, not a deployment.

Independent Codex source review approved. Earlier Gemini Hydrogen
website dependency and adapter repairs are retained unchanged.
2026-11-12 23:20:49 -08:00
CodeWhale BotandClaude Sonnet 5.5 0d8db3eb6b feat(extension-host): gate service acts and retain sealed act receipts
Service acts use the session permission gate with child write/network
requirements. Reserve plugin management for the built-in creator module.
Record allowed and refused broker acts, and return those receipts when the
host throws, cancels or unregisters the tool. Seal the broker-act record
before returning so a late gate answer cannot authorize an unrecorded act.
Ordinary core-call receipts may remain in flight on cancellation; this does
not claim synchronous completion of all effects.

Preserves the reviewed SEAM-2 candidate and its real-host regression,
with complementary held-gate and successful-answer-after-seal tests.
Original candidate commits: 1d43f49bd41b55c287e2ced0702558cf2c10f3f9,
852332ad26 and 05c9de15c23394135129eedce9cc0f6f311e6fa6.

Checks on the frozen 22-file source over 622a493078:
- macOS mandatory Node selection: 277/277 passed; compiled Native: 2/2.
- Engine all-target Clippy with -D warnings: exit 0; fmt clean.
- Linux XL run 20261113T060722Z-seam2-xl-915835: 277/277 passed,
  6981 skipped, exit 0. The separate Large run is still pending.
The initial two missing-native-artifact failures were resolved by building
and explicitly supplying the supported fixture; original logs retained.
Integration adds mirrored changelog and regenerated docs; final candidate
full hosted CI and Windows qualification remain pending.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:15:37 -08:00
CodeWhale Bot 224764dc08 docs: align release checklist with exact-source CI
Remove stale Windows compiler-cache and main-push coverage claims. Keep release-candidate acceptance separate from normal CI and record confidential counts only in the private release record. Existing gate checkboxes remain unchecked.

Prepared by agy Gemini Hydrogen, then corrected and independently reviewed by Codex. Node release-doc check and diff check passed; this documentation change does not claim any release gate or publication.
2026-11-12 23:04:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 f2598ded67 docs: reword Windows smoke sccache checklist item as checkable (CW-7953)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:37 -08:00
CodeWhale BotandClaude Sonnet 5.5 5e1fa0877f docs: release checklist gains RC SHA, windows sccache, telemetry and security gates
Docs only; no CHANGELOG line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:36 -08:00
CodeWhale Bot 70fbe7aab6 fix(web): scope legacy lint allowances and retire the upstreamed adapter patch
Keep React lint errors enabled globally. Expose nine existing effect diagnostics as warnings on six exact component files and limit the server-clock purity exception to the revalidated home page. Preserve hydration, privacy and resource lifecycle behavior.

OpenNext 1.20.17 already includes server/preview-props.json, so remove the obsolete exact-byte monkey patch rather than rewriting the upstream loader. The old Linux packaging attempt failed its hash gate; a final clean packaging run is still pending.

Worker: agy Gemini Hydrogen, medium effort; independently reviewed by Codex. Independent ESLint checked 591 files with zero errors and nine warnings, and calculated configs prove exact exception scope. Pinned installed loader source/hash and its Next consumer match. Worker-reported test counts are not promoted to verified evidence without raw logs. No deploy or publication.
2026-11-12 23:04:34 -08:00
CodeWhale Bot c06ea6d11c chore(web): next 16.4.6, wrangler 4.161.0 and the OpenNext adapter update (CW-8205 RU-15)
Checks in web/:
- npm ci: exited 0 (880 packages)
- npm run build: exited 0 (Next.js 16.4.6 Turbopack, 979/979 static pages)
- npm run lint: exited 0 (0 errors, 0 warnings)
- npm test: 7/7 node --test (dependency-security.test.mjs), 92/92 vitest files (1306/1306 tests)
- npx tsc --noEmit: exited 0
- npm audit: 10 moderate before, 10 moderate after; npm run audit:dependencies exited 0 (10/10 mitigated, 0 blocked)

Worker: agy 1.5.7 with gemini-4-hydrogen-medium; reviewed and landed by the Codewhale coordinator.
2026-11-12 23:04:33 -08:00
CodeWhale Bot 4b1e691f3b fix(llm): reuse duration formatting and avoid repeated Go hints
Recover the saved quota-window fix using the existing command-contract duration formatter, so 70168 seconds reads as 19h29m and unknown resets omit the clause. Keep the OpenCode Go key guidance from repeating when an already-hinted error is displayed again. Extend the existing display regression and add mirrored release notes.

Worker: Codewhale on OpenCode Go deepseek-v4.1-flash, medium effort. Original saved Kimi-worker commit and author credit remain intact. Independent source review, rustfmt, changelog sync and diff checks passed. Focused exact-source Linux test is running; native and hosted gates remain required before landing.
2026-11-12 23:04:31 -08:00
CodeWhale BotandClaude Sonnet 5.5 2e2def655f fix(llm): print quota-window reset as plain text (CW-8209)
LlmError::QuotaWindow Display formatted retry_after with {:?}, leaking
Some(70168s)/None into user-visible text. Print the reset as plain text
("19 h 29 min", "H h", "N min", "N s") and omit the reset clause
entirely when no reset time is known. QuotaWindow and RateLimited gain an
OpenCode Go hint when the message names that provider.

Worker: Codewhale (kimi-k3 on OpenCode Go)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:30 -08:00
CodeWhale BotandClaude Sonnet 5.5 346cde38bd fix: make first-run download and trust choices clearer
Retry installer downloads up to three times, remove failed partials,
and use explicit digit choices for directory trust. Keep configured
provider errors while clarifying the fresh custom-route placeholder.
Align the Chinese trust keybindings and mirrored release notes.

Original author: CodeWhale Bot <[email protected]>
Carried only these three original commits:
  cb1505a37773270451cacd66de438221d7a08e1e
  20bfae466dbd131373f0d0e2a08f9e090779c654
  2c1e49c99901ff7a9a2f791e8e3f1ecd3a371c3e

The old gen-strings --check current claim is withdrawn: prior reviews
found the app check red on both base and candidate. No app-string
qualification is claimed here. Hard-link delivery and the original
saved-working-endpoint report remain unresolved separate acceptance.

Integrated-source checks: installer safety 76/76; website contract 33/33;
prebuild, fmt and diff checks pass. preflight passes. Rust gates pending.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:28 -08:00
CodeWhale BotandClaude Sonnet 5.5 61d068842b refactor: retire unused APIs and the duplicate agent catalog
Preserve the reviewed REF-08 deletions and retained locale consumers.
Move the remaining static model admission into route_preferences while
retaining push18 live-catalog ownership and custom-route enumeration.
Regenerate web facts/docs and document removed crate APIs.

Original author: CodeWhale Bot <[email protected]>
Squashed from:
  2268c0d8a749c8794acc71af92c58e227186734e
  051cdc69126d8bbf8ed89ba371d3e605beda1820
  b4d6e16f196f1e72955140ff7d33960ee76a5863
  2a4c733b387aa1e8b2da10e4f6224f2fa99ad1d6
  c13c580ab7dfe5ed10b452fda2987eda960abc62
  3e6bb3616f377b9614fe17e7e4da7c0db5595ef4
  7620f050fbfd5cd47c48213fcfaa404e3d5f74d1
  459c0933eda34453ff739d292e1b1b42f049c308
  863fb512eb4ba6f49fe987287b2db36589711f91

Checks on this integrated tree: fmt, preflight, provider-registry and
diff checks passed; native workspace/all-target/all-feature Clippy is running; tests pending. Preflight: 109 dead-code
suppressions, 666 blocking-call sites within budget, 244 runtime routes.
Historical exact-tip evidence at 863fb512eb: 147/147 small-crate tests
and engine/core/hooks/localization check passed. Earlier full-suite
counts belong to prior trees and are not claimed for this integration.
Independent integrated-source review approved; exact-candidate native and
hosted CI gates remain required before landing. Corrected retained schema
documentation and the mirrored API-removal count after review.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:26 -08:00
CodeWhale BotandClaude Opus 5.5 db73296af6 fix(subagent): remove the workspace-wide coordination lock
The v3 transaction lock and per-writer boot leases already own coordination.
Remove the lifetime process lock and its read-only fallback so one stale
session cannot keep later sessions read-only. Retry failed store admission
on later delegated writes, preserving image validation and lease ownership.

Pin an admitted Windows workspace directory without delete sharing. Keep
ordinary directory handles unchanged for plugin staging. The pin protects
the workspace and its ancestors, not children under .codewhale; that residual
boundary remains explicit in the review and release acceptance.

Integrated from 9a1e8e3b9f993a6adad30ad26d17a6d16e90737c; preserve the
original candidate and historical evidence. Updated lifecycle/architecture
docs, conformance fixtures, call sites and mirrored release notes together.

Validation on the integrated source: formatting, preflight and diff checks
passed (121 dead-code suppressions, 665 blocking sites, 244 runtime routes).
Independent source review approved. Focused Linux tests are running; exact
Windows convergence PASS and app-server compilation on all three platforms
remain required before landing. Earlier test counts are historical and are
not claimed for this integrated candidate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:04:23 -08:00
CodeWhale Bot 0002f88ca2 fix(api): keep repeated hook labels distinct in plugin index
Use the existing per-event ordinal for hook row identity. Hook names are optional labels and validation deliberately retains repeated names. The route regression covers repeated explicit labels and a label matching the unnamed fallback.

Validation: plugin-index route tests: 2 passed, 0 failed, 844 filtered out; cargo fmt --all -- --check and git diff --check passed; scripts/preflight.sh --check passed. macOS linker reported the existing __eh_frame size warning. Refs CW-7875.
2026-11-12 23:01:25 -08:00
CodeWhale BotandClaude Sonnet 5.5 fb43ff028a fix(config): show the telemetry setting default as on, matching the runtime
The settings schema declared telemetry default false while the resolver
(telemetry_consent_from_env) treats an unset value as on, so the settings
API reported default false for a default-on policy.

Landed in Engine push 18 (CW-7875) with the CHANGELOG line the review wrote
(CW-7987 REVIEW.md finding 2; the packet's line was not used). This is the
committed part only: CW-7987 stays open for the active-version readback and
the ingest bundle rebuilt from the frozen candidate. Review: farm runs
20261112T110021Z-gtelrev-config-37102e (codewhale-config, 21 run, 21 passed,
823 skipped) and 20261112T110024Z-gtelrev-telem-751b20 (codewhale-telemetry,
53 run, 53 passed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 23:01:24 -08:00
CodeWhale BotandClaude Sonnet 5.5 f8c3a015a4 feat(router): preserve explicit per-question decision abstention
An explicit abstained:true on a System One answer now survives decoding and the
routing receipt, and can never authorize Auto (tier), the thinking effort, a
shadow noul read or a shadow chat intent, even at a zero confidence floor. A
non-boolean abstained value fails answer decoding and is refused like any other
malformed answer. Provider usage evidence and the declared fallback are
unchanged. The custom decision endpoint half of the original change is not
included.

Receipt field abstained_questions touches crates/models, cost_status and the
tui activity_detail fixture outside the slice owner list; it is additive with a
serde default.

Landed in Engine push 18 (CW-7875) with the CHANGELOG line from the review (CW-7992 REVIEW.md finding 1). Review: farm run 20261112T112213Z-leg01review-tip-faf9ea, 42 run, 42 passed, 7181 skipped; counterfactual run 20261112T112313Z-leg01review-cf-032c60 fails 2 of 3 with the guards removed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 23:01:23 -08:00
CodeWhale BotandClaude Sonnet 5.5 973d48e35c test(engine): expire the dropped-dispatch deadline only after the client call is entered (CW-8127)
The dropped-dispatch test gave the nested call a real 100 ms deadline. On a
slow runner that deadline could expire before bind_dispatch settled the
unknown-usage record, so the biased wall-clock select won with zero records
(left: 0, right: 1). Give the call a 600 s deadline, wait until the fixture
client is entered (the record is settled before the client is called), then
pause tokio time and advance 601 s so the deadline always fires after dispatch.
The wait selects on entered against the dispatch with a panic if the dispatch
ends first, inside a 10 s real-time timeout, so a regression that stops the
dispatch before the client fails at once instead of hanging. Every assertion is
unchanged. Product code is unchanged.

nextest stress 40/40 passed, 6 tests per iteration, target test 40/40 (farm run
20261112T113538Z-cw8127r1-new-33edc8); the old test under the same stress failed
1 of 20 at left: 0 (farm run 20261112T104528Z-cw8127-old-7569e0).
clippy -p codewhale-engine --all-targets --all-features -D warnings clean (farm
run 20261112T113538Z-cw8127r1-clippy-373b1e); cargo fmt --check and
scripts/preflight.sh --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 5be2da2a69 fix(app-server): plugin index names plugins by id, labels tools, signals cut name lists (CW-8049 PL-02 repair 1)
/v1/commands rows carry plugin_id (not the name) and workspace_sourced; the
index projects an active plugin's commands from those rows, labels native and
language-server plugins as tool, sources project commands as project, and
reports a cut or control-character name list under problems. Doc comment
removed.

Checks (Boat farm, Linux x86_64, at c3bc310042): nextest -p codewhale-app-server
-p codewhale-tui -E 'test(/plugin_index|command_catalog|get_v1_commands|user_registry|commands::discovery/)':
51 run, 51 passed, 6713 skipped (run 20261112T080110Z-plidx4-test-26f07e);
clippy -D warnings for engine, app-server and tui: clean
(run 20261112T080110Z-plidx4-clippy-124ba8). fmt, runtime-routes and facts
were the worker's local checks; preflight was not run on that tip.

Landing (push 18): rebased onto push 17; agent-docs.generated.json conflicted
with push 17's regeneration and was regenerated with derive-facts at each of
the two picks. docs/RUNTIME_ROUTES.md: check-runtime-routes PASS, no change.
CHANGELOG line from the round-2 review wording.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 9ca1c1c076 feat(app-server): one read route lists every plugin kind (CW-8049 PL-02)
GET /v1/apps/plugin-index projects the plugin registry, the built-in
switch catalog, skills, connectors, hooks and hand-written commands into
one list with kind, source and state. Each item embeds the row its owner's
route returns by calling that owner's handler, so a field an owner adds
appears here without a second edit; an owner that fails is named under
unavailable and the rest still list. A plugin item carries the names of the
skills, connectors, hooks, commands and profiles it adds, and those are
not listed again as items. Replaces nothing; the owners keep their routes.

A user command row now says which plugin added it (UserCommand.plugin,
CommandCatalogEntry.plugin, filled from the registry's plugin authority),
so a plugin's markdown command is not shown as hand-written. The wiring
in runtime_api.rs (mod line, route line, that field) and the
command_catalog and tui hunks are separable from plugin_index.rs.

Checks: farm nextest 6 run, 6 passed; farm clippy -D warnings clean for
engine, app-server, tui; preflight OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:19 -08:00
CodeWhale BotandClaude Fable 5.2 7179ae8d17 refactor(catalog): one model listing for both commands, and the inventory stops judging windows by name
With the readings proved equal on one captured catalog, the second copies
go:

- `route_preferences::list_models` no longer assembles its own roster
  (catalog rows, saved default, sort, dedup). It calls
  `provider_lake::listed_models_for_route`, the same function `models`
  prints.
- The routing inventory no longer asks the bundled catalog whether a model
  name has a context window to decide if a number is verified. The
  resolved route's own source decides; a route that does not resolve is
  unverified.

Farm (Linux x86_64), this commit: catalog_parity 1 test run: 1 passed;
config and engine lib tests filtered
`catalog model pricing cost_status route`: 1316 passed, 0 failed; clippy
-D warnings for config and engine (all targets, all features) and for cli
and app-server (all targets): clean.

Landing (push 18): CHANGELOG line for the pair (REF-05, CW-7971) from the
round-2 review, added in this commit.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-12 23:01:18 -08:00
CodeWhale BotandClaude Fable 5.2 117a1fa6ff fix(catalog): both model commands, the Runtime API, routing and cost read one captured catalog the same way
The new check loads one captured Models.dev catalog (the bundled seed,
sha256 7c48830c…d256) and an id-only Anthropic roster into a sealed home,
then reads three routes through `model list` / `model resolve`, `models`,
`GET /v1/providers/{id}/models`, the routing inventory and the cost
envelope: the official Anthropic route, a custom endpoint serving a
same-named model, and the Z.ai plan endpoint. It prints every reading and
fails on any disagreement.

On the previous source it found three:

- `model list` skipped a custom endpoint unless its table was literally
  named `custom`. It now lists every configured custom route under the
  route's own name.
- `model resolve` refused a custom endpoint's saved model ("no registered
  model") because the legacy registry was asked about an endpoint that owns
  its own roster. The registry is no longer consulted for those routes; the
  route resolver and a fetched roster still decide.
- The routing inventory gave a custom endpoint the context window and
  output limit of a same-named model and marked the window verified. It
  now takes the window, its source and the output limit from the resolved
  route, so a borrowed number is marked unverified and an unpublished
  output limit stays unknown.

`provider_lake::listed_models_for_route` is the list body of `models`
moved out unchanged so the check can read it.

One spelling difference is left and reported, not asserted: the inventory
normalises a listed `glm-5.1` to `GLM-5.1`.

One disagreement is also named, not hidden: a custom endpoint has no
published window, so routing still reports 200,000 for a saved model whose
name matches a known one (the router's long-context tag reads it), marked
unverified, while the Runtime API reports unknown. The check records the
Runtime reading as unknown and allows exactly that pair as a known
difference; any other window disagreement fails.

The check also pins the charge side: a custom endpoint gets no charge
(`UnknownBillingBasis`); the Z.ai plan route gets no charge
(`NotMoneyMetered`) even for a model that has a list rate, and both
surfaces show that same list rate; an id-only first-party model with no
price row reads unknown on both surfaces and produces no charge.

Farm (Linux x86_64): config, engine and app-server lib tests filtered
`catalog_parity catalog model pricing cost_status route`: 1444 passed,
0 failed; clippy -D warnings for config, engine, cli and app-server (all
targets, all features): clean.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 7e225f05ef fix(acp): a plugin tool's nested approval reaches the editor as a permission request that names the nested tool (CW-8050)
When a plugin tool asks the core to run a tool that needs approval, the ACP
projection resolves the nested execution id to the admitted plugin call and
shows the request on it. The request is built from the approval.required
payload (nested tool name, input, summary) so title, kind and rawInput show
what is being approved, and it carries no status so the running plugin call is
not reset to pending. The gates test and journey test live in acp_journey and
assert the second request names write and carries nested.txt.

Docs (RUNTIME_API, EXTENSIONS, zh_hans) and web/data/agent-docs.generated.json
updated.

Checks: nextest -p codewhale-app-server -E 'test(acp)' on the build farm,
48 run, 48 passed, 815 skipped; cargo fmt --check clean; preflight --check OK.

Landing (push 18): rebased onto push 17. The zh_hans EXTENSIONS and
RUNTIME_API paragraphs conflicted with push 17's CW-8071 wording of the same
ACP paragraph; kept push 17's text and added this change's sentences where
the English copies have them. agent-docs.generated.json regenerated with
derive-facts. CHANGELOG line from the round-2 review.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 23:01:15 -08:00
CodeWhale Bot 9ddd373849 test(ci): validate bounded macOS artifact timeout branches
The release workflow now has a platform-specific bound, but three existing helper checks accepted numeric literals only. Parse only a numeric cap or the supported macOS conditional, require both branches to stay within 1–360 minutes, and assert macOS 360 versus other platforms 210. Keep nightly at 210 and verify compiler-cache installation and activation remain Linux-only.

Validation: full Node workflow contract passed (211 consumed inputs, 11 wrapper event cases with 12 steps each, 13 hermetic invocation checks). Independent review repeated it, rejected eight in-memory workflow regressions and checked eight parser edge cases. Prior Version drift CI failures were this stale contract, not a version mismatch. Exact new-SHA CI/RC are still required before landing.
2026-11-12 22:58:22 -08:00
CodeWhale Bot dc56a2c3be ci: give macOS artifact builds room to finish without sccache
Both macOS builds in RC 43354633748 on cbc255813d exhausted the
210-minute job limit. Their logs recorded repeated sccache server
shutdowns and local fallback without a Rust compiler diagnostic.
The cache failure is observed; its share of the elapsed time is unknown.

Restrict the optional compiler cache to Linux and allow macOS artifact
jobs 360 minutes. Other platforms keep 210 minutes. Preserve the dist
profile, source SHA pinning, import checks and downstream artifact gates.
Remove the obsolete timeout comment.

Validation: actionlint with ShellCheck disabled and git diff --check
passed, independently repeated by the reviewer. No Rust source changed.
The prior full CI passed at cbc255813d; exact new-SHA CI and RC remain
required before landing. No release or publication was performed.
2026-11-12 22:27:51 -08:00
CodeWhale Bot cbc255813d fix(tui): find the codewhale executable from a per-unit build directory
Cargo 1.100 places a test executable in <profile>/build/<package>/<hash>/out,
so the acceptance lookup took out as the profile directory and searched
target/out/codewhale, which failed 146 integration and 64 cucumber tests in the
release parity gate. A test executable under an out directory now resolves the
profile directory above the nearest build directory, and one test covers the
plain and cross-target layouts.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit 3950ad2001)
2026-11-12 18:47:39 -08:00
CodeWhale Bot c8be6163e5 fix(release): qualify the two kernel32 imports Rust 1.100 adds to the Windows executables
Release candidate run 43320217667 built both 64-bit Windows targets for the
first time on the 1.100.0 stable toolchain and the import guard stopped on
kernel32.dll GetLogicalDrives and QueryDosDeviceW in codewhale, codew and
codewhale-tui. The pairs come from the Rust 1.100.0 standard library's Windows
file system code, not from a Codewhale call site.

ARM64: windows-arm64-export-probe.yml run 43330905781 on Windows 11 ARM64
26200.9725 resolved 449/449 imports of that candidate's executables (sha256
4d7dfba5...). The 2 pairs were added (473 -> 475) and the probe receipt replaces
the previous qualification fields, which move under supersedes.

x64: no native x64 export probe exists. The 2 pairs are namedApiContracts with
the Microsoft Learn page for each function (minimum supported client Windows XP
on both, read from the pages), below the unchanged 14393 floor and labelled
apart from the reporter-observed exports (471 -> 473). Floors, guard and x86
profile are unchanged.

Verified: the checker's own parse_imports over the retained llvm-objdump outputs
of the run's receipts leaves zero unqualified pairs for all six executables.
The executables were not kept, so the checker was not rerun on the bytes. Not a
14393, installer or startup run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit 6d91b04a4b)
2026-11-12 18:47:37 -08:00
CodeWhale Bot 911e70dfdb fix(ci): guard npm wrapper smoke sccache with matrix.os
The wrapper-guard contract test evaluates step conditions without a runner
context, so use matrix.os, which it supplies.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit d0da1d2c5e)
2026-11-12 18:47:34 -08:00
CodeWhale Bot 55ca20bc85 fix(ci): drop unused engine arboard and keep sccache off Windows legs
Cargo 1.100 lays build artifacts out per unit, so a rustc call for
codewhale-runtime carries about 330 search-path arguments (45K characters).
sccache expands the argument file and spawns rustc with the full line, which
Windows refuses past 32767 characters. The release-artifacts, nightly and npm
wrapper smoke Windows legs no longer enable sccache. codewhale-engine declared
arboard but never uses it; the TUI keeps its own.

Picked from ci/rc-fix b97e38645f (without its terminal.rs hunk).

Co-Authored-By: CodeWhale Bot <[email protected]>
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
(cherry picked from commit 199ab3179d)
2026-11-12 18:47:29 -08:00
CodeWhale BotandClaude Opus 5.5 622a493078 feat(tui-kit): animated data displays an agent can send as JSON (CW-8202)
The vendored terminal kit could move things (MotionStep, MotionPolicy) but
had nothing that shows numbers. This adds three displays and a data
description of them:

- DataSparkline: one row, newest values on the right, with now/min/max in
  words. DataBars: a titled list of bars in eighth-cell steps that grow
  with a stagger and glide from previous values. DataTrend: a braille line
  chart with its range on the axis; it draws in from the left with a
  body-ink pen and falls back to a sparkline when too small.
- Every value is in the words, so colour and ink never carry a fact alone,
  and ASCII and NoColor profiles keep the rules. Draw-in reads a caller
  progress from the new MotionStep::drawn, so reduced and still motion
  paint the end state and the kit still never reads a clock.
- DataDisplay is the same vocabulary as serde JSON, borrowing the UI SDK
  idea (vercel-labs/ui) that a component says what it is and holds as data
  so an agent reads and writes facts, not pixels. from_json validates at
  the trust boundary (finite numbers, <=1024 points, <=24 bars, labels
  <=120 chars, trend rows 3..=16); describe() gives the plain-text facts;
  DATA_DISPLAY_SCHEMA is the tool schema.
- Gallery entries (6), examples/data.rs (live dashboard, --json FILE for an
  agent-sent display, --text, --svg DIR), CHANGELOG and COMPONENTS rows.

Not in this slice: an Engine tool or transcript event that lets the model
emit a DataDisplay. README's gallery count (199) and rendered previews are
not regenerated for the 6 new entries.

Checks (kit crate, standalone target): cargo test --lib 137 passed, 0
failed (13 new in components::chart, incl. rule checks over 9 profiles x
40/80/120 cols and an insta snapshot); cargo test --doc 11 passed; cargo
clippy --all-targets -D warnings clean; cargo fmt --check clean. No Engine
crate glob-imports the kit or uses the new names; Engine build not run
locally, CI covers it on landing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 18:11:16 -08:00
CodeWhale BotandClaude Opus 5.5 8aa7707d04 fix(web): list MannXo with the post-0.10.3 contributors and drop the duplicate Qwen Code line (CW-8181)
5d7ca06a5a added @MannXo to RELEASE_CONTRIBUTORS, the website's 0.10.3
credits, but #7293 landed after 0.10.3: docs/CONTRIBUTORS.md lists MannXo in
"Unreleased — landed after v0.10.3", and the 0.10.3 "### Contributors" ledger
in CHANGELOG.md does not name them. public-copy.test.ts ("keeps
current-release website credits in exact changelog parity") then failed, which
failed the Lint & Type Check job of web.yml run 43344178264 and skipped the
Cloudflare deploy. MannXo moves to UNRELEASED_CONTRIBUTORS beside
SamhandsomeLee, matching the contributor doc.

The same commit meant to rewrite the Qwen Code changelog line with (#7293) but
added a second copy; the old "(PR 7293)" line is removed from CHANGELOG.md and
crates/tui/CHANGELOG.md, keeping the (#7293) form the release-note receipt
check looks for.

Evidence (web/, worktree claude-CW-8181b, on 97c1ad923d):
- public-copy.test.ts: passes (part of 5 files, 109/110; the one failure is
  ratatui/catalogue "Test timed out in 5000ms" with load average ~60)
- npm run check: exit 0 (facts OK, docs PASS, runtime routes 243 PASS,
  next build 979/979 static pages)
- npm test: 1303/1306; the 3 failures are 5 s timeouts in
  public-installer-contract and ratatui/catalogue that move between runs
  under that load, in files this change does not touch
- scripts/check-contributor-credit.py: "Every contributor in the window is
  credited on all three surfaces."

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 17:37:28 -08:00
CodeWhale BotandClaude Opus 5.5 97c1ad923d feat(web): first-time guide for people who have never used a terminal (CW-8181)
New docs page /docs/first-time ("Use Codewhale for the first time", en + zh)
on the existing DocArticle renderer and docs-map registry. It covers opening a
terminal on macOS, Windows and Linux; installing and checking the version;
choosing the folder Codewhale works in and when that choice matters; opening a
terminal in a folder from Finder (Services > New Terminal at Folder),
File Explorer (Open in Terminal / Open PowerShell window here), `cd` + drag,
or `codewhale -C <dir>`; first run and provider choice; a first task; and
Esc, /undo, /diff, /receipts, /exit and `codewhale --continue`.

Every claim was checked against the v0.10.2 release, not main:
- workspace = current directory or -C/--workspace (resolve_workspace)
- file tools stay in the workspace unless /trust add, /trust on or Full Access
- Seatbelt on macOS, bubblewrap on Linux only when installed, no Windows sandbox
- snapshots and /undo are off in /, home, and ~/Desktop, Documents,
  Downloads, Library, Movies, Music and Pictures themselves
  (unsafe_workspace_snapshot_reason); snapshots do not need git
- no-git folders ask before edits ("No Git repository here, so edits ask
  first." in the v0.10.2 locale); the page avoids Accept Edits and the
  0.10.3 permission dial, and is now in the docs-release guard that keeps
  them out of every docs body while v0.10.2 is the published release
- Finder service names read from Terminal.app's Info.plist and its zh_CN
  ServicesMenu.strings on macOS 26.1

The install page gets a short "New to the terminal?" block for macOS and
Windows that links to the guide; llms.txt and the install markdown carry the
same steps (llms-txt contract test). The page also adds the topic to the
docs search index, the fallback topic list, and the GT catalog registry.

Regenerated web/data/agent-docs.* with npm run prebuild. That also picks up
docs/RUNTIME_API.md drift from 2c9174012e, which had left check:facts
failing with "generated agent docs are stale".

The new copy assumes the main web/public/install.sh (CW-8036), which adds the
PATH block itself; the live site serves the older installer until the next
web.yml dispatch, which ships this page and that installer together.

Evidence (web/, this worktree):
- npm test: vitest 92 files, 1306 passed, 0 failed
- npm run check:facts OK; check:docs PASS; check:locales PASS; gt-site check OK
- eslint (touched files) clean; tsc --noEmit exit 0
- next build: compiled, 979/979 static pages, including /{en,zh,ja}/docs/first-time
  and /docs-markdown/first-time.md
- next dev, built-in browser: /en/install and /en/docs/first-time at 583px,
  /zh/install and /zh/docs/first-time at 375px; no console errors, no
  horizontal page overflow, sidebar link and all internal links resolve
- main's installer in a throwaway HOME (zsh, default macOS PATH): installed
  v0.10.2, appended the marked block to ~/.zshrc, new `zsh -i` found
  codewhale 0.10.2

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 17:16:44 -08:00
CodeWhale BotandClaude Sonnet 5.5 00cf9f002d fix(tui): satisfy clippy 1.100 needless_range_loop and unreachable_code
Iterate with enumerate in the context menu rows and two transcript tests, and
match the uninhabited backend error in the probe test instead of formatting it.
Behaviour unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:36 -08:00
CodeWhale BotandClaude Sonnet 5.5 2cd5969e31 chore(web): regenerate agent docs for the CONTRIBUTORS entry and RUNTIME_API
npm run prebuild; the generated snapshot was stale for RUNTIME_API.md on main
and now also carries the CONTRIBUTORS.md change.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:28 -08:00
CodeWhale BotandClaude Sonnet 5.5 5d7ca06a5a chore(credit): credit MannXo for the Qwen Code registry change and cite the issue number
Map the commit author's name and email to the MannXo account, list them in
CONTRIBUTORS and the website release credits, and write the existing Qwen Code
line as (#7293) so the feature release-note receipt check finds it. check-
contributor-credit.py and check-versions.sh --range-audit-advisory pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 31178aec57 fix(engine): collapse four needless_bool tails flagged by clippy 1.100
Return the condition directly in auto_review tag-like check, fleet start_leased
result, DSH overlay_granted (cfg split) and ledger contract overlap. Behaviour
unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 17:07:08 -08:00
CodeWhale BotandClaude Opus 5.5 5500fee639 docs(changelog): MCP and LSP clients answer server requests (PR 7319)
Merged as its author after two independent reviews (Opus 5.5, high;
correctness and attacker lenses, both "merge" at 8e8cd373ad) with every
check green on the PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 15:17:22 -08:00
落尘 30952497ca fix(mcp,lsp): answer server requests instead of taking them as replies (#7319)
* fix(mcp,lsp): answer server requests instead of taking them as replies

A JSON-RPC message with a `method` is a request or a notification, never
a reply, but both clients matched any message carrying an `id` against
their own pending requests. Servers number their requests independently,
so a server `ping` (MCP) or `workspace/configuration` (LSP) that shared an
id with an in-flight request resolved that request with no result, the
real reply was then dropped, and the server's request was never answered.

Both clients now skip messages with a `method` when matching replies and
answer server requests: MCP replies to `ping` with an empty result and to
anything else with -32601; LSP replies -32601 through the outbound queue
without blocking the dispatcher.

Tests: cargo test -p codewhale-lsp (65 passed); cargo test -p
codewhale-engine --lib mcp:: (371 passed). The two new tests fail on main.

Fixes #7306

* fix(mcp): keep the in-flight reply when answering a server request fails

The reply to a server request (`ping`, or -32601 for anything else) was
sent with `self.send(reply).await?`. A failed send failed the
`tools/call` it interrupted, even though the server's real answer was
still on its way, and marked the connection disconnected. Over HTTP it
was worse: a 404/405/406/415/501 on that POST made the transport fall
back to legacy SSE, dropping the Streamable HTTP messages still queued
for the request in flight.

The reply now goes through a new `McpTransport::send_reply`, which
defaults to `send`; HttpTransport overrides it to report a refusal
without changing mode. A failure is logged at debug level and not
propagated, and the connection state is left alone.

Tests: cargo test -p codewhale-engine --lib mcp:: (373 passed); clippy
--all-targets -D warnings clean. The two new tests fail without this
change.
2026-11-13 07:16:25 +08:00
CodeWhale BotandClaude Opus 5.5 9c10aabf78 docs(changelog): five contributor fixes merged today
Session titles count characters (PR 7322), non-ASCII paths shown as written
(PR 7317), session index entries after a torn line and snapshot retention
(PR 7316), blank environment variables no longer hide their aliases
(PR 7315), and Qwen Code as an external agent (PR 7293). Each merged as its
author after an independent review (Opus 5.5, high; two lenses on PR 7293)
with green required checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 13:53:22 -08:00
Parman Mohammadalizadeh 2c9174012e feat(engine): add Qwen Code to the external agent registry (#7293)
Qwen Code starts in ACP mode with `qwen --acp`, the flag that replaced
`--experimental-acp` in 0.6.1, so that is the version floor. Sign-in is
`/auth` inside an interactive session (`qwen auth` was removed), so the
row has no login command.
2026-11-13 05:52:26 +08:00
落尘 898c192257 fix(config): read every env alias chain through env_non_blank (#7315)
* fix(config): read every env alias chain through env_non_blank

env_non_blank exists so that a variable exported empty does not shadow
its legacy alias, but most of EnvRuntimeOverrides::load still read
`env::var(A).or_else(|_| env::var(B)).ok().filter(non_blank)`. An empty
A returns Ok(""), the or_else never runs, the filter then drops the
empty value, and a set B is ignored. CODEWHALE_MODEL= with
DEEPSEEK_MODEL set resolved to the default model; the same held for
CODEWHALE_BASE_URL, CODEWHALE_HTTP_HEADERS, the NVIDIA, Volcengine,
Wanjie, MiMo, Moonshot/Kimi, Hugging Face, Codex, Z.ai and StepFun
alias chains, and CODEWHALE_FORCE_HTTP1 / CODEWHALE_STREAM_OPEN_TIMEOUT_SECS.

Route all of them through env_non_blank, which also replaces about 240
lines of repeated filter chains. CODEWHALE_TELEMETRY_ENDPOINT keeps
reading an empty value on purpose, and CODEWHALE_YOLO is unchanged
because falling through to DEEPSEEK_YOLO would widen approval.

The new test fails on main (model None instead of legacy-model) and
passes here: codewhale-config lib 833 passed, 0 failed.

Refs #7297

* chore: keep changelog edits out of the branch

* fix(ci): accept env_non_blank chains in the provider registry env check

The provider registry drift check looked for the Hugging Face base URL
and model aliases only as literal std::env::var("NAME") calls, so it
failed once those chains read through env_non_blank. An
env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]) list states the
same lookup order, so it now satisfies the check; any other spelling
still falls back to the ordered std::env::var search.

python3 scripts/check-provider-registry.py: passed (failed before this
change on this branch).
2026-11-13 05:52:18 +08:00
落尘 3f2a019444 fix(state,snapshot): keep index entries after a torn line and clamp prune age (#7316)
* fix(state,snapshot): keep index entries after a torn line and clamp prune age

Session index: a crash mid-append leaves a final line with no trailing
newline. The next append_thread_name wrote straight after it, so the new
entry joined the fragment, failed to parse and was skipped, and the name
the caller had just saved was lost. Start the entry on a new line when
the file does not end in one. The existing torn-line test wrote its
fragment with writeln!, so it never covered the unterminated case.

Snapshot prune: cutoff was `now - max_age.as_secs() as i64`. A
max_age_days above about 1.07e14 (for example i64::MAX as "keep
forever") gives more than i64::MAX seconds after saturating_mul, the
cast wraps negative, the cutoff lands in the future and every snapshot
is pruned at boot. Clamp to i64::MAX and use saturating_sub.

Both new tests fail on main (thread-3 lookup None; 1 snapshot removed
instead of 0) and pass here: codewhale-state 28 passed,
codewhale-snapshot 92 passed.

Refs #7298

* chore: keep changelog edits out of the branch
2026-11-13 05:52:10 +08:00
落尘 eef19b9d30 fix: show non-ASCII paths as written in /diff and git mentions (#7317)
* fix: show non-ASCII paths as written in /diff and git mentions

Git quotes paths with non-ASCII bytes as octal escapes by default
(core.quotePath=true). /diff, the @git and @diff mentions and the
Runtime API workspace diff passed that output on unchanged, so a file
named `中文 目录/笔记 📝.md` reached the user and the model as
"\344\270\255...". Git::review_base now sets core.quotePath=false next
to its other runtime overrides, and the two /diff readers that do not
go through it (the snapshot work-tree diff and the workspace git
fallback) pass -c core.quotePath=false.

The working set also recorded the workspace root as an empty
"Active paths" entry ("-  (dir)") when a message carried the bare
workspace path, which an @-mention of a non-ASCII file name does
because the path regex stops at the first non-ASCII segment. An empty
relative path is no longer recorded.

Tests: codewhale-snapshot work_tree_changes_since (2 passed),
codewhale-runtime working_set (34 passed), codewhale-tui git_mention
(15 passed), codewhale-dependencies (55 passed). The new snapshot and
working-set tests fail without the fix.

Refs #7303

* chore: keep changelog edits out of the branch
2026-11-13 05:52:02 +08:00
落尘 f9236ad8f7 fix(tui): count characters, not bytes, for the session title limit (#7322)
Renaming a session rejected a 36-character Chinese title as too long because the check used byte length against 100. Use MAX_SESSION_TITLE_CHARS with a character count, matching the 1–100 characters message.
2026-11-13 05:51:54 +08:00
CodeWhale BotandClaude Opus 5.5 5dac4e1797 fix(credit): say what GitHub actually counts, and fail when a harvest, trailer or noreply id would leave someone uncounted
GitHub's contributor graph takes commit authors and the Co-authored-by
trailers in a commit's final trailer block whose address is linked to an
account. Accounts such as czf0718, baendlorel, OctoBored, PurplePulse and
RinZ27 appear in it with no authored commit on main, so the old wording
("counts commit authors only") in AGENTS.md, cw-land, AUTHOR_MAP and the
wall script was wrong, and it hid the honest fix for lost credit: a later
commit with a well-formed trailer.

The audit behind this found three ways credit was lost that nothing caught:
a harvest line with no trailer (seven people harvested in the v0.8 releases), a
trailer separated from the trailer block by a blank line (dmitri-0 in
d19a6eaa75), and a noreply address whose numeric id belongs to someone else
(ddlw's 17 commits use 13579959+ddlw, which GitHub credits to another
account). check-contributor-credit.py now reads every commit's trailers in one git log
call with %(trailers:key=Co-authored-by,unfold), the parser git
interpret-trailers uses, and, across the release window, fails on each of
these unless some commit in the window credits that person in a way GitHub
links: a noreply address whose numeric id matches AUTHOR_MAP, or a raw
email AUTHOR_MAP lists for them. A bare name, or an address the map does not
vouch for, credits nobody. A commit whose subject starts with chore(credit):
and whose author is a maintainer identity restores credit for older work, so
it counts toward GitHub credit but is not treated as work landing in the
release; the subject alone, or the same words in a body line, exempts
nothing.

What this does not do: a contributor whose own git config carries a wrong
noreply id fails the check in every release window where they commit, until
a chore(credit): commit in that window names them correctly (ddlw is still
active, so the next window needs either that or their own fix). Harvest
lines in a maintainer's chore(credit): commit are not checked, so real work
must not land under that subject. A Co-authored-by line quoted in a commit
body outside its trailer block is reported for the rest of the window. A raw
email listed in AUTHOR_MAP waives even if the person never linked it (the
map carries some local machine addresses for history), so an alias belongs
there only once it is linked. A harvested contributor with no AUTHOR_MAP line
fails the check until one is added, which cw-land now says.

AUTHOR_MAP: the header describes trailers and the numeric id; wdw8276 and
angus-guo now point at their renamed accounts (mars-base, imgusguo, same ids);
bengao168 and 894876246, the handles two harvest lines used, map to gaord and
idling11. Four identities GitHub already counts are added so the stricter
waiver still sees them: the commit emails [email protected] and
[email protected] (LING71671), and the logins BX166 and c020627, each with
the id from gh api users/<login>. .mailmap unifies the commit identities found in the audit for
git shortlog (296 to 294 names); GitHub reads neither file.

Checks: python3 scripts/check-contributor-credit.py exits 0 on the v0.10.2
window with the credit commit (16 contributors, all credited); on origin/main
without it, the same script exits 1 and lists 17 ddlw commits with a foreign
noreply id. On the v0.8.40 window (13,286 commits) the "Credit GitHub
cannot see" section lists only mky's 147e407464, whose trailer sits outside
the trailer block. With the same AUTHOR_MAP, output on both windows is
identical to the earlier version of this check apart from the added hint
lines, in 1.19 s and 0.33 s. Synthetic commits in an isolated clone of
f5a3dda975 with this script and map: a forged
"Co-authored-by: ddlw <[email protected]>" trailer, and a commit authored
as ddlw with an unlisted email, both still exit 1 with 17 ddlw lines; a
contributor commit with chore(credit): as its subject or as a body line stays
in the owed set (17 to 18); a maintainer chore(credit): commit with ddlw's
own noreply address exits 0; a harvest without a trailer, a trailer cut off
by a blank line and a foreign id for a mapped login each exit 1.
scripts/preflight.sh --check: OK. git diff --check: clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:15:17 -08:00
+21 2a0658b907 chore(credit): restore GitHub credit for 29 contributors whose work reached main unlinked
Each person below wrote code that is on main, through the pull request
named beside them, but GitHub's contributor count never saw them: the
commit used an email not linked to their account, a maintainer re-landed
or squashed the work under a maintainer identity, or the trailer was
malformed. History is not rewritten. This commit records their GitHub
identities in .github/AUTHOR_MAP and carries one Co-authored-by trailer
per person, with the numeric noreply address from the GitHub users API,
so the count can include them.

- AiurArtanis: Solarized Light theme, PR #2270 (commit c4a2f40723 used an unlinked email)
- Apeiron0w0: FocusGained recovery debounce, PR #1560 (9038e70bcb, unlinked local email)
- ChaceLyee2101: reasoning tokens in cost and CNY on zh-Hans, PR #1505; zh-CN README sync, PR #1504 (946c12b4d3, 13bfcf9e39, unlinked email)
- ddlw: installer download dedupe, PR #7107; mcp formatting, PR #7108; DSH receipt bytes on Windows, PR #7109 (commits used a noreply address whose numeric id belongs to another account)
- dmitri-0: phase strip cache hit rate, PR #4474 (a376f88ecf used a local email; the trailer in d19a6eaa75 sits outside the final trailer block)
- merchloubna70-dot: thirteen merged pull requests, PRs #597, #646, #648, #649, #652, #653, #654, #655, #658, #659, #661, #668, #669, plus re-landed work from #600, #601, #602, #645, #656, #657, #660, #671 (their commits used unlinked emails, and the squash commits are authored by a maintainer)
- mo-vic: /purge context pruning command, PR #2387 (a21e9e5c83, unlinked email)
- Jianfengwu2024: MSVC toolchain variables for Windows exec_shell, harvested from PR #1487 (383ed44fe8)
- alphapku-ux: toast stack no longer covers the composer, harvested from PR #1485 (58c39ebc91); alphapku-ux authored the pull request's one commit, and the MeAiRobot account that opened it, which may be the same person, is not credited a second time
- MMMarcinho: image_analyze tool, harvested from PR #1467 (3aaf0ad95e)
- NorethSea: update refreshes the sibling TUI binary, harvested from PR #1492 (23d3a71126)
- sandofree: Tavily and Bocha web search backends, harvested from PR #1294 (40df46c73d)
- tiger-dog: identifier underscores and the approval banner, harvested from PR #1455 (02f889f193, a57cb4dfa7)
- YaYII: opt-in /translate command, harvested from PR #1462 (d62facafac)
- wplll: cache-aware prompt diagnostics, PR #1196 (54ca5718d2); cache inspect, warmup keys, pack ordering and dedup tests, harvested from PRs #2390, #2391, #2392, #2393
- xulongzhe: ACP conversation history across turns, PR #3372 (9d2950fe59, unlinked email)
- ZhulongNT: NSIS installer and classroom admin checklist, PR #2045 (c2c36cca11, unlinked email)
- xiluoduyu: reject no-op edit_file replacements, PR #1460 (1352a65ac2, unlinked email)
- wlon: pager G/End overshoot fix, PR #1706 (v0.8.39); LSP diagnostics analysis update, PR #1130 (ad31d2bcec, trailer used an unlinked email)
- krisclarkdev: compaction keeps the user text query, PR #1704 (v0.8.39)
- THatch26: terminal resize paging fix, PR #1724 (v0.8.39)
- jeoor: installer response streams pause before parsing, PR #1860 (b94ccd3897)
- harvey2011888: loop guard halt reports Failed, PR #1859 (236ad4137d)
- duanchao-lab: terminal mode cleanup guard on early exit, PR #1630 (f23c9828b7)
- dumbjack: null bytes rejected in shell commands, PR #706 (1122bb0333)
- adity982: qualified MCP tool calls run once, PR #4756 (cccaa4f3f)
- AquaZhang: npm wrapper -v passthrough and its test, PR #959 (593ed2f900)
- disappear00: panic-prone edge cases, PR #957 (063d5d7d99)
- namezzb: cost currency persists from /config, PR #956 (d2e9f58756)

Co-authored-by: AiurArtanis <[email protected]>
Co-authored-by: Apeiron <[email protected]>
Co-authored-by: ChaceLyee2101 <[email protected]>
Co-authored-by: ddlw <[email protected]>
Co-authored-by: dmitri-0 <[email protected]>
Co-authored-by: merchloubna70-dot <[email protected]>
Co-authored-by: movic <[email protected]>
Co-authored-by: Jianfengwu2024 <[email protected]>
Co-authored-by: Alpha Wu <[email protected]>
Co-authored-by: MMMarcinho <[email protected]>
Co-authored-by: NorethSea <[email protected]>
Co-authored-by: Ye Wang <[email protected]>
Co-authored-by: tiger-dog <[email protected]>
Co-authored-by: YaYII <[email protected]>
Co-authored-by: wplll <[email protected]>
Co-authored-by: luola <[email protected]>
Co-authored-by: SONG ZHENRUI <[email protected]>
Co-authored-by: xiluoduyu <[email protected]>
Co-authored-by: Stephen Xu <[email protected]>
Co-authored-by: Kristopher Clark <[email protected]>
Co-authored-by: THatch26 <[email protected]>
Co-authored-by: Kayro <[email protected]>
Co-authored-by: harvey2011888 <[email protected]>
Co-authored-by: DC <[email protected]>
Co-authored-by: dumbjack <[email protected]>
Co-authored-by: ADITYA <[email protected]>
Co-authored-by: IceTear <[email protected]>
Co-authored-by: idontknow <[email protected]>
Co-authored-by: sailorZ <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:15:15 -08:00
CodeWhale BotandClaude Sonnet 5.5 08c1abd4ea fix(release): qualify 3 SID accessor Windows imports for 0.10.3
Release candidate run 43186389246 on f5a3dda975 stopped the Windows import
guard on advapi32.dll GetSidIdentifierAuthority, GetSidSubAuthority and
GetSidSubAuthorityCount in codewhale, codew and codewhale-tui on both 64-bit
targets (from crates/config private_directory.rs, b3e874b8fb).

ARM64: existing windows-arm64-export-probe.yml run 43197611105 on Windows 11
ARM64 26200.9725 resolved 447/447 imports of that candidate's executables
(sha256 c8c6f97d...). The 3 pairs were added (470 -> 473) and the probe receipt
replaces the 2026-11-07 qualification fields, which move under supersedes.

x64: no native x64 export probe exists. The 3 pairs are namedApiContracts with
the Microsoft Learn page for each function (minimum supported client Windows XP
on all three, read from the pages), below the unchanged 14393 floor and labelled
apart from the reporter-observed exports (468 -> 471). Floors, guard and x86
profile are unchanged.

Verified: the checker's own parse_imports over the retained llvm-objdump outputs
of the run's receipts leaves zero unqualified pairs for all six executables
(3 x64 at 455 pairs, 3 ARM64 at 447), where before it left 3 each. The
executables were not kept, so the checker was not rerun on the bytes; the next
release candidate run on the landed SHA is the real check. Not a 14393,
installer or startup run. json.tool and scripts/preflight.sh --check pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:15:11 -08:00
CodeWhale BotandClaude Sonnet 5.5 63a964429c fix(web): Plugins page names what each permission mode does with an agent-written plugin and the real skill command (CW-8087 PL-04 repair 1)
Auto-Review's reviewer model judges and Plan refuses, per the recorded ruling, so the
page no longer says approval is always asked outside Full Access. /skill install is
the command that adds a skill. The coming lead says the rest of the page is the
released version, and the meta description no longer carries a sentence that outlives
the 0.10.3 release.

Checks: vitest 92 files / 1303 tests passed, tsc exit 0, eslint exit 0, check:facts exit 0,
next build exit 0; built /en and /zh plugins pages fetched and show the new text.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:15:10 -08:00
CodeWhale BotandClaude Sonnet 5.5 8f1ef4bfef feat(web): the Plugins page says plugin for what a person adds and labels what 0.10.3 brings as coming (CW-8087 PL-04)
The page described "everything is a plugin" as if the released terminal
already listed skills, connectors and hooks as plugins. It does not: a plugin
bundle carries them, and /skills, /mcp and /hooks add them one at a time. The
title, lead and meta now say that, and the heading "One format for every
extension" becomes "What a plugin can add", since one format is not released.

What 0.10.3 adds goes in a new section, "Coming in 0.10.3, not in the current
release": one Plugins place with kind labels, Standard and Creator mode per
conversation, the approval rule for a plugin the agent wrote, and the old
Plan/Work control renamed Permissions. The section renders only while the
published release is older than 0.10.3 (pluginsComingApplies) and is in the
site search under the same condition. English and Simplified Chinese carry the
text; the other 16 languages show the English page body through the existing
fallback, and no dictionary key was added or removed, so the 18-language
parity checks are unchanged.

Also: the authoring button opens the site's own plugin-authoring page (written
from the released tag) instead of the main-branch document; the DeepSeek
Harness compatibility link says it describes current source, because that
document is not in the v0.10.2 tag; the desktop app page names Plugins as one
noun with skills, connectors and hooks inside it; docs map descriptions say a
connector is an MCP server, a hook is added, and a skill can come inside a
plugin; the unrendered product copy says a skill is shared inside a plugin.

Checks run in web/: tsc --noEmit, eslint, check:facts, check:locales,
check:docs, vitest (91 files, 1301 tests plus 2 new), next build; the built
/en, /zh and /de plugins pages were fetched and the coming section checked.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:15:08 -08:00
CodeWhale BotandClaude Sonnet 5.5 08f1797b26 docs(plugins): repair round 2 for review of CW-8071
plugin-creator step 7 says to write the Full Access plugin in an ordinary
project folder, because install refuses a name already used by the bundle
step 1 creates. English RUNTIME_API gets the same after-0.10.2 label as zh.
Agent docs regenerated; preflight --check OK.

At landing: the skills and plugins rows in docs/features.toml are
shortened to the registry's 120-character summary limit (they were 122
and 153, which failed feature_registry_matches_code); features JSON
regenerated.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:15:06 -08:00
CodeWhale BotandClaude Sonnet 5.5 a2264f350c docs(plugins): repair round 1 for review of CW-8071
Docs no longer claim a single Plugins list exists: hand-made skills, servers
and hooks are plugins of a kind, still managed in Extensions and Customize,
and the one list is stated as planned for 0.10.3. plugin-creator step 7 now
gives the install, trust with review token and enable steps that the Full
Access exception accepts. Drop the generation 22 comment. zh RUNTIME_API says
ACP plugin and MCP tools are main after 0.10.2.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:41 -08:00
CodeWhale BotandClaude Sonnet 5.5 451c78c637 docs(plugins): the docs say plugin, in English and Simplified Chinese (CW-8071)
PLUGINS, SKILLS, MCP and HOOKS open by saying everything a person adds is a plugin;
a skill, an MCP connector and a hook are each a plugin or part of one and keep their
kind as a label, with their files, trust rules and commands unchanged. Connection
stays the word for a model provider or another agent and nothing else. PLUGINS no
longer says DSH plugin UI is unsupported. features.toml names the three kinds as
plugin kinds (ids unchanged); agent docs and feature data regenerated.

Checks: scripts/preflight.sh --check (see the review packet).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:39 -08:00
CodeWhale BotandClaude Sonnet 5.5 b43716b7fc docs(plugins): correct the stale creator texts of ruling R8 (CW-8071)
plugin-creator no longer tells an agent never to automate trust: in Full Access an
agent may trust and enable a plugin it wrote and installed from its own project,
through the session's approval, recorded as the agent's (section 142.4), and code or
screen plugins point at cordis-plugin-development instead of a second format. The
0.10.2 body (generation 19) is retained and listed by hash so untouched copies
upgrade; the bundled generation is 22. DSH_PLUGIN_COMPAT now says imported client
UIs are served on the admitted seats instead of unsupported. The Simplified Chinese
EXTENSIONS and RUNTIME_API say ACP sessions get the extension host, plugin tools and
configured MCP servers, as the English does. The vocabulary script says /pod answers
Unknown command and codewhale pod is prompt text.

Checks: codewhale-skills nextest 188 passed 0 skipped; clippy -D warnings clean;
scripts/check-tui-product-vocabulary.sh exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:27 -08:00
CodeWhale BotandClaude Sonnet 5.5 4aca7d114a fix(extension-host): cordisInspect commands.register states the alias and refusal rules the registry enforces (CR-06a, CW-8073)
A built-in clash gets plugin-<plugin-slug>-<command> and is refused if invalid or taken; a name another plugin registered is refused. Catalog regenerated; --check exit 0; inspect tests 17/17 on Node and Bun; typecheck clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:26 -08:00
CodeWhale BotandClaude Sonnet 5.5 ba9b5a2642 fix(extension-host): cordisInspect catalog states the persona section and activation result contracts the host enforces (CR-06a, CW-8073)
systemPrompt.section is named deployment:persona-prefix with the order getSectionOrder returns; subagents result is a getter resolving to stopReason and output blocks. Command, avatar and hook limits corrected; non-JSON provider output is refused as DSH does. The method-existence test now checks each method on the class that serves it; a renamed shim method fails it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:24 -08:00
CodeWhale BotandClaude Sonnet 5.5 cc0b29971f feat(extension-host): cordisInspect data that describes this host (CR-06a, CW-8073)
A dependency-light cordisInspect service with list() and query() in the
shape DSH's cordis_inspect_list/query tools call. Host providers: Service,
Event, Config (Tool only when a source is supplied); client providers:
Slots and Theme. The catalog is generated from compatibility.json, the
host's refused-event list in root.ts, the shell-hook event map and the
Engine seat table, plus one hand-kept method table; manifests pass DSH's
own supported-schema check and inputs its validator. Not wired to the live
host (CR-06b); dist/ is not regenerated.

Checks: node --test test/inspect.test.mjs 15 pass 0 fail; bun test 15 pass
0 fail; npm run typecheck clean; generator --check clean; preflight OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:22 -08:00
CodeWhale BotandClaude Opus 5.5 eee770b34b docs(sandbox): Fleet workers keep the managed variable; named files fail closed; serve --mcp refuses shell (CW-8052)
CHANGELOG: the Sandbox bullet's managed-configuration sentence now says what
the four CW-8052 commits do.

Checks: round 0 nextest 987/987 passed and clippy clean on runtime, engine,
app-server and TUI; repair round on the rebased tip nextest 1058/1058 passed
(runtime lib 922, engine fleet::host 26, TUI config_stay 110); preflight OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:21 -08:00
CodeWhale BotandClaude Opus 5.5 e758fb0d98 fix(mcp): serve --mcp does not expose shell tools under a tool launcher (CW-8052)
The stdio MCP server builds its own ToolContext, whose shell manager has
no launcher, deny list or prefer_bwrap. When the loaded config carries a
managed tool_launcher, run_mcp_server now removes every expose_tools entry
that maps to exec_shell (shell, exec_shell) before the registry is built,
so no shell tool is registered, and names each one on stderr. Wiring the
launcher would also need the deny list and read guard; refusing is the
closed form the review allowed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:19 -08:00
CodeWhale BotandClaude Opus 5.5 f0bd96633b fix(fleet): local workers keep the parent's managed config variable (CW-8052)
A Fleet local worker is spawned with a cleaned environment rebuilt from
the child allow-list, which has no CODEWHALE_* names, so it lost
CODEWHALE_MANAGED_CONFIG_PATH and read the user config's
managed_config_path instead. base_env_from now carries both managed-config
names from the parent for every local worker (not only when a sessions
directory is set), and worker_env drops them from the request so an
allow-listed request cannot replace or add them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:18 -08:00
CodeWhale BotandClaude Opus 5.5 2561e2924f fix(config): a named managed file that cannot be read is a load error (CW-8052)
One helper, managed_config_file, now resolves the managed path for the
loader and for the three "who controls this" readers: the variable first
(an empty CODEWHALE_MANAGED_CONFIG_PATH defers to the legacy alias instead
of hiding it), then the config file's managed_config_path, then the
default path. The variable is read through codewhale_env_var, under the
test environment lock.

A file named by the variable or by managed_config_path that is missing,
a dangling link, a directory or unreadable used to load with no managed
layer; it now fails the load. Only a missing file at the default path
means no managed layer, and a config file's managed_config_path can no
longer point away from an existing default-path file. The readers report
Ambiguous where the loader would fail.

A provider that only a plugin declares used to fail under a managed file
with the saved-session message; it now says that plugin providers are not
usable under a managed configuration and what to do.

Tests that isolated themselves with an absent managed file now name an
empty one (no_managed_config writes it). New test covers missing,
directory, config-key and blank-variable cases; the late-provider test
now pins the error instead of passing on any Err.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 8676887110 fix(usage-reporting): installers force the kill switch for their own run; doctor names a saved opt-out as config (CW-8033)
The installers (web/public/install.sh, scripts/release/install.sh,
scripts/release/install.ps1) now run their closing `doctor --installs --brief`
with CODEWHALE_TELEMETRY=0 and CODEWHALE_NO_UPDATE_CHECK=1 for that command
only, and print one line saying usage reporting is on by default and how to
turn it off. v0.10.2 already honours both variables, so nothing needs the next
release. `doctor` is classed read-only and does not arm reporting at v0.10.2 or
main, so this is a guard that does not depend on that classification.

The dispatcher hands the child CODEWHALE_TELEMETRY with the resolved value, so
a saved opt-out read as an environment kill switch and doctor printed "(env)".
The dispatcher now also passes CODEWHALE_TELEMETRY_SOURCE; the child uses it
only to label the source, only next to the dispatcher's floor statement, and
never to change the decision. A hand-set CODEWHALE_TELEMETRY=0 still reads
"(env)".

Wording: the two clap help strings and the English TUI strings say "usage
reporting".

Checks: node install-safety.test.js 74 passed; farm nextest of the four new or
touched tests 4 passed, and a wider telemetry sweep (config, cli, tui,
localization) 206 passed, 1 failed, the failure being a test expectation fixed
in this commit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 9fe5e64377 fix(engine): withhold tools whose encoded name exceeds the route limit; make decode exact (CW-8041)
to_api_tool_name expands every character outside [A-Za-z0-9_] (a dot is
nine characters, a hyphen two), so a long qualified MCP or plugin tool name
could encode past the provider limit and fail every retry with a 400 on
tools.N.name. A shortened name could not be decoded back, so no shortening
is attempted: the encoder stays injective and nothing is truncated.

Policy, applied in the one encoder module (client/wire.rs) and used by the
chat, responses and Anthropic builders:
- A tool whose encoded name exceeds the limit is withheld through the
  existing omitted-tools path. Forcing it with tool_choice fails with an
  error giving the tool name, its encoded length, the limit and what to
  change.
- A tool name already present in message history that exceeds a documented
  limit (Anthropic, OpenAI and Codex, DeepSeek) refuses the request with
  its own message: the tool was called earlier in the session, so switch to
  a route with a larger limit or start a new session. Routes with no
  documented limit send history unchanged. History is never rewritten.
- The omitted-tools warning (core/events.rs, one message) names the tools
  and states the limits and what to do. The exact encoded length is not in
  it: the warning carries names already cut to 64 characters; adding it
  needs a new field on the warning event.

Limit rule, in tool_name_limit(provider, wire): where a provider documents
its own limit it is used; otherwise the default is the limit of the wire
the route speaks, 128 on the Anthropic Messages wire and 64 on the OpenAI
chat and responses wires, except Ollama, Ollama Cloud, vLLM, SGLang and
custom routes, which have no name limit and use 128.
- Documented, read 2026-11-10: Anthropic 128 (define-tools page,
  ^[a-zA-Z0-9_-]{1,128}$); DeepSeek 128 on its chat and Anthropic wires
  (chat-completion API reference); OpenAI 64 (OpenAPI spec, function name
  description).
- Wire default, no documented limit found: MiniMax and ModelStudio on the
  Anthropic wire (128; their Anthropic-compatible references, read
  2026-11-10, state no tool-name limit), every other Anthropic-wire route
  (128), and every other chat or responses provider including Gemini (64;
  a wire default, not a documented provider limit).

Decode: from_api_tool_name ran a lenient second pass that rewrote bare
x00002E-style text, so a tool literally named ax00002Eb decoded to a.b, a
different tool. A name that is the canonical encoding of its own exact
decode now returns that decode and skips the lenient pass, so a legal tool
name can no longer be turned into another. The cost: a model that strips
both dashes from an encoded name is no longer recovered; the old
foo_x00002Ebar expectation is changed accordingly.

Tests: encoding collision and round trip over hostile names, limit counting,
limit per wire and provider, a 100-character name offered on the
MiniMax Anthropic wire and withheld on a chat wire, over-long MCP-style
names withheld per provider in the Anthropic, chat and responses builders,
forced-tool and history refusals, and a 65 to 128 character history name sent unchanged on Ollama, Custom and MiniMax chat routes, and the warning wording. Farm: nextest -p codewhale-engine client:: and core::events:: 664 run, 664 passed;
workspace clippy (ci.yml flags) clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 14b8e1d315 fix(engine): encode the tool name in a chat function-object tool_choice (CW-8040)
map_tool_choice_for_chat encoded only the internal {"type":"tool","name"}
shape. A choice already in chat-completions form,
{"type":"function","function":{"name":"web.run"}}, fell into the
passthrough arm and named a tool the request never declared, because the
declaration is sent as web-x00002E-run. The same held for the flat
{"type":"function","name":...} form and for a bare tool-name string, both
of which forced_tool_name already treats as a forced tool.

All of these now go out as {"type":"function","function":{"name":
to_api_tool_name(name)}}. Reserved strings (auto, none, required) and a
function choice with no name are unchanged.

Shape per OpenAI's published OpenAPI spec (ChatCompletionNamedToolChoice),
read 2026-11-10. The regression test covers web.run in all four shapes and
checks the built request body against the declared tool name.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 04:14:10 -08:00
CodeWhale BotandClaude Sonnet 5.5 be4d539891 fix(app-server): match a gone store spelled through a symlink or another letter case when retiring a dead owner receipt (CW-8054)
The dead-owner discard compared the lexically normalised selected store with the
receipt's data_dir, so a home spelled through a symlinked ancestor (for example
/tmp on macOS) or in another letter case never matched and startup stayed
refused. Both sides now resolve the deepest existing ancestor and re-append the
missing tail, and on macOS and Windows the final comparison folds letter case.
Adds a symlinked-home test (unix) and a case-spelling test (macOS).

The one new canonicalize is admitted to the blocking-calls budget; it runs
inside owner_work, off the async runtime.

Checks: nextest -p codewhale-app-server --test daemon_socket 18/18 passed;
clippy -p codewhale-app-server --all-targets --all-features 0 warnings;
blocking-calls budget within budget; fmt clean.

Squashed from bf3c88bde3 and 6cdf29d72c.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:09 -08:00
CodeWhale BotandClaude Sonnet 5.5 2bc050dac1 test(engine): settle the queue fixture and bound idle reads by measured time (CW-8070)
The settled-nap test waited a fixed 2.3s for a fresh queue file to age and
bounded fingerprint reads by the nominal 3s window. On a slow runner the queue
could still be unsettled when counting began, or the sleep could overshoot and
fit more 1s naps than the bound allowed. Settle the stamp through the existing
fixture, wait for every worker to report a settled schedule, and derive the
bound from measured elapsed time. Product code is unchanged.

nextest stress 20/20 passed (farm run 20261112T061942Z-nap-stress-90898e);
clippy -p codewhale-engine --all-targets --all-features clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 04:14:06 -08:00
287 changed files with 10601 additions and 8791 deletions

No files matched your search

+3 -3
View File
@@ -48,7 +48,7 @@
tags: cnb:arch:amd64
cpus: 16
docker:
image: rust:1.88-bookworm
image: rust:1.100.0-bookworm
# codewhale-cnb-bridge GitHub App credentials from the CNB KeyStore
# (codewhale.net/codewhale-ci-secrets, github-bridge.yml), injected as
# environment variables. Values are never printed.
@@ -112,7 +112,7 @@
tags: cnb:arch:amd64
cpus: 16
docker:
image: rust:1.88-bookworm
image: rust:1.100.0-bookworm
stages:
- name: install release dependencies
script: |
@@ -163,7 +163,7 @@ main:
$:
tag_push:
- docker:
image: rust:1.88-bookworm
image: rust:1.100.0-bookworm
stages:
- name: build linux x64 release assets (static)
# The static full-LTO link can also outlive CNB's default no-output
+1 -1
View File
@@ -1,4 +1,4 @@
ARG RUST_VERSION=1.88
ARG RUST_VERSION=1.100.0
FROM rust:${RUST_VERSION}-slim-bookworm
# Native DBus headers and pkg-config are required by libdbus-sys during
+1 -1
View File
@@ -4,7 +4,7 @@
"context": "..",
"dockerfile": "Dockerfile",
"args": {
"RUST_VERSION": "1.88"
"RUST_VERSION": "1.100.0"
}
},
"customizations": {
+51 -8
View File
@@ -4,11 +4,12 @@
# alias = Display Name <[email protected]>
#
# The right-hand side must use GitHub's numeric noreply address so a harvested
# commit authored with it links to the contributor's account. Only commit
# authorship puts someone in the repository's contributor list; a co-author
# trailer shows on their profile but not there. The left-hand side may be a
# GitHub login, old-style noreply address, raw email from a contributor commit,
# or local machine email seen in older harvested history.
# commit authored with it, or a Co-authored-by trailer in the commit's final
# trailer block, links to the contributor's account. GitHub resolves the
# address by its numeric id, so the id must be the contributor's own
# (`gh api users/<login> --jq .id`). The left-hand side may be a GitHub login,
# old-style noreply address, raw email from a contributor commit, or local
# machine email seen in older harvested history.
hmbown = Hmbown <[email protected]>
[email protected] = Hmbown <[email protected]>
@@ -50,6 +51,7 @@ Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
idling11 = idling11 <[email protected]>
Hanmiao Li = idling11 <[email protected]>
[email protected] = idling11 <[email protected]>
894876246 = idling11 <[email protected]>
angziii = angziii <[email protected]>
aboimpinto = aboimpinto <[email protected]>
Paulo Aboim Pinto = aboimpinto <[email protected]>
@@ -79,19 +81,24 @@ Vince = Liu-Vince <[email protected]>
[email protected] = Liu-Vince <[email protected]>
LI-Jialu = LI-Jialu <[email protected]>
JiarenWang = JiarenWang <[email protected]>
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
wdw8276 = kitty <3972439+mars-base@users.noreply.github.com>
mars-base = kitty <[email protected]>
[email protected] = kitty <[email protected]>
pengyou200902 = pengyou200902 <[email protected]>
linzhiqin2003 = linzhiqin2003 <[email protected]>
LING71671 = LING71671 <[email protected]>
[email protected] = LING71671 <[email protected]>
JasonOA888 = JasonOA888 <[email protected]>
Inference1 = Inference1 <[email protected]>
hongqitai = hongqitai <[email protected]>
gordonlu = gordonlu <[email protected]>
angus-guo = gus <217034332+angus-[email protected]>
gus.guo@tec-do.com = gus <217034332+angus-[email protected]>
angus-guo = gus <217034332+im[email protected]>
imgusguo = gus <217034332+im[email protected]>
[email protected] = gus <[email protected]>
gaord = gaord <[email protected]>
Ben Gao = gaord <[email protected]>
[email protected] = gaord <[email protected]>
bengao168 = gaord <[email protected]>
MXAntian = MXAntian <[email protected]>
[email protected] = MXAntian <[email protected]>
DarrellThomas = Darrell Thomas <[email protected]>
@@ -247,6 +254,7 @@ [email protected] = zhuowp <[email protected]>
[email protected] = 秋月凉梦 <[email protected]>
asto18089 = asto18089 <[email protected]>
[email protected] = asto18089 <[email protected]>
xiechimon = Xmon Dai <[email protected]>
yrk111222 = Rongkun Yan <[email protected]>
VincentCorleone = Vincent Corleone <[email protected]>
@@ -278,3 +286,38 @@ Felixkw12 = Felix Wunderlich <[email protected]>
[email protected] = Felix Wunderlich <[email protected]>
Felix Wunderlich = Felix Wunderlich <[email protected]>
[email protected] = luochen211 <[email protected]>
AiurArtanis = AiurArtanis <[email protected]>
[email protected] = AiurArtanis <[email protected]>
Apeiron0w0 = Apeiron <[email protected]>
[email protected] = Apeiron <[email protected]>
[email protected] = ChaceLyee2101 <[email protected]>
AmosLeeeeeeee = ChaceLyee2101 <[email protected]>
ddlw = ddlw <[email protected]>
mo-vic = movic <[email protected]>
[email protected] = movic <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
xulongzhe = luola <[email protected]>
[email protected] = luola <[email protected]>
ZhulongNT = SONG ZHENRUI <[email protected]>
[email protected] = SONG ZHENRUI <[email protected]>
xiluoduyu = xiluoduyu <[email protected]>
[email protected] = xiluoduyu <[email protected]>
wlon = Stephen Xu <[email protected]>
[email protected] = Stephen Xu <[email protected]>
krisclarkdev = Kristopher Clark <[email protected]>
THatch26 = THatch26 <[email protected]>
jeoor = Kayro <[email protected]>
harvey2011888 = harvey2011888 <[email protected]>
duanchao-lab = DC <[email protected]>
dumbjack = dumbjack <[email protected]>
[email protected] = dumbjack <[email protected]>
AquaZhang = IceTear <[email protected]>
disappear00 = idontknow <[email protected]>
namezzb = sailorZ <[email protected]>
alphapku-ux = Alpha Wu <[email protected]>
BX166 = BX166 <[email protected]>
c020627 = c020627 <[email protected]>
[email protected] = Parman Mohammadalizadeh <[email protected]>
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
+119 -19
View File
@@ -248,7 +248,7 @@ assert.match(nightly, /startsWith\(matrix\.target, 'aarch64-'\).*runner\.arch ==
const nightlyArmMuslSetup = namedStep(nightly, "Install Linux ARM64 musl toolchain");
assert.match(nightlyArmMuslSetup, /matrix\.target == 'aarch64-unknown-linux-musl'/);
assert.match(nightlyArmMuslSetup, /apt-get install -y binutils musl-tools/);
assert.match(nightlyArmMuslSetup, /rustup target add --toolchain stable aarch64-unknown-linux-musl/);
assert.match(nightlyArmMuslSetup, /rustup target add aarch64-unknown-linux-musl/);
const nightlyArmStaticSmoke = namedStep(
nightly,
"Verify static Linux ARM64 binary and launch",
@@ -373,7 +373,7 @@ assert.deepEqual([...new Set(valuesForKey(artifacts, "target"))].sort(), expecte
const releaseMuslBuild = namedStep(artifacts, "Build static Linux binaries (musl)");
assert.match(releaseMuslBuild, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
assert.match(releaseMuslBuild, /apt-get install -y binutils musl-tools/);
assert.match(releaseMuslBuild, /rustup target add --toolchain stable \$\{\{ matrix\.target \}\}/);
assert.match(releaseMuslBuild, /rustup target add \$\{\{ matrix\.target \}\}/);
assert.match(
releaseMuslBuild,
/cargo build --profile dist --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
@@ -810,6 +810,20 @@ assert.doesNotMatch(
// A job that calls a reusable workflow (`uses:`) cannot carry the key at all —
// GitHub rejects it — so the callee owns its own caps. That is why the artifact
// bounds live in release-artifacts.yml rather than in its callers.
function timeoutMinutes(value) {
const literal = /^\d+$/.test(value);
const conditional = value.match(
/^\$\{\{\s*startsWith\(matrix\.os,\s*'macos-'\)\s*&&\s*(\d+)\s*\|\|\s*(\d+)\s*\}\}$/,
);
if (!literal && !conditional) return null;
const macos = Number(literal ? value : conditional[1]);
const other = Number(literal ? value : conditional[2]);
if (![macos, other].every((cap) => Number.isInteger(cap) && cap > 0 && cap <= 360)) {
return null;
}
return { macos, other };
}
function jobsWithoutTimeout(source) {
const lines = source.split("\n");
const jobsAt = lines.findIndex((line) => /^jobs:\s*$/.test(line));
@@ -823,7 +837,8 @@ function jobsWithoutTimeout(source) {
for (let j = i + 1; j < lines.length; j += 1) {
if (/^ [A-Za-z0-9_-]+:\s*$/.test(lines[j])) break;
if (/^ uses:/.test(lines[j])) reusable = true;
if (/^ timeout-minutes:\s*\d+\s*$/.test(lines[j])) capped = true;
const timeout = lines[j].match(/^ timeout-minutes:\s*(.+?)\s*$/);
if (timeout && timeoutMinutes(timeout[1])) capped = true;
}
if (!reusable && !capped) offenders.push(header[1]);
}
@@ -862,26 +877,41 @@ for (const [name, source] of [
);
}
// The Windows artifact build historically runs 40-45 minutes, so its cap has to
// keep real margin — a tight bound here fails healthy releases.
const buildTimeout = artifacts.match(/^ build:\n(?:.*\n)*? timeout-minutes: (\d+)$/m);
assert.ok(buildTimeout, "release-artifacts build job must be capped");
assert.ok(
Number(buildTimeout[1]) >= 60,
`artifact build cap ${buildTimeout[1]}m leaves no margin over a healthy 40-45m Windows build`,
);
function jobTimeout(source, job) {
function jobTimeout(source, job, platform = "other") {
const match = source.match(
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (\\d+)$`, "m"),
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (.+)$`, "m"),
);
assert.ok(match, `${job} must declare timeout-minutes`);
return Number(match[1]);
const caps = timeoutMinutes(match[1].trim());
assert.ok(caps, `${job} must declare a bounded timeout`);
return caps[platform];
}
// Fast setup and packaging fail quickly. Native builds need the same finite
// cold-build allowance as full CI: both 0.10.1 macOS artifacts hit the old
// 90m compilation cap before reaching their launch and inventory checks.
const macosTimeout = "${{ startsWith(matrix.os, 'macos-') && 360 || 210 }}";
assert.deepEqual(timeoutMinutes(macosTimeout), { macos: 360, other: 210 });
for (const invalid of [
"0", "361", "Infinity", "${{ matrix.timeout }}",
"${{ startsWith(matrix.os, 'macos-') && 361 || 210 }}",
"${{ startsWith(matrix.os, 'macos-') && 360 || 0 }}",
]) {
assert.equal(timeoutMinutes(invalid), null, `${invalid} must not count as a bounded cap`);
}
assert.deepEqual(
jobsWithoutTimeout(`jobs:\n capped:\n timeout-minutes: ${macosTimeout}\n`),
[],
);
assert.equal(jobTimeout(artifacts, "build", "macos"), 360);
assert.equal(jobTimeout(artifacts, "build"), 210);
const artifactCacheSetup = artifacts.match(
/ - uses: mozilla-actions\/sccache-action@[^\n]+\n(?:(?! - ).*\n)*/,
);
assert.ok(artifactCacheSetup, "artifact compiler cache setup must be explicit");
assert.match(artifactCacheSetup[0], /^ if: runner\.os == 'Linux'$/m);
assert.match(
namedStep(artifacts, "Enable sccache"),
/^ if: steps\.sccache\.outcome == 'success' && runner\.os == 'Linux'$/m,
);
assert.equal(jobTimeout(candidate, "resolve"), 10);
assert.equal(jobTimeout(candidate, "web"), 15);
assert.equal(jobTimeout(artifacts, "pin"), 10);
@@ -894,7 +924,7 @@ for (const job of ["bundle", "windows-installer", "assemble", "smoke"]) {
}
assert.equal(
jobTimeout(nightly, "build"), jobTimeout(artifacts, "build"),
"nightly and release must share the native cold-build allowance",
"nightly and non-macOS release builds must share the cold-build allowance",
);
assert.equal(jobTimeout(release, "resolve"), 10);
// The 0.10.1 cold parity build exhausted 45 minutes before tests started.
@@ -904,6 +934,76 @@ assert.ok(
"release parity must allow the full CI suite's cold-build budget",
);
const pinnedToolchain = read("rust-toolchain.toml").match(/^channel\s*=\s*"([^"]+)"/m)[1];
assert.match(pinnedToolchain, /^\d+\.\d+\.\d+$/, "rust-toolchain.toml must pin an exact Rust release");
const msrv = read("Cargo.toml").match(/^rust-version\s*=\s*"([^"]+)"/m)[1];
assert.match(msrv, /^\d+\.\d+$/, "Cargo.toml must declare a minor MSRV");
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const stripWorkflowComments = (source) =>
source
.split("\n")
.map((line) => line.replace(/(^|\s)#.*$/, ""))
.join("\n");
const workflowDir = path.join(repoRoot, ".github", "workflows");
const workflowSources = new Map();
for (const file of fs.readdirSync(workflowDir).filter((name) => /\.ya?ml$/.test(name)).sort()) {
workflowSources.set(file, stripWorkflowComments(read(`.github/workflows/${file}`)));
}
const ciSource = workflowSources.get("ci.yml");
const msrvHeader = /^ msrv:[ \t]*$/m.exec(ciSource);
assert.ok(msrvHeader, "ci.yml must keep an MSRV job");
const msrvStart = msrvHeader.index;
const afterMsrvHeader = msrvStart + msrvHeader[0].length;
const nextJob = /^ \S/m.exec(ciSource.slice(afterMsrvHeader));
const msrvEnd = nextJob ? afterMsrvHeader + nextJob.index : ciSource.length;
const selectorValue = (value) => value.trim().replace(/^(["'])(.*)\1$/, "$2");
const inMsrvJob = (index) => index >= msrvStart && index < msrvEnd;
for (const [file, source] of workflowSources) {
const selectors = [
...[...source.matchAll(/dtolnay\/rust-toolchain@([^\s]+)/g)].map((m) => ["action ref", m[1], m.index]),
...[...source.matchAll(/^[ \t]+toolchain:[ \t]*([^\r\n]*)$/gm)].map((m) => ["toolchain input", selectorValue(m[1]), m.index]),
...[...source.matchAll(/^[ \t]+rust-version:[ \t]*([^\r\n]*)$/gm)].map((m) => ["rust-version input", selectorValue(m[1]), m.index]),
...[...source.matchAll(/^[ \t]+RUSTUP_TOOLCHAIN:[ \t]*([^\r\n]*)$/gm)].map((m) => ["RUSTUP_TOOLCHAIN", selectorValue(m[1]), m.index]),
...[...source.matchAll(/\bcargo\s+\+\s*["']?([^\s"']+)/g)].map((m) => ["cargo + selector", m[1], m.index]),
];
for (const [kind, value, index] of selectors) {
if (kind === "action ref" && (/^[0-9a-f]{40}$/.test(value) || value === "master")) continue;
const expected = file === "ci.yml" && inMsrvJob(index) ? msrv : pinnedToolchain;
assert.equal(value, expected, `${file}: ${kind} ${value} must equal ${expected}`);
}
assert.doesNotMatch(source, /rustup (?:target|component) add --toolchain/, `${file}: rustup adds must follow rust-toolchain.toml`);
}
const msrvJob = ciSource.slice(msrvStart, msrvEnd);
assert.match(msrvJob, new RegExp(`RUSTUP_TOOLCHAIN: "${escapeRegExp(msrv)}"`), "the MSRV job must override rust-toolchain.toml for every step");
assert.match(msrvJob, new RegExp(`toolchain: "?${escapeRegExp(msrv)}"?`));
assert.match(msrvJob, new RegExp(`cargo \\+${escapeRegExp(msrv)} check --workspace --locked`));
assert.doesNotMatch(msrvJob, new RegExp(`\\b${escapeRegExp(pinnedToolchain)}\\b`), "the MSRV job must not select the pinned release toolchain");
const rootDockerfile = read("Dockerfile");
const devcontainerDockerfile = read(".devcontainer/Dockerfile");
const devcontainerConfig = read(".devcontainer/devcontainer.json");
const tencentCnb = read("deploy/tencent-lighthouse/cnb/cnb.yml.example");
const pinnedTag = `${pinnedToolchain}-bookworm`;
for (const [label, source] of [
["Dockerfile", rootDockerfile],
[".devcontainer/Dockerfile", devcontainerDockerfile],
]) {
assert.match(source, new RegExp(`^ARG RUST_VERSION=${escapeRegExp(pinnedToolchain)}$`, "m"), `${label} must default RUST_VERSION to the pinned toolchain`);
assert.match(source, /^FROM .*\brust:\$\{RUST_VERSION\}/m, `${label} must build from the RUST_VERSION image`);
}
assert.match(devcontainerConfig, new RegExp(`"RUST_VERSION":\\s*"${escapeRegExp(pinnedToolchain)}"`), ".devcontainer/devcontainer.json must pass the pinned RUST_VERSION build arg");
for (const [label, source] of [
[".cnb.yml", cnb],
["deploy/tencent-lighthouse/cnb/cnb.yml.example", tencentCnb],
]) {
const tags = [...stripWorkflowComments(source).matchAll(/^[ \t]*image:[ \t]*([^\r\n]*)$/gm)]
.map((m) => selectorValue(m[1]))
.filter((value) => value.startsWith("rust:"))
.map((value) => value.slice("rust:".length));
assert.ok(tags.length > 0, `${label} must declare a Rust build image`);
assert.deepEqual([...new Set(tags)], [pinnedTag], `${label}: Rust build images must match rust-toolchain.toml`);
}
console.log(
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 8-target/38-asset release candidate.",
);
+5 -5
View File
@@ -23,17 +23,17 @@ jobs:
continue-on-error: ${{ matrix.checks == 'advisories' }}
steps:
- uses: actions/checkout@v7
# rust-toolchain.toml pins `channel = "stable"` with no target, so
# rust-toolchain.toml pins `channel = "1.100.0"` with no target, so
# inside the action's alpine (musl) container rustup resolves it to
# `stable-x86_64-unknown-linux-musl` — which the container does not
# `1.100.0-x86_64-unknown-linux-musl`, which the container does not
# have. Without provisioning, the action's `rustup show` step prints
# "error: override toolchain 'stable-x86_64-unknown-linux-musl' is not
# "error: override toolchain '1.100.0-x86_64-unknown-linux-musl' is not
# installed" and then auto-installs mid-run (network-dependent ~12s
# detour, hard failure if the download stalls). `rust-version` makes the
# entrypoint run `rustup default stable` (same musl host triple) before
# entrypoint run `rustup default 1.100.0` (same musl host triple) before
# cargo-deny touches the workspace, so the toolchain file resolves to an
# already-installed toolchain on every run.
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check ${{ matrix.checks }}
rust-version: stable
rust-version: 1.100.0
+14 -12
View File
@@ -203,7 +203,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: actions/[email protected]
with:
node-version: 22
@@ -428,7 +428,7 @@ jobs:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
toolchain: 1.100.0
- uses: mozilla-actions/[email protected]
id: sccache
# The GitHub Actions cache backend is main-only, mirroring
@@ -487,7 +487,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
if: needs.changes.outputs.heavy == 'true'
with:
toolchain: stable
toolchain: 1.100.0
components: rustfmt, clippy
- uses: mozilla-actions/[email protected]
id: sccache
@@ -728,6 +728,8 @@ jobs:
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
timeout-minutes: 45
runs-on: ubuntu-latest
env:
RUSTUP_TOOLCHAIN: "1.89"
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
@@ -751,7 +753,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: mozilla-actions/[email protected]
id: sccache
# Main-only GitHub Actions cache backend; see the Safety gate job.
@@ -825,11 +827,11 @@ jobs:
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
shell: pwsh
run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- name: Install macOS LLVM tools
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
run: rustup component add llvm-tools --toolchain stable
run: rustup component add llvm-tools
- name: Omit macOS build debug metadata
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
shell: bash
@@ -1222,7 +1224,7 @@ jobs:
if: needs.changes.outputs.heavy == 'true'
shell: pwsh
run: ./scripts/installer/update-user-path.tests.ps1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
if: needs.changes.outputs.heavy == 'true'
- name: Check every workspace target compiles on Windows
if: needs.changes.outputs.heavy == 'true'
@@ -1267,7 +1269,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
@@ -1305,7 +1307,7 @@ jobs:
run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context."
- uses: actions/checkout@v7
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- uses: mozilla-actions/[email protected]
id: sccache
@@ -1313,7 +1315,7 @@ jobs:
# Main-only GitHub Actions cache backend; see the Safety gate job.
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success' && matrix.os != 'windows-latest'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
@@ -1364,7 +1366,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: mozilla-actions/[email protected]
id: sccache
# Main-only GitHub Actions cache backend; see the Safety gate job.
@@ -1423,7 +1425,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
toolchain: 1.100.0
components: rustfmt
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+3 -3
View File
@@ -67,13 +67,13 @@ jobs:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
toolchain: 1.100.0
targets: ${{ matrix.target }}
- uses: mozilla-actions/[email protected]
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
if: steps.sccache.outcome == 'success' && runner.os != 'Windows'
shell: bash
run: |
{
@@ -99,7 +99,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y binutils musl-tools
rustup target add --toolchain stable aarch64-unknown-linux-musl
rustup target add aarch64-unknown-linux-musl
- name: Build
shell: bash
# Nightly artifacts are disposable smoke binaries (14-day retention),
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Install Rust target
uses: dtolnay/rust-toolchain@stable
uses: dtolnay/rust-toolchain@1.100.0
with:
targets: aarch64-unknown-linux-ohos
- name: Install OpenHarmony native SDK
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: pet/package-lock.json
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
- run: npm --prefix pet run sync
@@ -60,7 +60,7 @@ jobs:
cache-dependency-path: pet/package-lock.json
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
if: runner.os == 'macOS'
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
if: runner.os == 'macOS'
+5 -6
View File
@@ -88,9 +88,7 @@ jobs:
build:
name: Build ${{ matrix.platform }}
# Both cold macOS artifact builds on 0.10.1 exhausted 90 minutes inside
# compilation. Keep the full CI build budget; packaging stays capped below.
timeout-minutes: 210
timeout-minutes: ${{ startsWith(matrix.os, 'macos-') && 360 || 210 }}
# FreeBSD is a source-build target with no prebuilt asset, no npm binary and no CI check
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd).
strategy:
@@ -172,13 +170,14 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
with:
toolchain: stable
toolchain: 1.100.0
targets: ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
if: runner.os == 'Linux'
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
if: steps.sccache.outcome == 'success' && runner.os == 'Linux'
shell: bash
run: |
{
@@ -216,7 +215,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y binutils musl-tools
rustup target add --toolchain stable ${{ matrix.target }}
rustup target add ${{ matrix.target }}
cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
- name: Configure Android NDK linker
if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux'
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
package-manager-cache: false
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable 2026-07-18
with:
toolchain: stable
toolchain: 1.100.0
- name: Match dispatch to the requested commit
id: source
shell: bash
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
with:
toolchain: stable
toolchain: 1.100.0
components: clippy, rustfmt
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
id: sccache
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run cargo audit
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
with:
ref: ${{ inputs.expected_sha }}
fetch-depth: 1
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@1.100.0
- uses: taiki-e/install-action@nextest
- name: Run focused tests
shell: bash
+14
View File
@@ -14,3 +14,17 @@ Hunter Bown <[email protected]> Cursor Agent <[email protected]>
Hunter Bown <[email protected]> cursoragent <[email protected]>
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
ddlw <[email protected]> <[email protected]>
dmitri-0 <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
AiurArtanis <[email protected]> <[email protected]>
Apeiron <[email protected]> <[email protected]>
ChaceLyee2101 <[email protected]> <[email protected]>
movic <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
luola <[email protected]> <[email protected]>
SONG ZHENRUI <[email protected]> <[email protected]>
xiluoduyu <[email protected]> <[email protected]>
+10 -3
View File
@@ -259,9 +259,16 @@ prefer merging the contributor's PR itself). When a contributor's work
lands as our commit, the contributor is that commit's **author** (`git commit
--author` with their GitHub-linked address), it carries `Harvested from PR #N by
@handle`, and maintainers or agents go in `Co-authored-by`. GitHub's contributor
list counts commit authors only: about 200 harvested commits authored by
maintainer identities left their contributors off it, which is why
`docs/CONTRIBUTORS.md` carries the full wall (`scripts/contributor-wall.py`).
count takes commit authors and the `Co-authored-by` trailers in a commit's final
trailer block, and only addresses linked to an account: a trailer cut off by a
blank line, an unlinked email, or a noreply address carrying someone else's
numeric id credits nobody or the wrong person. Harvested commits authored by
maintainer identities without such a trailer left their contributors off it,
which is why `docs/CONTRIBUTORS.md` carries the full wall
(`scripts/contributor-wall.py`). Credit lost in history that cannot change is
restored by a later commit with one trailer per person, naming each pull
request; `scripts/check-contributor-credit.py` fails the release window when a
harvest, a trailer or a noreply id would leave someone uncounted.
A maintainer closes their PR by hand with a thank-you naming the commit;
nothing closes a PR automatically. Canonical human identities come from
`.github/AUTHOR_MAP`.
+22
View File
@@ -7,8 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
- **Usage-reporting collector:** accepts runtime schema 6 alongside existing versions and keeps schema 6 reports first-party only.
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
Generated
+5 -27
View File
@@ -912,14 +912,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "codewhale-agent"
version = "0.10.3"
dependencies = [
"codewhale-config",
"serde",
]
[[package]]
name = "codewhale-agent-contract"
version = "0.10.3"
@@ -943,7 +935,6 @@ dependencies = [
"chrono",
"clap",
"codewhale-account-cli",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
"codewhale-build-support",
@@ -1049,6 +1040,7 @@ dependencies = [
"codewhale-paths",
"codewhale-release",
"codewhale-runtime",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-state",
"codewhale-telemetry",
@@ -1118,6 +1110,7 @@ dependencies = [
"codewhale-execpolicy",
"codewhale-paths",
"codewhale-protocol",
"codewhale-sanitize",
"codewhale-secrets",
"fd-lock",
"libc",
@@ -1141,7 +1134,6 @@ name = "codewhale-core"
version = "0.10.3"
dependencies = [
"codewhale-config",
"codewhale-hooks",
"codewhale-protocol",
"codewhale-state",
"regex",
@@ -1150,7 +1142,6 @@ dependencies = [
"sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
"tokio",
"uuid",
]
@@ -1179,14 +1170,12 @@ dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"arboard",
"async-stream",
"async-trait",
"axum",
"base64 0.23.1",
"chrono",
"clap",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
"codewhale-build-support",
@@ -1221,7 +1210,6 @@ dependencies = [
"codewhale-util",
"codewhale-workflow",
"codewhale-workflow-js",
"cucumber",
"dirs 7.0.0",
"encoding_rs",
"fd-lock",
@@ -1240,12 +1228,10 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"portable-pty",
"pretty_assertions",
"proc-macro2",
"regex",
"reqwest",
"ring",
"rio-vt",
"rmcp",
"rusqlite",
"rustls",
@@ -1261,9 +1247,7 @@ dependencies = [
"tar",
"tempfile",
"thiserror 2.0.21",
"tiny_http",
"tokio",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
@@ -1307,14 +1291,11 @@ name = "codewhale-hooks"
version = "0.10.3"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"codewhale-app-server-protocol",
"codewhale-paths",
"codewhale-protocol",
"codewhale-release",
"reqwest",
"serde",
"serde_json",
"tempfile",
"tokio",
@@ -1434,7 +1415,7 @@ version = "0.10.3"
dependencies = [
"codewhale-config",
"codewhale-runtime",
"codewhale-secrets",
"codewhale-sanitize",
"dirs 7.0.0",
"regex",
"tempfile",
@@ -1611,12 +1592,12 @@ dependencies = [
"codewhale-paths",
"codewhale-protocol",
"codewhale-release",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-util",
"colored",
"dirs 7.0.0",
"fd-lock",
"futures-util",
"ignore",
"jsonwebtoken",
"libc",
@@ -1884,9 +1865,7 @@ dependencies = [
"reqwest",
"rio-vt",
"rquickjs",
"rusqlite",
"rustls",
"schemars 1.2.2",
"semver",
"serde",
"serde_json",
@@ -1896,7 +1875,6 @@ dependencies = [
"tempfile",
"tiny_http",
"tokio",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
@@ -1920,7 +1898,7 @@ version = "0.10.3"
dependencies = [
"codewhale-palette",
"codewhale-ratatui",
"codewhale-secrets",
"codewhale-sanitize",
"crossterm",
"ratatui",
"similar",
-1
View File
@@ -1,7 +1,6 @@
[workspace]
members = [
"crates/account-cli",
"crates/agent",
"crates/agent-contract",
"crates/app-server",
"crates/app-server-protocol",
+1 -1
View File
@@ -12,7 +12,7 @@
# Or mount an env file:
# docker run --rm -it --env-file .env codewhale
ARG RUST_VERSION=1.88
ARG RUST_VERSION=1.100.0
# ── Stage 1: Build ────────────────────────────────────────────────────
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "codewhale-agent"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Model/provider registry and fallback strategy for Codewhale"
[lints]
workspace = true
[dependencies]
codewhale-config = { path = "../config", version = "0.10.3" }
serde.workspace = true
File diff suppressed because it is too large. Load diff
-1
View File
@@ -32,7 +32,6 @@ codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
codewhale-agent = { path = "../agent", version = "0.10.3" }
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-core = { path = "../core", version = "0.10.3" }
codewhale-hooks = { path = "../hooks", version = "0.10.3" }
+50 -15
View File
@@ -47,6 +47,18 @@ impl Drop for TurnClaim {
}
}
}
fn admitted_call<'a>(
calls: &'a HashMap<String, PendingToolCall>,
execution: &str,
) -> Option<(&'a str, &'a PendingToolCall)> {
let mut id = execution;
loop {
if let Some((key, call)) = calls.get_key_value(id) {
return Some((key.as_str(), call));
}
id = id.rsplit_once('.')?.0;
}
}
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
}
@@ -419,27 +431,50 @@ impl PromptRun {
&& pending.turn_id == turn
&& pending.tool_call_id.as_deref() == Some(execution)
}) {
let call = calls
.get(execution)
let (shown, call) = admitted_call(calls, execution)
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
let id = uuid::Uuid::new_v4().to_string();
let title = tool_call_title(call);
let text = p
let nested = shown != execution;
let nested_call;
let shown_call = if nested {
nested_call = PendingToolCall {
execution_id: execution.to_string(),
name: p
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or(&call.name)
.to_string(),
input: p.get("input").cloned().unwrap_or(Value::Null),
};
&nested_call
} else {
call
};
let title = tool_call_title(shown_call);
let description = p
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.map_or_else(|| title.clone(), str::to_string);
.filter(|text| !text.trim().is_empty());
let summary = p
.get("summary")
.and_then(Value::as_str)
.filter(|text| nested && !text.trim().is_empty());
let text = match (summary, description) {
(Some(summary), Some(description)) => format!("{summary}\n{description}"),
(Some(text), None) | (None, Some(text)) => text.to_string(),
(None, None) => title.clone(),
};
let mut fields = acp::ToolCallUpdateFields::new()
.title(title)
.kind(tool_call_kind(shown_call))
.raw_input((!shown_call.input.is_null()).then(|| shown_call.input.clone()))
.content(vec![acp::ToolCallContent::from(text)]);
if !nested {
fields = fields.status(acp::ToolCallStatus::Pending);
}
let request = acp::RequestPermissionRequest::new(
session.to_string(),
acp::ToolCallUpdate::new(
execution.to_string(),
acp::ToolCallUpdateFields::new()
.title(title)
.kind(tool_call_kind(call))
.status(acp::ToolCallStatus::Pending)
.raw_input((!call.input.is_null()).then(|| call.input.clone()))
.content(vec![acp::ToolCallContent::from(text)]),
),
acp::ToolCallUpdate::new(shown.to_string(), fields),
vec![
acp::PermissionOption::new(
"allow-once",
+2 -33
View File
@@ -14,7 +14,6 @@ use crate::stdio::{BoundedLines, run_stdio_loop};
use anyhow::Context as _;
use anyhow::{Result, anyhow};
use axum::http::{Method, StatusCode};
use codewhale_agent::ModelRegistry;
use codewhale_app_server_protocol::rpc::{
RequestId, RpcCall, RpcErrorDetails, RpcErrorResponse, RpcResponse,
};
@@ -23,7 +22,6 @@ use codewhale_app_server_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES;
use codewhale_app_server_protocol::runtime::RuntimeImageInput;
use codewhale_config::ConfigStore;
use codewhale_core::Runtime;
use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink};
use codewhale_protocol::EventFrame;
use codewhale_state::StateStore;
use serde::Deserialize;
@@ -364,7 +362,6 @@ pub struct AppState {
/// Bookkeeping config and read-only historical archive access.
/// Actual turns and transcript writes belong to the captured owner.
runtime: Arc<RwLock<Runtime>>,
registry: ModelRegistry,
auth_token: Option<String>,
#[cfg(test)]
runtime_bridge: Arc<Mutex<Option<SharedRuntimeBridge>>>,
@@ -797,46 +794,19 @@ async fn build_state_off_runtime(
fn build_state_with_transport(
config_path: Option<PathBuf>,
auth_token: Option<String>,
transport: AppTransport,
_transport: AppTransport,
) -> Result<AppState> {
let has_explicit_config_path = config_path.is_some();
let store = ConfigStore::load(config_path)?;
let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
let config = store.config.clone();
let registry = ModelRegistry::default();
let state_db_path = config_path
.as_ref()
.and_then(|p| p.parent().map(|parent| parent.join("state.db")));
let state_store = StateStore::open(state_db_path)?;
let mut hooks = HookDispatcher::default();
// Stdio carries JSON-RPC on stdout: printing raw hook events there
// corrupts the protocol stream (#5165). HTTP mode keeps the stdout
// sink for local development visibility.
if transport == AppTransport::Http {
hooks.add_sink(Arc::new(StdoutHookSink));
}
// The hook log sits beside the state database: next to an explicit
// config, otherwise in the directory the state store resolved under the
// Codewhale home. Never relative to the process cwd (#6513).
let hook_log_path = state_store
.db_path()
.parent()
.unwrap_or_else(|| Path::new("."))
.join("events.jsonl");
hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
if let Some(socket_path) = config
.hook_sinks
.as_ref()
.and_then(|sinks| sinks.unix_socket_path.as_ref())
.filter(|path| !path.as_os_str().is_empty())
{
hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
}
let runtime = Runtime::new(config.clone(), state_store, hooks);
let runtime = Runtime::new(config.clone(), state_store);
Ok(AppState {
captured_owner: None,
@@ -846,7 +816,6 @@ fn build_state_with_transport(
config_path,
config: Arc::new(RwLock::new(config)),
runtime: Arc::new(RwLock::new(runtime)),
registry,
auth_token,
#[cfg(test)]
runtime_bridge: Arc::new(Mutex::new(None)),
+12 -5
View File
@@ -111,7 +111,14 @@ pub(crate) async fn runtime_app_models(state: &AppState) -> Result<Vec<Value>> {
providers.len() <= 256,
"Runtime provider catalog exceeds its limit"
);
let aliases = state.registry.list();
let aliases = codewhale_config::catalog::reviewed::bundled_reviewed()
.selections
.iter()
.filter_map(|row| {
codewhale_config::ProviderKind::parse_config_identity(&row.provider)
.map(|provider| (provider, row))
})
.collect::<Vec<_>>();
let mut models = Vec::new();
let mut routes = std::collections::HashSet::new();
for provider in providers {
@@ -149,13 +156,13 @@ pub(crate) async fn runtime_app_models(state: &AppState) -> Result<Vec<Value>> {
.context("Runtime model identity is invalid")?;
let compatibility_aliases = aliases
.iter()
.find(|entry| {
.find(|(provider, row)| {
parsed != codewhale_config::ProviderKind::Custom
&& provider_id.is_none_or(|identity| identity == kind)
&& entry.provider == parsed
&& entry.id == id
&& *provider == parsed
&& row.id == id
})
.map(|entry| entry.aliases.clone())
.map(|(_, row)| row.aliases.clone())
.unwrap_or_default();
let capabilities: codewhale_config::route::RouteCapabilities =
match model.pointer("/facts/capabilities") {
+13 -1
View File
@@ -129,6 +129,7 @@ mod members;
mod memory_lens;
mod mobile;
mod plans;
mod plugin_index;
mod plugins;
pub(crate) mod review;
mod secrets;
@@ -386,7 +387,7 @@ impl RuntimeWorkspaceScopes {
let owner = self.clone();
codewhale_runtime::runtime_control::owner_work(move || {
let (canonical, directory) =
codewhale_util::workspace_dir::open_workspace_directory(&lexical)?;
codewhale_util::workspace_dir::open_pinned_workspace_directory(&lexical)?;
let directory = Arc::new(directory);
let mut scopes = owner.scopes.lock();
if let Some(scope) = scopes.get(&lexical) {
@@ -3372,6 +3373,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/skills/{name}/audit", get(audit_skill_api))
.route("/v1/apps/mcp/tools", get(list_mcp_tools))
.route("/v1/apps/plugins", get(plugins::list_plugins))
.route("/v1/apps/plugin-index", get(plugin_index::get_plugin_index))
.route("/v1/apps/avatars", get(avatars::list_avatars))
.route(
"/v1/apps/avatars/{handle}/atlases/{page}",
@@ -5875,6 +5877,10 @@ struct CommandCatalogEntry {
#[serde(skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin_id: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
workspace_sourced: bool,
#[serde(skip_serializing_if = "Option::is_none")]
registration_id: Option<String>,
}
@@ -5933,6 +5939,8 @@ fn command_catalog(
shadowed_by,
shadowed_aliases,
origin: None,
plugin_id: None,
workspace_sourced: false,
registration_id: None,
});
}
@@ -5956,6 +5964,8 @@ fn command_catalog(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: None,
plugin_id: command.plugin_id.clone(),
workspace_sourced: command.workspace_sourced,
registration_id: None,
});
}
@@ -6038,6 +6048,8 @@ async fn list_commands(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: Some(reference.origin.clone()),
plugin_id: Some(reference.plugin_id.clone()),
workspace_sourced: false,
registration_id: query
.thread_id
.as_deref()
@@ -0,0 +1,489 @@
use std::collections::{BTreeSet, HashMap};
use std::path::{Path as FsPath, PathBuf};
use axum::Json;
use axum::extract::{Query, State};
use serde::Serialize;
use serde_json::Value;
use super::plugins::{self, PluginSummaryEntry};
use super::{ApiError, CommandsQuery, HooksQuery, RuntimeApiState};
const MAX_ADDED_NAMES: usize = 512;
#[derive(Debug, Serialize)]
pub(super) struct PluginIndexResponse {
workspace: String,
items: Vec<IndexItem>,
unavailable: Vec<IndexNote>,
problems: Vec<IndexNote>,
}
#[derive(Debug, Serialize)]
struct IndexNote {
kind: &'static str,
message: String,
}
#[derive(Debug, Serialize)]
struct IndexItem {
id: String,
kind: &'static str,
name: String,
description: Option<String>,
source: &'static str,
state: &'static str,
labels: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
adds: Option<PluginAdds>,
#[serde(skip_serializing_if = "Option::is_none")]
tier: Option<&'static str>,
owner: &'static str,
row: Value,
}
#[derive(Debug, Default, Serialize)]
struct PluginAdds {
skills: Vec<String>,
connectors: Vec<String>,
hooks: Vec<String>,
commands: Vec<String>,
profiles: Vec<String>,
}
impl PluginAdds {
fn labels(&self, entry: &PluginSummaryEntry) -> Vec<&'static str> {
[
("skill", !self.skills.is_empty()),
("connector", !self.connectors.is_empty()),
("hook", !self.hooks.is_empty()),
("command", !self.commands.is_empty()),
(
"tool",
entry.inventory.native > 0 || entry.inventory.lsp > 0,
),
("profile", !self.profiles.is_empty()),
]
.into_iter()
.filter_map(|(label, present)| present.then_some(label))
.collect()
}
}
fn row_value<T: Serialize>(row: &T) -> Value {
serde_json::to_value(row).unwrap_or(Value::Null)
}
fn note_failure(unavailable: &mut Vec<IndexNote>, kind: &'static str, error: &ApiError) {
unavailable.push(IndexNote {
kind,
message: error.message.clone(),
});
}
fn plugin_source(origin: &str) -> &'static str {
match origin {
"codewhale-builtin" => "built_in",
"workspace-codewhale" => "project",
_ => "user",
}
}
fn plugin_state(entry: &PluginSummaryEntry) -> &'static str {
if matches!(
entry.trust_status,
"content-changed" | "capabilities-changed"
) || entry.state == "enabled-untrusted"
{
return "needs_review";
}
match entry.state {
"active" => "enabled",
"disabled" => "disabled",
_ => "unavailable",
}
}
fn add_stem(names: &mut BTreeSet<String>, path: &FsPath, extension: Option<&str>, lowercase: bool) {
if let Some(wanted) = extension
&& path.extension().and_then(|value| value.to_str()) != Some(wanted)
{
return;
}
if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) {
names.insert(if lowercase {
stem.to_lowercase()
} else {
stem.to_string()
});
}
}
async fn component_names(
paths: &[PathBuf],
extension: Option<&str>,
lowercase: bool,
omitted: &mut bool,
) -> Vec<String> {
let mut names = BTreeSet::new();
for path in paths {
let Ok(meta) = tokio::fs::symlink_metadata(path).await else {
continue;
};
if meta.is_file() {
add_stem(&mut names, path, extension, lowercase);
} else if meta.is_dir()
&& let Ok(mut entries) = tokio::fs::read_dir(path).await
{
let mut seen = 0;
while let Ok(Some(entry)) = entries.next_entry().await {
seen += 1;
if seen > MAX_ADDED_NAMES {
*omitted = true;
break;
}
if entry.file_type().await.is_ok_and(|kind| kind.is_file()) {
add_stem(&mut names, &entry.path(), extension, lowercase);
}
}
}
if names.len() >= MAX_ADDED_NAMES {
*omitted = true;
break;
}
}
if names.iter().any(|name| name.chars().any(char::is_control)) {
*omitted = true;
names.retain(|name| !name.chars().any(char::is_control));
}
names.into_iter().take(MAX_ADDED_NAMES).collect()
}
async fn plugin_adds(
plugin: &codewhale_plugins::types::LoadedPlugin,
omitted: &mut bool,
) -> PluginAdds {
let mut skills: Vec<String> = plugin
.skill_snapshots
.iter()
.map(|skill| skill.name.clone())
.collect();
skills.sort();
let mut connectors: Vec<String> = plugin
.manifest
.mcp_servers
.as_ref()
.map(|servers| servers.keys().cloned().collect())
.unwrap_or_default();
connectors.sort();
PluginAdds {
skills,
connectors,
hooks: component_names(&plugin.components.hooks, None, false, omitted).await,
commands: component_names(&plugin.components.commands, Some("md"), true, omitted).await,
profiles: component_names(&plugin.components.agents, Some("toml"), false, omitted).await,
}
}
async fn plugin_adds_by_id(
state: &RuntimeApiState,
omitted: &mut BTreeSet<String>,
) -> HashMap<String, PluginAdds> {
let registry = state
.plugin_discovery
.registry_for_workspace(&state.workspace);
let mut adds = HashMap::new();
for plugin in registry.list() {
let mut cut = false;
adds.insert(
plugin.id.as_str().to_string(),
plugin_adds(plugin, &mut cut).await,
);
if cut {
omitted.insert(plugin.name().to_string());
}
}
adds
}
fn skill_source(
path: Option<&PathBuf>,
is_bundled: bool,
skills_dir: &FsPath,
workspace: &FsPath,
) -> &'static str {
if is_bundled {
return "built_in";
}
match path {
Some(path) if path.starts_with(skills_dir) => "user",
Some(path) if path.starts_with(workspace) => "project",
Some(_) => "other_app",
None => "plugin",
}
}
pub(super) async fn get_plugin_index(
State(state): State<RuntimeApiState>,
) -> Result<Json<PluginIndexResponse>, ApiError> {
let mut items = Vec::new();
let mut unavailable = Vec::new();
let mut problems = Vec::new();
let commands = super::list_commands(
State(state.clone()),
Query(CommandsQuery { thread_id: None }),
)
.await;
let mut plugin_commands: HashMap<String, BTreeSet<String>> = HashMap::new();
if let Ok(Json(response)) = &commands {
for command in &response.commands {
if let (Some(plugin_id), "user") = (command.plugin_id.as_ref(), command.kind) {
plugin_commands
.entry(plugin_id.clone())
.or_default()
.insert(command.name.clone());
}
}
}
match plugins::list_plugins(State(state.clone())).await {
Ok(Json(response)) => {
let mut omitted = BTreeSet::new();
let mut adds_by_id = plugin_adds_by_id(&state, &mut omitted).await;
for name in &omitted {
problems.push(IndexNote {
kind: "plugin",
message: format!(
"plugin '{name}' adds more files than are listed, or a file name with control characters; some names are left out of `adds`"
),
});
}
for diagnostic in &response.registry_diagnostics {
problems.push(IndexNote {
kind: "plugin",
message: diagnostic.message.clone(),
});
}
for entry in &response.plugins {
let mut adds = adds_by_id.remove(&entry.id);
if entry.state == "active"
&& commands.is_ok()
&& let Some(adds) = adds.as_mut()
{
adds.commands = plugin_commands
.remove(&entry.id)
.map(|names| names.into_iter().collect())
.unwrap_or_default();
}
items.push(IndexItem {
id: format!("plugin:{}", entry.id),
kind: "plugin",
name: entry
.display_name
.clone()
.unwrap_or_else(|| entry.name.clone()),
description: entry.description.clone(),
source: plugin_source(entry.origin),
state: plugin_state(entry),
labels: adds
.as_ref()
.map(|adds| adds.labels(entry))
.unwrap_or_default(),
plugin: None,
adds,
tier: None,
owner: "/v1/apps/plugins",
row: row_value(entry),
});
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match plugins::list_plugin_catalog(State(state.clone())).await {
Ok(Json(catalog)) => {
for (tier, rows) in [
("builtin", &catalog.builtin),
("experimental", &catalog.experimental),
("more", &catalog.more),
] {
for row in rows {
items.push(IndexItem {
id: format!("plugin:catalog:{}", row.id),
kind: "plugin",
name: row.title.clone(),
description: row.description.clone(),
source: "built_in",
state: if !row.available {
"unavailable"
} else if row.enabled {
"enabled"
} else {
"disabled"
},
labels: Vec::new(),
plugin: None,
adds: None,
tier: Some(tier),
owner: "/v1/apps/plugins/catalog",
row: row_value(row),
});
}
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match super::list_skills(State(state.clone())).await {
Ok(Json(response)) => {
for warning in &response.warnings {
problems.push(IndexNote {
kind: "skill",
message: warning.clone(),
});
}
for skill in &response.skills {
if skill.plugin_id.is_some() {
continue;
}
items.push(IndexItem {
id: format!("skill:{}", skill.name),
kind: "skill",
name: skill.name.clone(),
description: Some(skill.description.clone()),
source: skill_source(
skill.path.as_ref(),
skill.is_bundled,
&response.directory,
&state.workspace,
),
state: if skill.enabled { "enabled" } else { "disabled" },
labels: vec!["skill"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/skills",
row: row_value(skill),
});
}
}
Err(error) => note_failure(&mut unavailable, "skill", &error),
}
match super::list_mcp_servers(State(state.clone())).await {
Ok(Json(response)) => {
for server in &response.servers {
let source = match server.origin {
"plugin" => continue,
"project" => "project",
"runtime" => "runtime",
_ => "user",
};
items.push(IndexItem {
id: format!("connector:{}", server.name),
kind: "connector",
name: server.name.clone(),
description: None,
source,
state: if !server.enabled {
"disabled"
} else if server.auth_required {
"needs_sign_in"
} else {
"enabled"
},
labels: vec!["connector"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/apps/mcp/servers",
row: row_value(server),
});
}
}
Err(error) => note_failure(&mut unavailable, "connector", &error),
}
match super::list_hooks(State(state.clone()), Query(HooksQuery { thread_id: None })).await {
Ok(Json(response)) => {
for problem in &response.problems {
problems.push(IndexNote {
kind: "hook",
message: problem.clone(),
});
}
let mut ordinals: HashMap<&'static str, usize> = HashMap::new();
for hook in &response.hooks {
let ordinal = ordinals.entry(hook.event).or_default();
*ordinal += 1;
let source = match hook.source {
"plugin" => continue,
"project" => "project",
_ => "user",
};
let name = hook
.name
.clone()
.unwrap_or_else(|| format!("{} {}", hook.event, ordinal));
items.push(IndexItem {
id: format!("hook:{source}:{}:{ordinal}", hook.event),
kind: "hook",
name,
description: None,
source,
state: if response.enabled {
"enabled"
} else {
"disabled"
},
labels: vec!["hook"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/hooks",
row: row_value(hook),
});
}
}
Err(error) => note_failure(&mut unavailable, "hook", &error),
}
match commands {
Ok(Json(response)) => {
for command in &response.commands {
let (source, plugin) = match (command.kind, command.plugin_id.as_ref()) {
("builtin", _) => continue,
("user", Some(_)) => continue,
("extension", plugin_id) => ("plugin", plugin_id.cloned()),
_ if command.workspace_sourced => ("project", None),
_ => ("user", None),
};
items.push(IndexItem {
id: format!("command:{}", command.name),
kind: "command",
name: command.name.clone(),
description: command.summary.clone(),
source,
state: "enabled",
labels: vec!["command"],
plugin,
adds: None,
tier: None,
owner: "/v1/commands",
row: row_value(command),
});
}
}
Err(error) => note_failure(&mut unavailable, "command", &error),
}
Ok(Json(PluginIndexResponse {
workspace: state.workspace.display().to_string(),
items,
unavailable,
problems,
}))
}
@@ -15,6 +15,7 @@ use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::sleep;
use uuid::Uuid;
mod catalog_parity;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
@@ -28,6 +29,7 @@ mod headless_catalog;
mod member_operations;
#[cfg(unix)]
mod owner_bootstrap;
mod plugin_index;
mod plugin_ui;
mod profile_constitution;
#[cfg(any(unix, windows))]
@@ -0,0 +1,600 @@
use super::*;
use codewhale_config::catalog::{
CatalogOffering, CatalogSource, CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
now_unix,
};
use codewhale_engine::cost_status::EffectiveRouteEnvelope;
use codewhale_engine::model_inventory::ModelInventory;
use codewhale_runtime::config::ProviderIdentity;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
const SINGULAR: &str = "model list / model resolve";
const PLURAL: &str = "models";
const RUNTIME: &str = "runtime api";
const ROUTING: &str = "routing inventory";
const COST: &str = "cost";
const UNKNOWN: &str = "unknown";
const ROSTER: &[&str] = &["claude-opus-5", "claude-haiku-4-5", "claude-parity-probe-1"];
type Readings = BTreeMap<(String, String, &'static str), BTreeMap<&'static str, String>>;
struct Route {
label: &'static str,
api_path: &'static str,
identity: ProviderIdentity,
base_url: String,
models: &'static [&'static str],
}
fn cold_process() {
codewhale_engine::provider_catalog_live::reset_cache_for_test();
codewhale_engine::provider_lake::clear_live_snapshot();
}
fn known<T: ToString>(value: Option<T>) -> String {
value.map_or_else(|| UNKNOWN.to_string(), |value| value.to_string())
}
fn rate(value: &Value) -> String {
let number = match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.parse::<f64>().ok(),
_ => None,
};
known(number)
}
fn roster(mut models: Vec<String>) -> String {
models.sort();
models.dedup();
models.join(",")
}
fn read(
readings: &mut Readings,
route: &Route,
model: &str,
field: &'static str,
surface: &'static str,
value: String,
) {
readings
.entry((route.label.to_string(), model.to_string(), field))
.or_default()
.insert(surface, value);
}
fn persist_id_only_roster(identity: &ProviderIdentity, base_url: &str) {
let fingerprint = base_url_fingerprint(base_url);
let fetched_at = now_unix();
let ticket = codewhale_engine::provider_catalog_live::begin_refresh_for_identity(
identity.provider,
identity.key.as_str(),
base_url,
);
assert_eq!(
codewhale_engine::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: identity.key.to_string(),
base_url_fingerprint: fingerprint.clone(),
fetched_at,
offerings: ROSTER
.iter()
.map(|id| CatalogOffering {
provider: identity.key.to_string(),
wire_model_id: (*id).to_string(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at,
},
..Default::default()
})
.collect(),
},
),
Some(CatalogStatus::Fresh)
);
}
fn read_singular(readings: &mut Readings, config_path: &Path, route: &Route) -> Result<()> {
cold_process();
let store = codewhale_config::ConfigStore::load(Some(config_path.to_path_buf()))?;
let listed =
codewhale_engine::route_preferences::list_models(&store, Some(route.identity.provider))?
.into_iter()
.find(|(listed, _)| listed == route.identity.key.as_str())
.map(|(_, models)| roster(models));
read(
readings,
route,
"*",
"roster",
SINGULAR,
listed.unwrap_or_else(|| "route not listed".into()),
);
for model in route.models {
let limits = codewhale_engine::route_preferences::resolve_model(
config_path,
route.identity.key.as_str(),
&route.base_url,
Some(model),
)
.map(|candidate| candidate.limits());
let (context, output) = match limits {
Ok(limits) => (known(limits.context_tokens), known(limits.output_tokens)),
Err(error) => (format!("refused: {error}"), format!("refused: {error}")),
};
read(readings, route, model, "limit.context", SINGULAR, context);
read(readings, route, model, "limit.output", SINGULAR, output);
}
Ok(())
}
fn read_plural(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
cold_process();
codewhale_engine::models_dev_live::maybe_load_persisted_cache();
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&route.identity)
.map_err(anyhow::Error::msg)?;
read(
readings,
route,
"*",
"roster",
PLURAL,
roster(codewhale_engine::provider_lake::listed_models_for_route(
&route_config,
&route.identity,
)),
);
Ok(())
}
async fn read_runtime(
readings: &mut Readings,
client: &reqwest::Client,
addr: &SocketAddr,
route: &Route,
) -> Result<()> {
let body: Value = client
.get(format!("http://{addr}/v1/providers/{}", route.api_path))
.send()
.await?
.error_for_status()?
.json()
.await?;
let models = body["models"].as_array().context("models array")?;
read(
readings,
route,
"*",
"roster",
RUNTIME,
roster(
models
.iter()
.filter_map(|model| model["id"].as_str().map(str::to_string))
.collect(),
),
);
for model in route.models {
let facts = models
.iter()
.find(|entry| entry["id"] == *model)
.map_or(&Value::Null, |entry| &entry["facts"]);
read(
readings,
route,
model,
"limit.context",
RUNTIME,
known(facts["effective_limits"]["context_tokens"].as_u64()),
);
read(
readings,
route,
model,
"limit.output",
RUNTIME,
known(facts["effective_limits"]["output_tokens"].as_u64()),
);
read(
readings,
route,
model,
"window",
RUNTIME,
known(facts["effective_context"]["tokens"].as_u64()),
);
read(
readings,
route,
model,
"window.source",
RUNTIME,
known(facts["effective_context"]["source"].as_str()),
);
read(
readings,
route,
model,
"price.input",
RUNTIME,
rate(&facts["pricing"]["input_per_million"]),
);
read(
readings,
route,
model,
"price.output",
RUNTIME,
rate(&facts["pricing"]["output_per_million"]),
);
read(
readings,
route,
model,
"price.basis",
RUNTIME,
format!(
"provenance={} status={} source={}",
facts["pricing"]["provenance"], facts["pricing_status"], facts["source"]
),
);
}
Ok(())
}
fn read_routing(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
let inventory = ModelInventory::from_config(config).map_err(anyhow::Error::msg)?;
let candidates: Vec<_> = inventory
.candidates
.iter()
.filter(|candidate| candidate.identity == route.identity)
.collect();
read(
readings,
route,
"*",
"roster",
ROUTING,
roster(
candidates
.iter()
.map(|candidate| candidate.model.clone())
.collect(),
),
);
for model in route.models {
let candidate = candidates
.iter()
.find(|candidate| candidate.model == *model);
read(
readings,
route,
model,
"window",
ROUTING,
known(candidate.map(|candidate| candidate.context_window)),
);
read(
readings,
route,
model,
"window.source",
ROUTING,
known(candidate.map(|candidate| {
if candidate.context_window_unverified {
"unverified"
} else {
"verified"
}
})),
);
read(
readings,
route,
model,
"limit.output",
ROUTING,
known(candidate.and_then(|candidate| candidate.max_output)),
);
}
Ok(())
}
fn read_cost(readings: &mut Readings, config: &Config, route: &Route) {
let usage = Usage {
input_tokens: 100_000,
output_tokens: 10_000,
..Default::default()
};
for model in route.models {
let envelope = EffectiveRouteEnvelope::from_admitted(
Some(config),
&route.identity,
*model,
Some(&route.base_url),
chrono::Utc::now(),
);
let quote = envelope.provider_live_pricing.as_ref();
let audit = envelope.audit(&usage);
read(
readings,
route,
model,
"price.input",
COST,
quote
.and_then(|quote| quote.input_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.output",
COST,
quote
.and_then(|quote| quote.output_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.basis",
COST,
format!(
"provenance={:?} billing={:?} charge_usd={} unpriced={:?}",
quote.map(|quote| quote.provenance.clone()),
envelope.billing_mode,
known(audit.exact_usd.as_deref()),
audit.unpriced_reason,
),
);
}
}
#[tokio::test]
async fn one_captured_catalog_reads_the_same_on_every_model_surface() -> Result<()> {
let _env = lock_test_env();
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
let root = tempfile::tempdir()?;
let captured = codewhale_config::catalog::BUNDLED_MODELS_DEV_JSON;
let catalog_path = root.path().join("captured-models-dev.json");
fs::write(&catalog_path, captured)?;
let _catalog = EnvVarGuard::set("CODEWHALE_MODELS_DEV_PATH", &catalog_path);
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
cold_process();
let config_path = root.path().join("config.toml");
fs::write(
&config_path,
r#"provider = "anthropic"
[providers.anthropic]
api_key = "parity-fixture-key"
model = "claude-opus-5"
[providers.zai]
api_key = "parity-fixture-key"
model = "GLM-5.3"
[providers.lab]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/v1"
api_key = "parity-fixture-key"
model = "claude-haiku-4-5"
"#,
)?;
let config = Config::load(Some(config_path.clone()), None)?;
let route = |label: &'static str,
api_path: &'static str,
key: &str,
models: &'static [&'static str]|
-> Result<Route> {
let identity = config
.resolve_provider_pin_identity(key)
.map_err(anyhow::Error::msg)?;
Ok(Route {
label,
api_path,
base_url: config.base_url_for_route(&identity),
identity,
models,
})
};
let routes = [
route(
"anthropic (official, id-only roster)",
"anthropic/models",
"anthropic",
ROSTER,
)?,
route(
"lab (custom endpoint, same spelling)",
"custom/models?model_provider_id=lab",
"lab",
&["claude-haiku-4-5"],
)?,
route(
"zai (plan endpoint)",
"zai/models",
"zai",
&["GLM-5.3", "GLM-5.2"],
)?,
];
assert!(
codewhale_engine::models_dev_live::refresh(false)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?
> 0
);
persist_id_only_roster(&routes[0].identity, &routes[0].base_url);
let mut readings = Readings::new();
for route in &routes {
read_singular(&mut readings, &config_path, route)?;
read_plural(&mut readings, &config, route)?;
}
cold_process();
let (addr, _, handle) = spawn_test_server_with_config_path(config_path.clone())
.await?
.expect("loopback server required");
let client = codewhale_release::tls::reqwest_client();
for route in &routes {
read_runtime(&mut readings, &client, &addr, route).await?;
read_routing(&mut readings, &config, route)?;
read_cost(&mut readings, &config, route);
}
handle.abort();
cold_process();
println!(
"PARITY captured_catalog sha256={} bytes={}",
Sha256::digest(captured.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
captured.len()
);
let mut differing = Vec::new();
let mut known_differences: Vec<String> = Vec::new();
for ((route, model, field), surfaces) in &readings {
let mut values: Vec<String> = surfaces
.values()
.map(|value| {
if *field == "roster" {
roster(
value
.to_ascii_lowercase()
.split(',')
.map(str::to_string)
.collect(),
)
} else {
value.clone()
}
})
.collect();
values.sort();
values.dedup();
let compared = !matches!(*field, "price.basis" | "window.source");
let borrowed_window = *field == "window"
&& surfaces.get(RUNTIME).map(String::as_str) == Some(UNKNOWN)
&& readings
.get(&(route.clone(), model.clone(), "window.source"))
.and_then(|sources| sources.get(ROUTING))
.map(String::as_str)
== Some("unverified");
let verdict = if !compared {
"info"
} else if borrowed_window {
known_differences.push(format!("{route} | {model} | {surfaces:?}"));
"KNOWN-DIFFERENCE"
} else if values.len() == 1 {
"agree"
} else {
differing.push(format!("{route} | {model} | {field} | {surfaces:?}"));
"DIFFER"
};
println!("PARITY {verdict} | {route} | {model} | {field} | {surfaces:?}");
}
let value = |route: &Route, model: &str, field: &'static str, surface: &'static str| {
readings
.get(&(route.label.to_string(), model.to_string(), field))
.and_then(|surfaces| surfaces.get(surface))
.cloned()
.unwrap_or_default()
};
assert_eq!(
value(&routes[0], "*", "roster", SINGULAR),
"claude-haiku-4-5,claude-opus-5,claude-parity-probe-1",
"the singular command lists the route's own roster, not every bundled row"
);
assert_eq!(
value(&routes[0], "claude-opus-5", "limit.context", RUNTIME),
"1000000"
);
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "limit.context", RUNTIME),
"200000"
);
assert_eq!(value(&routes[0], "claude-opus-5", "price.input", COST), "5");
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "price.output", COST),
"5"
);
for field in ["limit.context", "limit.output", "price.input"] {
assert_eq!(
value(&routes[1], "claude-haiku-4-5", field, RUNTIME),
UNKNOWN,
"a custom endpoint borrows no fact from a same-named model: {field}"
);
}
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "window.source", ROUTING),
"unverified",
"routing never presents a same-named model's window as this endpoint's"
);
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "limit.output", ROUTING),
UNKNOWN
);
for model in ["GLM-5.3", "GLM-5.2"] {
let basis = value(&routes[2], model, "price.basis", COST);
assert!(
basis.contains("NotMoneyMetered") && basis.contains("charge_usd=unknown"),
"a plan route is never billed per token: {model}: {basis}"
);
}
for (model, rate) in [("GLM-5.3", UNKNOWN), ("GLM-5.2", "1.4")] {
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[2], model, "price.input", surface),
rate,
"the list rate is shown the same everywhere and never turned into a charge: {model} {surface}"
);
}
}
let basis = value(&routes[1], "claude-haiku-4-5", "price.basis", COST);
assert!(
basis.contains("UnknownBillingBasis") && basis.contains("charge_usd=unknown"),
"a custom endpoint is never given a charge: {basis}"
);
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[0], "claude-parity-probe-1", "price.input", surface),
UNKNOWN,
"a metered first-party model with no price row reads unknown: {surface}"
);
}
let basis = value(&routes[0], "claude-parity-probe-1", "price.basis", COST);
assert!(
basis.contains("charge_usd=unknown") && !basis.contains("unpriced=None"),
"an unpriced model produces no charge and says why: {basis}"
);
assert_eq!(
known_differences,
vec![
"lab (custom endpoint, same spelling) | claude-haiku-4-5 | {\"routing inventory\": \"200000\", \"runtime api\": \"unknown\"}"
],
"the only named difference is the custom endpoint's borrowed routing window"
);
assert!(
differing.is_empty(),
"surfaces disagree on the same captured catalog:\n{}",
differing.join("\n")
);
Ok(())
}
@@ -28,6 +28,8 @@ fn user(name: &str) -> UserCommand {
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: None,
workspace_sourced: false,
builtin_bindings: vec![name.to_string()],
}
}
@@ -0,0 +1,219 @@
use super::*;
use codewhale_engine::command_catalog::{BuiltinCommand, CommandCatalog, UserCommand};
struct Catalog(Arc<std::sync::Mutex<Option<String>>>);
impl CommandCatalog for Catalog {
fn answers_to(&self, _name: &str) -> bool {
false
}
fn builtins(&self) -> Vec<BuiltinCommand> {
Vec::new()
}
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
let plugin_id = self.0.lock().expect("plugin id").clone();
let command = |name: &str, plugin: Option<String>| UserCommand {
name: name.to_string(),
aliases: Vec::new(),
description: Some("A command.".to_string()),
usage: None,
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: plugin,
workspace_sourced: false,
builtin_bindings: Vec::new(),
};
vec![command("mine", None), command("ship", plugin_id)]
}
}
#[tokio::test]
async fn the_index_lists_every_kind_once_and_a_plugin_carries_what_it_adds() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("index-route");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let plugin_id = Arc::new(std::sync::Mutex::new(None));
let _catalog = codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(Catalog(
plugin_id.clone(),
)));
let sources = root.join("sources");
fs::create_dir_all(&sources)?;
let bundle = write_plugin_source_bundle(&sources, "indexed")?;
fs::create_dir_all(bundle.join("commands"))?;
fs::write(bundle.join("commands/ship.md"), "Ship it.\n")?;
fs::create_dir_all(bundle.join("agents"))?;
fs::write(bundle.join("agents/reviewer.toml"), "name = \"reviewer\"\n")?;
fs::write(
bundle.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"indexed\"\nversion = \"1.0.0\"\n\
description = \"test bundle\"\n\n[skills]\npath = \"skills\"\n\
[commands]\npath = \"commands\"\n[agents]\npath = \"agents\"\n",
)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
plugin_ui::install_active(
&client,
&format!("http://{addr}/v1/apps/plugins"),
bundle.display().to_string(),
)
.await?;
let listed: Value = client
.get(format!("http://{addr}/v1/apps/plugins"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = listed["plugins"]
.as_array()
.and_then(|plugins| plugins.iter().find(|plugin| plugin["name"] == "indexed"))
.and_then(|plugin| plugin["id"].as_str())
.expect("installed plugin id")
.to_string();
*plugin_id.lock().expect("plugin id") = Some(id.clone());
let commands: Value = client
.get(format!("http://{addr}/v1/commands"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let ship = commands["commands"]
.as_array()
.and_then(|rows| rows.iter().find(|row| row["name"] == "ship"))
.expect("ship row");
assert_eq!(ship["plugin_id"], id.as_str());
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let items = index["items"].as_array().expect("items");
let find = |id: &str| items.iter().find(|item| item["id"] == id);
let plugin = items
.iter()
.find(|item| item["kind"] == "plugin" && item["row"]["name"] == "indexed")
.unwrap_or_else(|| panic!("plugin row in {index}"));
assert_eq!(plugin["kind"], "plugin");
assert_eq!(plugin["state"], "enabled");
assert_eq!(plugin["adds"]["skills"], serde_json::json!(["greet"]));
assert_eq!(plugin["adds"]["commands"], serde_json::json!(["ship"]));
assert_eq!(plugin["adds"]["profiles"], serde_json::json!(["reviewer"]));
assert_eq!(
plugin["labels"],
serde_json::json!(["skill", "command", "profile"])
);
assert_eq!(plugin["row"]["name"], "indexed");
assert!(find("skill:greet").is_none());
assert!(find("command:ship").is_none());
let mine = find("command:mine").expect("a hand-written command is a row");
assert_eq!(mine["source"], "user");
assert_eq!(mine["labels"], serde_json::json!(["command"]));
assert!(
items
.iter()
.any(|item| item["kind"] == "plugin" && item["tier"] == "builtin"),
"built-in switches are rows"
);
assert_eq!(
index["unavailable"].as_array().map(Vec::len),
Some(0),
"{index}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn the_index_keeps_same_name_hooks_distinct() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("hook-index-route");
let workspace = root.join("workspace");
let mut config = Config::default();
config.hooks = Some(codewhale_config::hooks::HooksConfig {
hooks: vec![
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo first",
)
.with_name("gate"),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo second",
)
.with_name("gate"),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo third",
),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo fourth",
)
.with_name("session_start 3"),
],
enabled: true,
..Default::default()
});
let (addr, _runtime_threads, handle) =
spawn_test_server_with_root_token_mobile_workspace_and_overrides(
root.clone(),
root.join("sessions"),
None,
false,
workspace.clone(),
TestServerOverrides {
config: Some(config),
plugin_discovery: Some(isolated_plugin_discovery(&root, &workspace)),
..TestServerOverrides::default()
},
)
.await?
.expect("hook index test server");
let client = codewhale_release::tls::reqwest_client();
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let hooks: Vec<_> = index["items"]
.as_array()
.expect("items")
.iter()
.filter(|item| item["kind"] == "hook")
.collect();
assert_eq!(hooks.len(), 4, "{index}");
for (ordinal, hook) in hooks.iter().enumerate() {
assert_eq!(
hook["id"],
format!("hook:user:session_start:{}", ordinal + 1)
);
assert_eq!(hook["state"], "enabled");
}
assert_eq!(hooks[0]["name"], "gate");
assert_eq!(hooks[1]["name"], "gate");
assert_eq!(hooks[2]["name"], "session_start 3");
assert_eq!(hooks[3]["name"], "session_start 3");
assert_eq!(hooks[3]["row"]["command"], "echo fourth");
assert_eq!(hooks[0]["row"]["command"], "echo first");
assert_eq!(hooks[1]["row"]["command"], "echo second");
handle.abort();
Ok(())
}
@@ -79,7 +79,7 @@ fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
Ok(bundle)
}
async fn install_active(
pub(super) async fn install_active(
client: &reqwest::Client,
base: &str,
source: String,
@@ -1202,15 +1202,15 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
{
let error = fs::rename(&selected_workspace, &retired_workspace)
.expect_err("a live Runtime owner must prevent workspace replacement");
assert_eq!(error.raw_os_error(), Some(5), "{error}");
assert_eq!(error.raw_os_error(), Some(32), "{error}");
assert!(!retired_workspace.exists());
}
manager.shutdown_and_wait().await?;
#[cfg(windows)]
{
let error = fs::rename(&selected_workspace, &retired_workspace)
.expect_err("Engine shutdown must retain the Runtime owner's workspace lock");
assert_eq!(error.raw_os_error(), Some(5), "{error}");
.expect_err("Engine shutdown must retain the Runtime owner's pinned workspace");
assert_eq!(error.raw_os_error(), Some(32), "{error}");
assert!(!retired_workspace.exists());
assert!(Arc::ptr_eq(
&scopes.admit(selected_workspace.clone()).await?,
-75
View File
@@ -367,81 +367,6 @@ fn build_state_keeps_resolved_explicit_config_path() {
);
}
#[tokio::test]
async fn stdio_transport_never_registers_the_stdout_hook_sink() {
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
let http_state =
build_state_with_transport(Some(config_path.clone()), None, AppTransport::Http)
.expect("http state");
let stdio_state = build_state_with_transport(Some(config_path), None, AppTransport::Stdio)
.expect("stdio state");
let http_sinks = http_state.runtime.read().await.hooks.sink_count();
let stdio_sinks = stdio_state.runtime.read().await.hooks.sink_count();
assert_eq!(
http_sinks,
stdio_sinks + 1,
"HTTP mode keeps StdoutHookSink + JsonlHookSink; stdio must drop the stdout sink (#5165)"
);
}
// Points CODEWHALE_HOME at a temp dir for one test and restores it after.
// The variable is process-global, so these tests hold a lock.
static CODEWHALE_HOME_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct CodewhaleHomeGuard {
prior: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl CodewhaleHomeGuard {
fn set(value: &Path) -> Self {
let lock = CODEWHALE_HOME_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var_os("CODEWHALE_HOME");
// SAFETY: serialised by CODEWHALE_HOME_TEST_LOCK.
unsafe { std::env::set_var("CODEWHALE_HOME", value) };
Self { prior, _lock: lock }
}
}
impl Drop for CodewhaleHomeGuard {
fn drop(&mut self) {
// SAFETY: serialised by CODEWHALE_HOME_TEST_LOCK.
unsafe {
match &self.prior {
Some(value) => std::env::set_var("CODEWHALE_HOME", value),
None => std::env::remove_var("CODEWHALE_HOME"),
}
}
}
}
#[tokio::test]
async fn hook_log_without_config_path_goes_beside_state_db_not_cwd() {
let home = tempfile::tempdir().expect("tempdir");
let _home = CodewhaleHomeGuard::set(home.path());
let state = build_state_with_transport(None, None, AppTransport::Stdio).expect("state");
state
.runtime
.read()
.await
.hooks
.emit(codewhale_hooks::HookEvent::ResponseStart {
response_id: "resp-6513".to_string(),
})
.await;
let log = fs::read_to_string(home.path().join("events.jsonl"))
.expect("hook log beside state.db in CODEWHALE_HOME");
assert!(log.contains("resp-6513"), "{log}");
}
async fn response_body_json(response: Response) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
+345 -2
View File
@@ -3,13 +3,13 @@ mod acp_support;
use acp_support::{Client, fixture_config, text_of, updates};
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::llm_client::mock::{CannedTurn, MockLlmClient, canned};
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use serde_json::{Value, json};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
@@ -497,3 +497,346 @@ async fn extension_journey() -> Result<()> {
host.shutdown().await;
Ok(())
}
const GATES_MCP_FIXTURE: &str = r#"import readline from 'node:readline';
readline.createInterface({input:process.stdin}).on('line', line => {
const r = JSON.parse(line); if (r.id === undefined) return;
let result = {};
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
if (r.method === 'tools/list') result = {tools:['echo','hidden'].map(name => ({name,description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}))};
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-ran:' + r.params.name + ':' + r.params.arguments.text}]};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
});
"#;
struct Outcome {
response: Value,
frames: Vec<Value>,
asked: Vec<Value>,
}
impl Outcome {
fn rendered(&self) -> String {
serde_json::to_string(&self.frames).unwrap_or_default()
}
fn completed(&self) -> usize {
updates(&self.frames, "tool_call_update")
.iter()
.filter(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
.count()
}
fn titles(&self) -> Vec<String> {
self.asked
.iter()
.map(|frame| {
frame["params"]["toolCall"]["title"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect()
}
fn announced_ids(&self) -> Vec<String> {
updates(&self.frames, "tool_call")
.iter()
.filter_map(|frame| frame.pointer("/params/update/toolCallId")?.as_str())
.map(str::to_string)
.collect()
}
}
async fn run_prompt(
client: &mut Client,
session: &str,
allow: &dyn Fn(usize) -> bool,
) -> Result<Outcome> {
client.next_id += 1;
let prompt_id = client.next_id;
client
.send(
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":[{"type":"text","text":"go"}]}}),
)
.await?;
let mut frames = Vec::new();
let mut asked = Vec::new();
let response = loop {
let frame = client.recv().await?;
if frame["method"] == "session/request_permission" {
let option = if allow(asked.len()) {
"allow-once"
} else {
"reject-once"
};
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],
"result":{"outcome":{"outcome":"selected","optionId":option}}}))
.await?;
asked.push(frame);
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
break frame;
} else {
frames.push(frame);
}
};
Ok(Outcome {
response,
frames,
asked,
})
}
async fn new_session(
client: &mut Client,
manager: &RuntimeThreadManager,
workspace: &Path,
turns: Vec<CannedTurn>,
plan: bool,
) -> Result<String> {
manager.set_test_model_client(Arc::new(MockLlmClient::new(turns)));
let (created, _) = client
.request("session/new", json!({"cwd":workspace}))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId: {created}"))?
.to_string();
if plan {
let (set, _) = client
.request(
"session/set_mode",
json!({"sessionId":session,"modeId":"plan"}),
)
.await?;
assert!(set.get("error").is_none(), "{set}");
}
Ok(session)
}
#[test]
fn extension_tools_keep_the_permission_boundary_in_an_acp_session() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(180), gates())
.await
.map_err(|_| anyhow!("extension gates exceeded 180s"))?
})
})?
.join()
.map_err(|_| anyhow!("extension gates thread panicked"))?
}
async fn gates() -> Result<()> {
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
let Some(node) = node_for_tests("extension_tools_keep_the_permission_boundary") else {
return Ok(());
};
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["plugin-context", "core-call"]).await;
let host = fixture.manager(node.clone());
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = fixture.workspace().to_path_buf();
std::fs::write(workspace.join("seed.txt"), "seed-content-xyz")?;
let server = dir.path().join("fixture-mcp.mjs");
std::fs::write(&server, GATES_MCP_FIXTURE)?;
let mcp_config = dir.path().join("mcp.json");
std::fs::write(
&mcp_config,
serde_json::to_vec(&json!({"servers":{
"fixture":{"command":node,"args":[server],"disabled_tools":["hidden"]},
"off":{"command":node,"args":[server],"disabled":true}
}}))?,
)?;
let runtime_dir = dir.path().join("runtime");
let mut config = fixture_config();
config.set_feature("mcp", true).unwrap();
config.set_feature("extension_host", true).unwrap();
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 16,
},
fixture.registry(),
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
codewhale_engine::session_manager::default_sessions_dir()?,
None,
None,
)?;
let (mut client, agent_read, agent_write) = Client::connect(false);
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let manager_for_script = Arc::clone(&manager);
let script =
async {
let manager = manager_for_script;
let result = async {
client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n1",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested allowed"),
],
false,
)
.await?;
let allowed = run_prompt(&mut client, &session, &|_| true).await?;
assert!(allowed.response.get("error").is_none(), "{}", allowed.response);
assert_eq!(allowed.response["result"]["stopReason"], "end_turn");
assert_eq!(allowed.asked.len(), 2, "{:?}", allowed.titles());
assert!(allowed.titles()[0].starts_with("cc_call"));
let announced = allowed.announced_ids();
for request in &allowed.asked {
let id = request["params"]["toolCall"]["toolCallId"]
.as_str()
.unwrap_or_default();
assert!(
announced.iter().any(|known| known == id),
"a nested approval is shown on a tool call the editor knows: {id} {announced:?}"
);
}
let nested = &allowed.asked[1]["params"]["toolCall"];
assert_eq!(nested["title"], "write: nested.txt", "{nested}");
assert_eq!(nested["kind"], "edit", "{nested}");
assert_eq!(nested["rawInput"]["path"], "nested.txt", "{nested}");
assert!(nested.get("status").is_none(), "{nested}");
assert_eq!(
std::fs::read_to_string(workspace.join("nested.txt"))?,
"via-plugin"
);
std::fs::remove_file(workspace.join("nested.txt"))?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n2",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested denied"),
],
false,
)
.await?;
let denied = run_prompt(&mut client, &session, &|index| index == 0).await?;
assert!(denied.response.get("error").is_none(), "{}", denied.response);
assert_eq!(denied.response["result"]["stopReason"], "end_turn");
assert_eq!(denied.asked.len(), 2, "{:?}", denied.titles());
assert!(
!workspace.join("nested.txt").exists(),
"a rejected nested write must not run"
);
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("r1", "ctx_probe", "{}"),
canned::tool_call_turn("r2", "mcp_fixture_echo", r#"{"text":"control"}"#),
canned::tool_call_turn("r3", "ctx_note", r#"{"text":"must-not-be-written"}"#),
canned::tool_call_turn("r4", "mcp_fixture_echo", r#"{"text":"rejected"}"#),
canned::simple_text_turn("rejections done"),
],
false,
)
.await?;
let rejected = run_prompt(&mut client, &session, &|index| index < 2).await?;
assert_eq!(rejected.asked.len(), 4, "{:?}", rejected.titles());
let rendered = rejected.rendered();
assert!(rendered.contains("mcp-ran:echo:control"), "{rendered}");
assert!(!rendered.contains("mcp-ran:echo:rejected"), "{rendered}");
assert!(!rendered.contains("note.txt"), "{rendered}");
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("p1", "ctx_probe", "{}"),
canned::tool_call_turn("p2", "mcp_fixture_echo", r#"{"text":"plan"}"#),
canned::simple_text_turn("plan done"),
],
true,
)
.await?;
let plan = run_prompt(&mut client, &session, &|_| true).await?;
assert!(plan.asked.is_empty(), "{:?}", plan.titles());
assert_eq!(plan.completed(), 0, "{}", plan.rendered());
assert!(!plan.rendered().contains("mcp-ran:"));
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("f1", "mcp_fixture_hidden", r#"{"text":"hid"}"#),
canned::tool_call_turn("f2", "mcp_off_echo", r#"{"text":"off"}"#),
canned::tool_call_turn("f3", "mcp_evil_echo", r#"{"text":"evil"}"#),
canned::tool_call_turn(
"f4",
"start_mcp_server",
r#"{"name":"evil","command":"node","args":["-e","0"]}"#,
),
canned::tool_call_turn("f5", "agent", r#"{"action":"start","prompt":"p"}"#),
canned::simple_text_turn("fabricated done"),
],
false,
)
.await?;
let fabricated = run_prompt(&mut client, &session, &|_| true).await?;
assert!(fabricated.asked.is_empty(), "{:?}", fabricated.titles());
assert_eq!(fabricated.completed(), 0, "{}", fabricated.rendered());
assert!(!fabricated.rendered().contains("mcp-ran:"));
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, scripted) = tokio::join!(connection, script);
served?;
scripted?;
manager.shutdown_and_wait().await?;
host.shutdown().await;
Ok(())
}
+1
View File
@@ -38,6 +38,7 @@ codewhale-workflow = { path = "../workflow", version = "0.10.3" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
codewhale-paths = { path = "../paths", version = "0.10.3" }
codewhale-release = { path = "../release", version = "0.10.3" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
codewhale-state = { path = "../state", version = "0.10.3" }
codewhale-telemetry = { path = "../telemetry", version = "0.10.3" }
+1 -1
View File
@@ -379,7 +379,7 @@ fn url_carries_credential(text: &str) -> bool {
// `***`; count masks rather than compare bytes, because it re-encodes the
// query and would otherwise flag benign URLs.
let masks = |value: &str| value.matches("***").count();
if masks(&codewhale_secrets::sanitize::redact_url_for_display(text)) > masks(text) {
if masks(&codewhale_sanitize::sanitize::redact_url_for_display(text)) > masks(text) {
return true;
}
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
+1
View File
@@ -25,6 +25,7 @@ semver.workspace = true
schemars = { version = "1.2.1", optional = true, features = ["derive", "preserve_order"] }
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
codewhale-paths = { path = "../paths", version = "0.10.3" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
fd-lock = "4.0.4"
libc = "0.2"
+2 -2
View File
@@ -300,7 +300,7 @@ pub(crate) fn remove_plaintext_api_keys_in_array(array: &mut toml_edit::Array) {
pub(crate) fn backup_value_carries_secret(value: &toml_edit::Value) -> bool {
value
.as_str()
.is_some_and(codewhale_secrets::sanitize::contains_secret)
.is_some_and(codewhale_sanitize::sanitize::contains_secret)
}
pub(crate) fn scrub_backup_decor(decor: &mut toml_edit::Decor) {
@@ -331,7 +331,7 @@ pub(crate) fn scrub_backup_comments(text: &str) -> String {
comment
.split_once('=')
.is_some_and(|(key, _)| is_sensitive_config_key(key))
|| codewhale_secrets::sanitize::contains_secret(comment)
|| codewhale_sanitize::sanitize::contains_secret(comment)
})
})
.collect()
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn toml_error_location(source: Option<&str>, err: &toml::de::Error) -> Strin
.and_then(|rest| rest.strip_prefix("\nin `"))
.and_then(|rest| rest.strip_suffix("`\n"))
.filter(|keys| !keys.is_empty())
.map(|keys| format!("in `{}`", codewhale_secrets::redact::redact_secrets(keys)));
.map(|keys| format!("in `{}`", codewhale_sanitize::redact::redact_secrets(keys)));
match (position, key_path) {
(Some(position), Some(keys)) => format!("{position}, {keys}"),
(Some(position), None) => position,
+1 -1
View File
@@ -128,7 +128,7 @@ pub(crate) fn redact_secret(secret: &str) -> String {
pub fn is_sensitive_config_key(key: &str) -> bool {
key.rsplit('.')
.next()
.is_some_and(codewhale_secrets::sanitize::is_sensitive_key_name)
.is_some_and(codewhale_sanitize::sanitize::is_sensitive_key_name)
}
/// Resolve dotted paths without treating a dotted key as a top-level literal.
+87 -325
View File
@@ -140,48 +140,17 @@ impl EnvRuntimeOverrides {
Self {
provider,
provider_source,
model: std::env::var("CODEWHALE_MODEL")
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_model: std::env::var("VOLCENGINE_MODEL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
.or_else(|_| std::env::var("WANJIE_MODEL"))
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_model: std::env::var("OPENROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_model: std::env::var("ORCAROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_model: std::env::var("MOONSHOT_MODEL")
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
.or_else(|_| std::env::var("KIMI_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
.or_else(|_| std::env::var("MIMO_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
.or_else(|_| std::env::var("MIMO_MODE"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_model: std::env::var("NOVITA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_model: std::env::var("FIREWORKS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_model: std::env::var("ARCEE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
model: env_non_blank(&["CODEWHALE_MODEL", "DEEPSEEK_MODEL", "DEEPSEEK_DEFAULT_TEXT_MODEL"]),
volcengine_model: env_non_blank(&["VOLCENGINE_MODEL", "VOLCENGINE_ARK_MODEL"]),
wanjie_ark_model: env_non_blank(&["WANJIE_ARK_MODEL", "WANJIE_MODEL", "WANJIE_MAAS_MODEL"]),
openrouter_model: env_non_blank(&["OPENROUTER_MODEL"]),
orcarouter_model: env_non_blank(&["ORCAROUTER_MODEL"]),
moonshot_model: env_non_blank(&["MOONSHOT_MODEL", "KIMI_MODEL_NAME", "KIMI_MODEL"]),
xiaomi_mimo_model: env_non_blank(&["XIAOMI_MIMO_MODEL", "MIMO_MODEL"]),
xiaomi_mimo_mode: env_non_blank(&["XIAOMI_MIMO_MODE", "MIMO_MODE"]),
novita_model: env_non_blank(&["NOVITA_MODEL"]),
fireworks_model: env_non_blank(&["FIREWORKS_MODEL"]),
arcee_model: env_non_blank(&["ARCEE_MODEL"]),
verbosity: env_non_blank(&["CODEWHALE_VERBOSITY", "DEEPSEEK_VERBOSITY"]),
auth_mode: env_non_blank(&["CODEWHALE_AUTH_MODE", "DEEPSEEK_AUTH_MODE"]),
log_level: env_non_blank(&["CODEWHALE_LOG_LEVEL", "DEEPSEEK_LOG_LEVEL"]),
@@ -216,9 +185,7 @@ impl EnvRuntimeOverrides {
None
}
}),
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
.ok()
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
.and_then(|value| match parse_http_headers(&value) {
Ok(h) => Some(h),
Err(_) => {
@@ -227,290 +194,85 @@ impl EnvRuntimeOverrides {
}
})
.filter(|headers| !headers.is_empty()),
active_route_base_url: std::env::var("CODEWHALE_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_base_url: std::env::var("OPENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
.or_else(|_| std::env::var("ARK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
.or_else(|_| std::env::var("MIMO_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_base_url: std::env::var("NOVITA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_model: std::env::var("SILICONFLOW_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_base_url: std::env::var("ARCEE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
.or_else(|_| std::env::var("KIMI_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
sglang_base_url: std::env::var("SGLANG_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
vllm_base_url: std::env::var("VLLM_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
.or_else(|_| std::env::var("HF_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_model: std::env::var("HUGGINGFACE_MODEL")
.or_else(|_| std::env::var("HF_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_base_url: std::env::var("MODELSCOPE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_model: std::env::var("MODELSCOPE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_base_url: std::env::var("TOGETHER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_model: std::env::var("TOGETHER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
qianfan_model: std::env::var("QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
.or_else(|_| std::env::var("CODEX_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
.or_else(|_| std::env::var("CODEX_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_model: std::env::var("ANTHROPIC_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_model: std::env::var("OPENMODEL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zai_base_url: std::env::var("ZAI_BASE_URL")
.or_else(|_| std::env::var("Z_AI_BASE_URL"))
.or_else(|_| std::env::var("ZHIPU_BASE_URL"))
.or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
.or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
zai_model: std::env::var("ZAI_MODEL")
.or_else(|_| std::env::var("Z_AI_MODEL"))
.or_else(|_| std::env::var("ZHIPU_MODEL"))
.or_else(|_| std::env::var("ZHIPUAI_MODEL"))
.or_else(|_| std::env::var("BIGMODEL_MODEL"))
.or_else(|_| std::env::var("GLM_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
.or_else(|_| std::env::var("STEP_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_model: std::env::var("STEPFUN_MODEL")
.or_else(|_| std::env::var("STEP_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_base_url: std::env::var("MINIMAX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_model: std::env::var("MINIMAX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_model: std::env::var("DEEPINFRA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_base_url: std::env::var("SAKANA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_model: std::env::var("SAKANA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_base_url: std::env::var("LONGCAT_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_model: std::env::var("LONGCAT_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
meta_model: std::env::var("META_MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
xai_base_url: std::env::var("XAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xai_model: std::env::var("XAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
google_base_url: std::env::var("GOOGLE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
google_model: std::env::var("GOOGLE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
mistral_model: std::env::var("MISTRAL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_model: std::env::var("TELECOMJS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_base_url: std::env::var("EDENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_model: std::env::var("EDENAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_base_url: std::env::var("ZENMUX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_model: std::env::var("ZENMUX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_base_url: std::env::var("CSDN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_model: std::env::var("CSDN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_model: std::env::var("CONCENTRATE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
active_route_base_url: env_non_blank(&["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"]),
deepseek_anthropic_base_url: env_non_blank(&["DEEPSEEK_ANTHROPIC_BASE_URL", "DEEPSEEK_CLAUDE_BASE_URL"]),
nvidia_base_url: env_non_blank(&["NVIDIA_NIM_BASE_URL", "NIM_BASE_URL", "NVIDIA_BASE_URL"]),
openai_base_url: env_non_blank(&["OPENAI_BASE_URL"]),
atlascloud_base_url: env_non_blank(&["ATLASCLOUD_BASE_URL"]),
volcengine_base_url: env_non_blank(&["VOLCENGINE_BASE_URL", "VOLCENGINE_ARK_BASE_URL", "ARK_BASE_URL"]),
wanjie_ark_base_url: env_non_blank(&["WANJIE_ARK_BASE_URL", "WANJIE_BASE_URL", "WANJIE_MAAS_BASE_URL"]),
openrouter_base_url: env_non_blank(&["OPENROUTER_BASE_URL"]),
orcarouter_base_url: env_non_blank(&["ORCAROUTER_BASE_URL"]),
xiaomi_mimo_base_url: env_non_blank(&["XIAOMI_MIMO_BASE_URL", "MIMO_BASE_URL"]),
novita_base_url: env_non_blank(&["NOVITA_BASE_URL"]),
fireworks_base_url: env_non_blank(&["FIREWORKS_BASE_URL"]),
siliconflow_base_url: env_non_blank(&["SILICONFLOW_BASE_URL"]),
siliconflow_model: env_non_blank(&["SILICONFLOW_MODEL"]),
arcee_base_url: env_non_blank(&["ARCEE_BASE_URL"]),
moonshot_base_url: env_non_blank(&["MOONSHOT_BASE_URL", "KIMI_BASE_URL"]),
sglang_base_url: env_non_blank(&["SGLANG_BASE_URL"]),
vllm_base_url: env_non_blank(&["VLLM_BASE_URL"]),
ollama_base_url: env_non_blank(&["OLLAMA_BASE_URL"]),
ollama_cloud_base_url: env_non_blank(&["OLLAMA_CLOUD_BASE_URL"]),
ollama_cloud_model: env_non_blank(&["OLLAMA_CLOUD_MODEL"]),
huggingface_base_url: env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]),
huggingface_model: env_non_blank(&["HUGGINGFACE_MODEL", "HF_MODEL"]),
modelscope_base_url: env_non_blank(&["MODELSCOPE_BASE_URL"]),
modelscope_model: env_non_blank(&["MODELSCOPE_MODEL"]),
together_base_url: env_non_blank(&["TOGETHER_BASE_URL"]),
together_model: env_non_blank(&["TOGETHER_MODEL"]),
qianfan_base_url: env_non_blank(&["QIANFAN_BASE_URL", "BAIDU_QIANFAN_BASE_URL"]),
qianfan_model: env_non_blank(&["QIANFAN_MODEL", "BAIDU_QIANFAN_MODEL"]),
openai_codex_base_url: env_non_blank(&["OPENAI_CODEX_BASE_URL", "CODEX_BASE_URL"]),
openai_codex_model: env_non_blank(&["OPENAI_CODEX_MODEL", "CODEX_MODEL"]),
anthropic_base_url: env_non_blank(&["ANTHROPIC_BASE_URL"]),
anthropic_model: env_non_blank(&["ANTHROPIC_MODEL"]),
openmodel_base_url: env_non_blank(&["OPENMODEL_BASE_URL"]),
openmodel_model: env_non_blank(&["OPENMODEL_MODEL"]),
zai_base_url: env_non_blank(&["ZAI_BASE_URL", "Z_AI_BASE_URL", "ZHIPU_BASE_URL", "ZHIPUAI_BASE_URL", "BIGMODEL_BASE_URL"]),
zai_model: env_non_blank(&["ZAI_MODEL", "Z_AI_MODEL", "ZHIPU_MODEL", "ZHIPUAI_MODEL", "BIGMODEL_MODEL", "GLM_MODEL"]),
stepfun_base_url: env_non_blank(&["STEPFUN_BASE_URL", "STEP_BASE_URL"]),
stepfun_model: env_non_blank(&["STEPFUN_MODEL", "STEP_MODEL"]),
minimax_base_url: env_non_blank(&["MINIMAX_BASE_URL"]),
minimax_anthropic_base_url: env_non_blank(&["MINIMAX_ANTHROPIC_BASE_URL"]),
minimax_model: env_non_blank(&["MINIMAX_MODEL"]),
deepinfra_base_url: env_non_blank(&["DEEPINFRA_BASE_URL"]),
deepinfra_model: env_non_blank(&["DEEPINFRA_MODEL"]),
sakana_base_url: env_non_blank(&["SAKANA_BASE_URL"]),
sakana_model: env_non_blank(&["SAKANA_MODEL"]),
longcat_base_url: env_non_blank(&["LONGCAT_BASE_URL"]),
longcat_model: env_non_blank(&["LONGCAT_MODEL"]),
opencode_go_base_url: env_non_blank(&["OPENCODE_GO_BASE_URL"]),
opencode_go_model: env_non_blank(&["OPENCODE_GO_MODEL"]),
opencode_zen_base_url: env_non_blank(&["OPENCODE_ZEN_BASE_URL"]),
opencode_zen_model: env_non_blank(&["OPENCODE_ZEN_MODEL"]),
meta_base_url: env_non_blank(&["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"]),
meta_model: env_non_blank(&["META_MODEL_API_MODEL", "MODEL_API_MODEL"]),
xai_base_url: env_non_blank(&["XAI_BASE_URL"]),
xai_model: env_non_blank(&["XAI_MODEL"]),
google_base_url: env_non_blank(&["GOOGLE_BASE_URL", "GEMINI_BASE_URL"]),
google_model: env_non_blank(&["GOOGLE_MODEL", "GEMINI_MODEL"]),
mistral_base_url: env_non_blank(&["MISTRAL_BASE_URL"]),
mistral_model: env_non_blank(&["MISTRAL_MODEL"]),
telecomjs_base_url: env_non_blank(&["TELECOMJS_BASE_URL"]),
telecomjs_model: env_non_blank(&["TELECOMJS_MODEL"]),
edenai_base_url: env_non_blank(&["EDENAI_BASE_URL"]),
edenai_model: env_non_blank(&["EDENAI_MODEL"]),
zenmux_base_url: env_non_blank(&["ZENMUX_BASE_URL"]),
zenmux_model: env_non_blank(&["ZENMUX_MODEL"]),
csdn_base_url: env_non_blank(&["CSDN_BASE_URL"]),
csdn_model: env_non_blank(&["CSDN_MODEL"]),
concentrate_base_url: env_non_blank(&["CONCENTRATE_BASE_URL"]),
concentrate_model: env_non_blank(&["CONCENTRATE_MODEL"]),
// `CODEWHALE_API_BASE` is a trust boundary, not a plain string:
// an origin this route would refuse to send a bearer to is
// dropped here rather than resolved into a route.
codewhale_base_url: provider::codewhale_api_base_from_env(),
codewhale_model: std::env::var("CODEWHALE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
codewhale_model: env_non_blank(&["CODEWHALE_MODEL"]),
modelstudio_token_plan_base_url: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]),
modelstudio_token_plan_model: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_MODEL"]),
modelstudio_coding_plan_base_url: env_non_blank(&["MODELSTUDIO_CODING_PLAN_BASE_URL"]),
modelstudio_coding_plan_model: env_non_blank(&["MODELSTUDIO_CODING_PLAN_MODEL"]),
}
}
-7
View File
@@ -5,13 +5,6 @@ use std::path::PathBuf;
/// On-disk schema for the `[hook_sinks]` table.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HookSinksToml {
/// Unix domain socket path used by the app-server event sink.
///
/// Known limitation: only `codewhale app-server` reads this table; the
/// TUI and the Runtime API do not dispatch hook events to it.
///
/// When unset, no Unix socket sink is registered. There is deliberately no
/// shared `/tmp` default because socket ownership should be explicit.
#[serde(default)]
pub unix_socket_path: Option<PathBuf>,
}
+2 -2
View File
@@ -314,10 +314,10 @@ fn rollback(snapshots: &[Snapshot]) {
}
// FEAT-025 D4: the pure secret-redaction primitives moved to
// `codewhale-secrets::redact` so portable command helpers can share one
// `codewhale-sanitize::redact` so portable command helpers can share one
// implementation without depending on this crate. Re-exported here to keep
// the existing `codewhale_config::persistence::*` public API stable.
pub use codewhale_secrets::redact::{
pub use codewhale_sanitize::redact::{
REDACTED, RedactionPolicy, redact_json_model_bound_secrets, redact_json_secrets,
redact_model_bound_secrets, redact_secrets, redact_secrets_with,
};
+2 -2
View File
@@ -59,7 +59,7 @@ pub fn check_config_toml_choice(key: &str, value: &str) -> Result<()> {
} else {
""
};
let value = codewhale_secrets::redact::redact_secrets(value);
let value = codewhale_sanitize::redact::redact_secrets(value);
bail!(
"invalid value '{value}' for '{key}': config.toml accepts {}.{settings_note} \
No value was changed.\nfix: codewhale config set {key} {}",
@@ -205,7 +205,7 @@ pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
// echoed; an ordinary typo stays readable.
Some(name) => format!(
"unknown provider '{}'",
codewhale_secrets::redact::redact_secrets(&name)
codewhale_sanitize::redact::redact_secrets(&name)
),
None => "unknown provider".to_string(),
},
+2 -8
View File
@@ -605,7 +605,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
def(
"show_thinking",
SettingKind::Bool(SHOW_THINKING),
"true",
"false",
ui(
TAB_APPEARANCE,
"display",
@@ -1313,7 +1313,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
def(
"telemetry",
SettingKind::Bool(TELEMETRY),
"false",
"true",
ui(
TAB_TRUST,
"network",
@@ -1546,12 +1546,6 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"false",
None,
),
def(
"account_preferences_notice_shown",
SettingKind::String,
"",
None,
),
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
def(
"work_surface_bottom_migrated",
+6 -7
View File
@@ -27,10 +27,11 @@ pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::D
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
),
_ => stream_open_timeout_from_env(
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
.ok()
.as_deref(),
crate::env_overrides::env_non_blank(&[
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
])
.as_deref(),
),
}
}
@@ -50,9 +51,7 @@ pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration
/// `false`, ...) leaves HTTP/2 on.
#[must_use]
pub fn force_http1_from_env() -> bool {
std::env::var("CODEWHALE_FORCE_HTTP1")
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
.ok()
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
.map(|v| v.trim().to_ascii_lowercase())
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
+54
View File
@@ -10767,6 +10767,60 @@ fn blank_or_unrecognized_env_overrides_do_not_shadow_config_or_legacy_vars() {
));
}
#[test]
fn blank_primary_env_vars_fall_through_to_their_legacy_aliases() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let names = [
"CODEWHALE_FORCE_HTTP1",
"DEEPSEEK_FORCE_HTTP1",
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
];
let saved: Vec<(&str, Option<OsString>)> = names
.iter()
.map(|name| (*name, env::var_os(name)))
.collect();
// SAFETY: env mutation is serialized by `env_lock` and restored below.
unsafe {
env::set_var("CODEWHALE_MODEL", "");
env::set_var("DEEPSEEK_MODEL", "legacy-model");
env::set_var("CODEWHALE_BASE_URL", " ");
env::set_var("DEEPSEEK_BASE_URL", "https://legacy.example/v1");
env::set_var("MOONSHOT_BASE_URL", "");
env::set_var("KIMI_BASE_URL", "https://kimi.example/v1");
env::set_var("CODEWHALE_FORCE_HTTP1", "");
env::set_var("DEEPSEEK_FORCE_HTTP1", "1");
env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "");
env::set_var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS", "120");
}
let env_overrides = EnvRuntimeOverrides::load();
let force_http1 = crate::stream_timeouts::force_http1_from_env();
let open_timeout = crate::stream_timeouts::resolve_stream_open_timeout(None);
unsafe {
for (name, value) in saved {
match value {
Some(value) => env::set_var(name, value),
None => env::remove_var(name),
}
}
}
assert_eq!(env_overrides.model.as_deref(), Some("legacy-model"));
assert_eq!(
env_overrides.active_route_base_url.as_deref(),
Some("https://legacy.example/v1")
);
assert_eq!(
env_overrides.moonshot_base_url.as_deref(),
Some("https://kimi.example/v1")
);
assert!(force_http1);
assert_eq!(open_timeout, std::time::Duration::from_secs(120));
}
#[cfg(unix)]
#[test]
fn failed_legacy_state_copy_leaves_no_partial_primary() {
-2
View File
@@ -14,7 +14,6 @@ workspace = true
serde.workspace = true
thiserror.workspace = true
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-hooks = { path = "../hooks", version = "0.10.3" }
codewhale-protocol = { path = "../protocol", version = "0.10.3" }
codewhale-state = { path = "../state", version = "0.10.3" }
regex = "1.11"
@@ -24,4 +23,3 @@ serde_json = { workspace = true, features = ["preserve_order"] }
[dev-dependencies]
tempfile.workspace = true
uuid.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "time"] }
-9
View File
@@ -1,9 +0,0 @@
//! `ThreadId` / `SessionId` for the `crates/core` boundary (issue #5261).
//!
//! Re-exports the protocol ids so every crate that depends on `core` (the
//! TUI, CLI, app-server) speaks the same typed ids without depending on
//! `protocol` directly. The persisted JSON shape stays a plain string
//! (`"thread-…"` / `"session-…"`) so existing `state.json` / `threads/`
//! files need no migration.
pub use codewhale_protocol::ids::{SessionId, ThreadId};
-78
View File
@@ -1,78 +0,0 @@
//! Session tree journal placeholder (issue #5262).
//!
//! The journal is append-only with an in-memory tree projection:
//! every non-header entry carries `id` + `parentId`, the active position is
//! a `leafId`, appending creates a child of the leaf, and branching only
//! moves the leaf — it never rewrites history. This file lands the entry
//! shape that #5262's tree operations hang off of; compaction and
//! branch-summary entry kinds are included as first-class kinds but their
//! *strategies* are deferred.
//!
//! Re-exports the protocol journal as the canonical shape so `protocol` and
//! `core` agree on the wire. `core` adds the `SessionJournal` wrapper that
//! owns the `current_leaf_id` column in `state.threads`.
pub use codewhale_protocol::journal::{Journal, JournalEntry};
use serde::{Deserialize, Serialize};
/// Persisted thread metadata extension for the tree. This is the
/// `current_leaf_id` column added to `state.threads`; `None` before the
/// first turn, `Some(id)` after. The existing `threads` JSON shape is
/// otherwise unchanged (back-compat: old rows read as `None` and the next
/// append mints the header leaf).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThreadLeafState {
pub thread_id: String,
pub leaf_id: Option<String>,
}
/// First-class journal entry kinds (data shape lands now; strategies later).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JournalKind {
Header,
User,
Assistant,
ToolResult,
Compaction,
BranchSummary,
}
impl JournalKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Header => "header",
Self::User => "user",
Self::Assistant => "assistant",
Self::ToolResult => "tool_result",
Self::Compaction => "compaction",
Self::BranchSummary => "branch_summary",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn leaf_state_roundtrip() {
let s = ThreadLeafState {
thread_id: "thread-1".into(),
leaf_id: Some("entry-abc".into()),
};
let j = serde_json::to_string(&s).unwrap();
let back: ThreadLeafState = serde_json::from_str(&j).unwrap();
assert_eq!(back, s);
}
#[test]
fn journal_append_is_child_of_leaf() {
let mut j = Journal::new();
let a = j.append("header", json!({}));
let b = j.append("user", json!("hi"));
assert_eq!(j.get(&b).unwrap().parent_id.as_deref(), Some(a.as_str()));
}
}
+5 -18
View File
@@ -1,12 +1,9 @@
pub mod context_reference;
pub mod fragments;
pub mod ids;
pub mod journal;
pub mod prefix_cache;
pub mod request;
pub mod role;
pub mod secret_eq;
pub mod session;
pub mod tool_parser;
pub use context_reference::{
@@ -15,10 +12,9 @@ pub use context_reference::{
};
use codewhale_config::{ConfigToml, ProviderKind};
use codewhale_hooks::HookDispatcher;
use codewhale_state::StateStore;
/// Compatibility configuration and hooks.
/// Compatibility configuration.
///
/// Conversation execution and durable history belong to the held canonical
/// RuntimeThreadManager/Engine. Legacy State data is retained for read-only
@@ -26,16 +22,11 @@ use codewhale_state::StateStore;
pub struct Runtime {
pub config: ConfigToml,
state: StateStore,
pub hooks: HookDispatcher,
}
impl Runtime {
pub fn new(config: ConfigToml, state: StateStore, hooks: HookDispatcher) -> Self {
Self {
config,
state,
hooks,
}
pub fn new(config: ConfigToml, state: StateStore) -> Self {
Self { config, state }
}
/// Existing compatibility data and receipts; no transcript controller.
@@ -103,7 +94,7 @@ mod tests {
fn runtime_bookkeeping_keeps_the_legacy_store_read_only() {
let store = temp_core_state("read-only-runtime");
let before = store.list_threads(Default::default()).unwrap();
let mut runtime = Runtime::new(ConfigToml::default(), store, HookDispatcher::default());
let mut runtime = Runtime::new(ConfigToml::default(), store);
runtime.update_config(ConfigToml::default());
assert_eq!(
runtime
@@ -113,11 +104,7 @@ mod tests {
.len(),
before.len()
);
let reopened = Runtime::new(
runtime.config.clone(),
runtime.state_store().clone(),
HookDispatcher::default(),
);
let reopened = Runtime::new(runtime.config.clone(), runtime.state_store().clone());
assert!(
reopened
.state_store()
-135
View File
@@ -1,135 +0,0 @@
//! `Thread` / `Session` split (issue #5261).
//!
//! `codewhale`'s `Session` was really a thread. The new split is:
//! - `Thread` — durable, persisted, owns the append-only `Journal` and the
//! `leafId` cursor. One row in `state.threads`, one directory on disk.
//! - `Session` — ephemeral, per-turn / per-engine-lifetime, owns the
//! in-memory `TurnContext` plus the live approval/sandbox posture for this
//! `SessionId`. Many sessions can attach to one thread over time, but only
//! one `Session` drives a turn for a given `ThreadId` at a time.
//!
//! The canonical RuntimeThreadManager creates sessions without a TUI and drives
//! the same Engine turn loop used by mounted clients. These types preserve the
//! shared model, append log, prefix stability and message revision state; the
//! compatibility Core Runtime owns only configuration, hooks and job bookkeeping.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::ids::{SessionId, ThreadId};
use crate::journal::Journal;
/// Durable thread (the former `Session`). One per conversation, persisted in
/// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the
/// journal cursor — plus the typed `ThreadId`. All other fields keep their
/// persisted JSON shape unchanged.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
pub thread_id: ThreadId,
/// Active branch tip. `None` before the first journal header.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<String>,
/// Journal (append-only). In-memory projection of the persisted
/// `threads/turns/items/events` layout is derived root→leaf.
#[serde(default)]
pub journal: Journal,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
pub workspace: PathBuf,
#[serde(default)]
pub ephemeral: bool,
}
impl Thread {
#[must_use]
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
Self {
thread_id,
leaf_id: None,
journal: Journal::new(),
model: model.into(),
reasoning_effort: None,
workspace,
ephemeral: false,
}
}
#[must_use]
pub fn leaf_id(&self) -> Option<&str> {
self.leaf_id.as_deref()
}
pub fn set_leaf(&mut self, leaf: Option<String>) {
self.leaf_id = leaf;
}
}
/// Ephemeral session within a thread (one engine lifetime / one turn's
/// live posture). The TUI's `EngineHandle` and the headless `exec` both
/// hold a `Session` that points at the same `ThreadId` but with different
/// `SessionId`s.
#[derive(Debug, Clone)]
pub struct Session {
pub session_id: SessionId,
pub thread_id: ThreadId,
/// Model for this session's next turn (may differ from thread default).
pub model: String,
pub workspace: PathBuf,
/// Monotonic `messages_revision` for prefix-cache memoization (carried
/// from the former `Session::messages_revision`).
pub messages_revision: u64,
}
impl Session {
#[must_use]
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
Self {
session_id: SessionId::new(),
thread_id,
model: model.into(),
workspace,
messages_revision: 0,
}
}
pub fn bump_revision(&mut self) {
self.messages_revision = self.messages_revision.wrapping_add(1);
}
}
/// Split helper: derive a `Session` from an existing `Thread` without
/// cloning the journal. Headless and TUI call the same constructor so
/// the request shape stays identical.
#[must_use]
pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session {
Session::new(thread.thread_id.clone(), workspace, thread.model.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thread_and_session_ids_are_distinct_scopes() {
let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash");
let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
assert_eq!(s1.thread_id, s2.thread_id);
assert_ne!(s1.session_id, s2.session_id);
}
#[test]
fn leaf_is_moved_not_rewritten() {
let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m");
let a = t.journal.append("header", serde_json::json!({}));
let b = t.journal.append("user", serde_json::json!("b"));
t.leaf_id = t.journal.leaf_id.clone();
assert_eq!(t.leaf_id.as_deref(), Some(b.as_str()));
assert!(t.journal.branch_to(&a));
t.leaf_id = t.journal.leaf_id.clone();
assert_eq!(t.leaf_id.as_deref(), Some(a.as_str()));
assert_eq!(t.journal.len(), 2); // history never rewritten; branching only moved the leaf
}
}
+5 -3
View File
@@ -1183,7 +1183,7 @@ impl Git {
overrides: Vec<(String, &'static str)>,
) -> anyhow::Result<Command> {
let mut command = Self::review_base(workspace)?;
let mut count = 2;
let mut count = 3;
for (key, value) in overrides {
// A subsection may contain '='; `-c key=value` would then
// override a different key. Separate env fields preserve it.
@@ -1195,7 +1195,7 @@ impl Git {
Ok(command)
}
/// Git with fsmonitor, hooks, lazy fetch and replace objects disabled,
/// Git with fsmonitor, hooks, lazy fetch, replace objects and path quoting disabled,
/// running in `workspace`. [`Self::review_command`] adds filter overrides.
pub fn review_base(workspace: &Path) -> anyhow::Result<Command> {
use anyhow::Context;
@@ -1257,7 +1257,7 @@ impl Git {
// Both phases must observe the same effective repository config.
.env_remove("GIT_CONFIG")
.env_remove("GIT_CONFIG_PARAMETERS")
.env("GIT_CONFIG_COUNT", "2")
.env("GIT_CONFIG_COUNT", "3")
.env("GIT_CONFIG_KEY_0", "core.fsmonitor")
.env("GIT_CONFIG_VALUE_0", "false")
.env("GIT_CONFIG_KEY_1", "core.hooksPath")
@@ -1265,6 +1265,8 @@ impl Git {
"GIT_CONFIG_VALUE_1",
if cfg!(windows) { "NUL" } else { "/dev/null" },
)
.env("GIT_CONFIG_KEY_2", "core.quotePath")
.env("GIT_CONFIG_VALUE_2", "false")
.env("GIT_NO_LAZY_FETCH", "1")
.env("GIT_NO_REPLACE_OBJECTS", "1");
Ok(command)
-9
View File
@@ -79,7 +79,6 @@ codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-memory = { path = "../memory", version = "0.10.3" }
anyhow.workspace = true
codewhale-agent = { path = "../agent", version = "0.10.3" }
codewhale-agent-contract = { path = "../agent-contract", version = "0.10.3" }
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-command-contract = { path = "../command-contract", version = "0.10.3" }
@@ -203,14 +202,9 @@ codewhale-util = { path = "../util", version = "0.10.3", features = ["test-suppo
codewhale-models = { path = "../models", version = "0.10.3", features = [
"test-support",
] }
cucumber = "0.23.0"
wiremock = "0.6"
tiny_http = "0.12"
pretty_assertions = "1.4"
rio-vt = "0.5.1"
rusqlite.workspace = true
schemars = { version = "1.2.1", features = ["derive", "preserve_order"] }
tokio-tungstenite = "0.30"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -219,9 +213,6 @@ libc = "0.2"
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemServices", "Win32_System_WindowsProgramming"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies]
arboard = { version = "3.4", features = ["wayland-data-control"] }
[target.'cfg(not(target_env = "ohos"))'.dependencies]
portable-pty = "0.9"
+1 -1
View File
@@ -18,7 +18,7 @@ var ErrorCode = {
Denied: -32003,
Cancelled: -32800
};
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote"];
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote", "plugins"];
var METHODS = [
{ name: "host/initialize", direction: "core_to_host", request: true, params: "InitializeParams", tiers: ["plugin", "builtin"] },
{ name: "host/ping", direction: "core_to_host", request: true, params: "EmptyParams", tiers: ["plugin", "builtin"] },
@@ -25,7 +25,7 @@ export const ErrorCode = {
export type Direction = 'core_to_host' | 'host_to_core'
export const DSH_SERVICES = ['workspace', 'llm', 'network', 'settings', 'catalog', 'terminal', 'remote'] as const
export const DSH_SERVICES = ['workspace', 'llm', 'network', 'settings', 'catalog', 'terminal', 'remote', 'plugins'] as const
export type DshService = typeof DSH_SERVICES[number]
/** Every method either side may send, and the trust tiers it is allowed on; nothing else is admitted. */
+1 -1
View File
@@ -56,7 +56,7 @@ use codewhale_models::{
ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Usage,
};
use codewhale_runtime::logging;
use codewhale_secrets::sanitize::redact_url_for_display;
use codewhale_sanitize::sanitize::redact_url_for_display;
/// Every provider request that can feed the interactive TUI's attached CWC run
/// takes a shared permit at this lowest common dispatch seam. Runtime Chat holds
+1 -1
View File
@@ -115,7 +115,7 @@ impl RouteShape {
/// Which endpoint this request would be POSTed to, as typed facts.
///
/// `url` is the real, unredacted target: production needs it to send. Every
/// display surface must go through [`codewhale_secrets::sanitize::redact_url_for_display`] rather
/// display surface must go through [`codewhale_sanitize::sanitize::redact_url_for_display`] rather
/// than printing it, and the manifest only ever publishes the redacted
/// scheme/host and a fingerprint — never the path, which can itself carry a
/// deployment secret.
+12
View File
@@ -138,6 +138,16 @@ pub struct SystemOneResponse {
pub answers_validated: Option<bool>,
}
impl SystemOneResponse {
pub fn abstained_questions(&self, names: &[&str]) -> Vec<String> {
names
.iter()
.filter(|name| self.answers.get(**name).is_some_and(|a| a.abstained))
.map(|name| (*name).to_string())
.collect()
}
}
impl<'de> Deserialize<'de> for SystemOneResponse {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
@@ -201,6 +211,8 @@ pub struct SystemOneAnswer {
pub probabilities: BTreeMap<String, Option<f64>>,
#[serde(default)]
pub confidence: Option<f64>,
#[serde(default)]
pub abstained: bool,
}
/// Provider usage. OpenRouter adds `cost`; the token-count casing differs
+2
View File
@@ -29,6 +29,8 @@ pub struct UserCommand {
pub takes_arguments: bool,
pub hidden: bool,
pub extension: bool,
pub plugin_id: Option<String>,
pub workspace_sourced: bool,
pub builtin_bindings: Vec<String>,
}
@@ -1130,7 +1130,7 @@ pub fn build_reviewer_context(
held_reason: &str,
tool_input: &Value,
) -> String {
use codewhale_secrets::redact::{redact_json_model_bound_secrets, redact_model_bound_secrets};
use codewhale_sanitize::redact::{redact_json_model_bound_secrets, redact_model_bound_secrets};
let mut input = redact_json_model_bound_secrets(tool_input);
let tool = redact_model_bound_secrets(ctx.tool_name.as_ref());
@@ -2152,10 +2152,7 @@ fn git_push_refspec_is_protected(refspec: &str) -> bool {
// Tag-like names (`v1`, `v0.9.1`): git resolves branch-vs-tag on the
// server, so treat them as publishes.
let mut chars = lower.chars();
if chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit()) {
return true;
}
false
chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit())
}
fn git_tag_tokens_are_publish_like(tokens: &[&str]) -> bool {
+220 -9
View File
@@ -2640,6 +2640,8 @@ mod tests {
enum Posture {
Ask,
FullAccess,
AutoReview,
Plan,
}
struct ExtensionTurn {
@@ -2655,18 +2657,44 @@ mod tests {
/// A turn whose model calls the extension tool (id `ext-1`) with `input`,
/// over the file, shell and web tools.
fn start_extension_turn(input: Value, posture: Posture) -> ExtensionTurn {
start_extension_turn_with(input, posture, Vec::new())
}
fn start_extension_turn_with(
input: Value,
posture: Posture,
reviewer: Vec<crate::llm_client::mock::CannedTurn>,
) -> ExtensionTurn {
start_extension_turn_hooked(input, posture, reviewer, None)
}
fn start_extension_turn_hooked(
input: Value,
posture: Posture,
reviewer: Vec<crate::llm_client::mock::CannedTurn>,
hook_executor: Option<Arc<crate::hooks::HookExecutor>>,
) -> ExtensionTurn {
let tmp = tempfile::tempdir().expect("fixture directory");
std::fs::write(tmp.path().join("a.txt"), "alpha").expect("fixture file");
let mock = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn("ext-1", FAKE_EXT, &input.to_string()),
canned::simple_text_turn("Extension finished."),
]));
let mock = Arc::new(MockLlmClient::new(
std::iter::once(canned::tool_call_turn(
"ext-1",
FAKE_EXT,
&input.to_string(),
))
.chain(reviewer)
.chain(std::iter::once(canned::simple_text_turn(
"Extension finished.",
)))
.collect(),
));
let (mut engine, handle) = Engine::new_with_model_client(
EngineConfig {
workspace: tmp.path().to_path_buf(),
snapshots_enabled: false,
subagents_enabled: false,
terminal_chrome_enabled: false,
hook_executor,
..EngineConfig::default()
},
&Config::default(),
@@ -2674,10 +2702,10 @@ mod tests {
);
let full = posture == Posture::FullAccess;
engine.session.auto_approve = full;
engine.session.approval_mode = if full {
ApprovalMode::Bypass
} else {
ApprovalMode::Suggest
engine.session.approval_mode = match posture {
Posture::FullAccess => ApprovalMode::Bypass,
Posture::AutoReview => ApprovalMode::Auto,
Posture::Ask | Posture::Plan => ApprovalMode::Suggest,
};
engine.session.mcp_config_path = tmp.path().join("mcp.json");
engine.session.add_message(Message {
@@ -2701,11 +2729,18 @@ mod tests {
registry.register(Arc::new(FakeExtensionTool {
withdraw: withdraw.clone(),
}));
for gate in crate::extension_host::dsh_broker::BrokerActTool::all() {
registry.register(gate);
}
let catalog = registry.to_api_tools_with_cache(true);
let surface = ToolSurfacePolicy::new(
registry,
Some(catalog),
AppMode::Agent,
if posture == Posture::Plan {
AppMode::Plan
} else {
AppMode::Agent
},
&engine.config.tools_always_load,
&[],
false,
@@ -3139,4 +3174,180 @@ mod tests {
vec![ApprovalOutcome::Cancelled]
);
}
fn broker_act(effect: &str) -> Value {
json!({
"plugin": "fake",
"service": "remote",
"method": "pair",
"effect": effect,
})
}
fn broker_calls(acts: &[&Value]) -> Value {
let calls: Vec<Value> = acts
.iter()
.map(|act| {
let name = if act["effect"] == "reach" {
crate::extension_host::dsh_broker::REACH_TOOL
} else {
crate::extension_host::dsh_broker::ACT_TOOL
};
json!({"name": name, "input": act})
})
.collect();
json!({ "calls": calls })
}
fn approvals(seen: &[Event]) -> usize {
seen.iter()
.filter(|event| matches!(event, Event::ApprovalRequired { .. }))
.count()
}
#[tokio::test]
async fn a_broker_act_asks_in_ask_and_proceeds_in_full_access() {
let act = broker_act("reach");
let _open = crate::extension_host::dsh_broker::OpenAct::open(&act, 0).expect("open act");
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::Ask);
let events = turn.events.clone();
let mut seen = Vec::new();
let event = next_approval_event(&events, &mut seen).await;
let (id, tool, description, _, _, force) = approval_fields(&event);
assert_eq!(tool, crate::extension_host::dsh_broker::REACH_TOOL);
assert!(!force, "the mode decides; no extension force-prompt");
assert!(
description.contains("plugin `fake`") && description.contains("remote"),
"{description}"
);
turn.handle.deny_tool_call(id).await.expect("deny");
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["rejected"], "Denied", "{answer}");
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::FullAccess);
let mut seen = Vec::new();
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["ok"], true, "{answer}");
assert_eq!(approvals(&seen), 0, "Full Access opens no card");
}
#[tokio::test]
async fn auto_review_reviewer_no_refuses_a_broker_act() {
let act = broker_act("write");
let _open = crate::extension_host::dsh_broker::OpenAct::open(&act, 0).expect("open act");
let mut turn = start_extension_turn_with(
broker_calls(&[&act]),
Posture::AutoReview,
vec![
canned::simple_text_turn(
r#"{"risk_level":"low","decision":"allow","reason":"the person asked to run the extension"}"#,
),
canned::simple_text_turn(
r#"{"risk_level":"high","decision":"deny","reason":"not what the person asked for"}"#,
),
],
);
let mut seen = Vec::new();
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
assert_eq!(approvals(&seen), 0, "the reviewer, not a person, decided");
}
#[tokio::test]
async fn plan_refuses_a_broker_act_that_writes_or_reaches_out() {
let reach = broker_act("reach");
let write = broker_act("write");
let read = broker_act("read");
let _open: Vec<_> = [&reach, &write, &read]
.into_iter()
.map(|act| crate::extension_host::dsh_broker::OpenAct::open(act, 0).expect("open act"))
.collect();
let mut turn = start_extension_turn(broker_calls(&[&reach, &write, &read]), Posture::Plan);
let mut seen = Vec::new();
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
assert_eq!(answer["results"][1]["rejected"], "Refused", "{answer}");
assert_eq!(answer["results"][2]["ok"], true, "{answer}");
assert_eq!(approvals(&seen), 0);
}
#[tokio::test]
async fn a_broker_act_nobody_opened_is_refused_before_any_card() {
let act =
json!({"plugin": "fake", "service": "network", "method": "fetch", "effect": "reach"});
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::Ask);
let mut seen = Vec::new();
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
assert_eq!(approvals(&seen), 0);
}
#[cfg(unix)]
#[tokio::test]
async fn a_hook_rewrite_cannot_make_the_gate_answer_for_another_act() {
use crate::extension_host::dsh_broker::{OpenAct, REACH_TOOL, answered_for};
let act = |target: &str| {
json!({
"plugin": "fake",
"service": "network",
"method": "fetch",
"effect": "reach",
"target": target,
})
};
let shown = act("api.github.com");
let asked = act("evil.example");
let hooks = tempfile::tempdir().expect("hook directory");
let hook = codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::ToolCallBefore,
&format!("printf '%s' '{}'", json!({ "updatedInput": shown })),
)
.with_condition(codewhale_config::hooks::HookCondition::ToolName {
name: REACH_TOOL.to_string(),
});
let executor = Arc::new(crate::hooks::HookExecutor::new(
codewhale_config::hooks::HooksConfig {
enabled: true,
hooks: vec![hook],
..codewhale_config::hooks::HooksConfig::default()
},
hooks.path().to_path_buf(),
));
let _asked_open = OpenAct::open(&asked, 0).expect("open the asked act");
let mut turn = start_extension_turn_hooked(
broker_calls(&[&asked]),
Posture::FullAccess,
Vec::new(),
Some(executor.clone()),
);
let mut seen = Vec::new();
let control = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(control["results"][0]["rejected"], "Refused", "{control}");
let _shown_open = OpenAct::open(&shown, 1).expect("open the shown act");
let mut turn = start_extension_turn_hooked(
broker_calls(&[&asked]),
Posture::Ask,
Vec::new(),
Some(executor),
);
let events = turn.events.clone();
let mut seen = Vec::new();
let event = next_approval_event(&events, &mut seen).await;
let (id, _tool, description, _key, _, _force) = approval_fields(&event);
assert!(description.contains("api.github.com"), "{description}");
turn.handle.approve_tool_call(id).await.expect("approve");
let answer = finish_extension_turn(&mut turn, &mut seen).await;
let result = &answer["results"][0]["result"];
assert!(
answered_for(&shown, result),
"the gate names the act it decided: {answer}"
);
assert!(
!answered_for(&asked, result),
"the yes is not for the act that asked: {answer}"
);
}
}
+32 -14
View File
@@ -456,7 +456,7 @@ async fn captured_identity_cancel_and_task_bounds_refuse_before_any_provider() {
#[tokio::test]
async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_retaining_authority() {
struct Pending;
struct Pending(Arc<tokio::sync::Notify>);
impl Replies for Pending {
fn effective_route_envelope(
&self,
@@ -478,24 +478,42 @@ async fn dropping_an_actual_dispatched_core_call_keeps_unknown_usage_without_ret
&self,
_: MessageRequest,
) -> Pin<Box<dyn Future<Output = anyhow::Result<MessageResponse>> + Send + '_>> {
self.0.notify_one();
Box::pin(std::future::pending())
}
}
let caller = Arc::new(caller_for(Arc::new(Pending), "captured-model"));
let entered = Arc::new(tokio::sync::Notify::new());
let caller = Arc::new(caller_for(
Arc::new(Pending(entered.clone())),
"captured-model",
));
let weak_caller = Arc::downgrade(&caller);
let usage = RlmUsageAccumulator::new();
let result = caller
.dispatch(RlmInvocation {
prompt: "pending".into(),
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: Some(tokio::time::Instant::now() + Duration::from_millis(100)),
gate: None,
events: None,
usage: usage.clone(),
})
.await;
let mut dispatch = caller.dispatch(RlmInvocation {
prompt: "pending".into(),
mode: RlmMode::Completion,
max_tokens: None,
task_instructions: None,
deadline: Some(tokio::time::Instant::now() + Duration::from_secs(600)),
gate: None,
events: None,
usage: usage.clone(),
});
tokio::time::timeout(Duration::from_secs(10), async {
tokio::select! {
biased;
() = entered.notified() => {}
result = &mut dispatch => panic!(
"dropped dispatch never reached the client: {:?}",
result.error
),
}
})
.await
.expect("the dispatched call must reach the client");
tokio::time::pause();
tokio::time::advance(Duration::from_secs(601)).await;
let result = dispatch.await;
assert!(
result
.error
@@ -30,18 +30,13 @@ use std::hash::{Hash, Hasher};
use crate::compaction::estimate_input_tokens_for_pressure;
use codewhale_models::{Message, SystemPrompt};
/// Default capacity for the rolling audit ring. Sized so a 64-entry window
/// covers a full capacity controller observation cycle without unbounded
/// growth on long-running sessions.
const AUDIT_RING_CAPACITY: usize = 64;
/// Process-local memoization for `estimate_input_tokens_for_pressure`.
///
/// The cache is keyed on the `(messages_revision, system_fingerprint)`
/// pair, both of which the engine bumps on every content change. On a hit
/// the previously stored token estimate is returned without re-walking the
/// message list. On a miss, the estimator runs and the result is stored
/// alongside the audit ring entry.
/// for the next lookup.
#[derive(Debug, Default, Clone)]
pub struct TokenEstimateCache {
/// Monotonic counter bumped by the engine on every message mutation.
@@ -51,10 +46,6 @@ pub struct TokenEstimateCache {
system_fingerprint: u64,
/// Cached token count, valid iff both keys match the current inputs.
cached_tokens: Option<usize>,
/// Audit ring of recent (revision, tokens) pairs. The most recent entry
/// is the tail; the oldest is dropped when capacity is exceeded. Used by
/// observability to surface cache effectiveness to `/status`.
audit_ring: Vec<(u64, usize)>,
/// Number of cache hits since the cache was last cleared. Saturates at
/// `u64::MAX` (effectively never in practice).
hits: u64,
@@ -97,7 +88,6 @@ impl TokenEstimateCache {
self.system_fingerprint = system_fingerprint;
self.cached_tokens = Some(tokens);
self.misses = self.misses.saturating_add(1);
self.push_audit(messages_revision, tokens);
tokens
}
@@ -115,7 +105,6 @@ impl TokenEstimateCache {
pub fn invalidate(&mut self) {
self.cached_tokens = None;
self.system_fingerprint = 0;
self.audit_ring.clear();
self.hits = 0;
self.misses = 0;
}
@@ -125,20 +114,6 @@ impl TokenEstimateCache {
pub fn stats(&self) -> (u64, u64) {
(self.hits, self.misses)
}
/// Returns the most recent `(revision, tokens)` audit entries, newest
/// first. Bounded by [`AUDIT_RING_CAPACITY`].
#[must_use]
pub fn recent_audit(&self) -> &[(u64, usize)] {
&self.audit_ring
}
fn push_audit(&mut self, revision: u64, tokens: usize) {
if self.audit_ring.len() >= AUDIT_RING_CAPACITY {
self.audit_ring.remove(0);
}
self.audit_ring.push((revision, tokens));
}
}
/// Stable 64-bit hash of the system prompt text. Walks the same shape the
@@ -288,29 +263,4 @@ mod tests {
assert_eq!(hits, 0);
assert_eq!(misses, 2);
}
#[test]
fn audit_ring_records_recent_pairs() {
let mut cache = TokenEstimateCache::new();
let messages = vec![user_text("hi")];
for rev in 1..=5 {
let _ = cache.lookup_or_compute(rev, None, &messages);
}
let ring = cache.recent_audit();
assert_eq!(ring.len(), 5);
assert_eq!(ring.last().copied(), Some((5, ring.last().unwrap().1)));
}
#[test]
fn audit_ring_bounded_by_capacity() {
let mut cache = TokenEstimateCache::new();
let messages = vec![user_text("hi")];
for rev in 1..=(AUDIT_RING_CAPACITY + 10) as u64 {
let _ = cache.lookup_or_compute(rev, None, &messages);
}
let ring = cache.recent_audit();
assert_eq!(ring.len(), AUDIT_RING_CAPACITY);
// newest entry should be the most recent revision we asked for
assert_eq!(ring.last().unwrap().0, (AUDIT_RING_CAPACITY + 10) as u64);
}
}
+3 -1
View File
@@ -5968,7 +5968,9 @@ fn mode_blocks_write_capable_tool(
&& (matches!(
canonical_action_alias(tool_name, input),
"write_file" | "edit_file" | "apply_patch"
) || (McpPool::is_mcp_tool(tool_name) && !read_only))
) || ((McpPool::is_mcp_tool(tool_name)
|| crate::extension_host::dsh_broker::is_gate_tool(tool_name))
&& !read_only))
}
/// Synthesize the tool result recorded for a tool call that never executed
+2 -2
View File
@@ -301,7 +301,7 @@ fn mcp_snapshot_to_wire(snapshot: &McpManagerSnapshot) -> wire::McpManagerSnapsh
/// `EventMsg::McpSessionBoot`, which every SSE and stream-JSON consumer
/// receives and any frame-retaining log keeps.
///
/// URLs reuse the existing masking in `codewhale_secrets::sanitize::redact_url_for_display`. Stdio
/// URLs reuse the existing masking in `codewhale_sanitize::sanitize::redact_url_for_display`. Stdio
/// keeps the program name and elides the arguments, because a secret there can
/// be positional and is not reliably recognizable by key.
fn redacted_command_or_url(raw: &str) -> String {
@@ -315,7 +315,7 @@ fn redacted_command_or_url(raw: &str) -> String {
// mentions a URL — `docker run -e TOKEN=… img --url https://…` — would
// reach the wire with its positional secret intact.
if !trimmed.is_empty() && !trimmed.contains(char::is_whitespace) && trimmed.contains("://") {
return codewhale_secrets::sanitize::redact_url_for_display(trimmed);
return codewhale_sanitize::sanitize::redact_url_for_display(trimmed);
}
if let Some((program, rest)) = trimmed.split_once(char::is_whitespace)
&& !rest.trim().is_empty()
+1
View File
@@ -2643,6 +2643,7 @@ pub fn decision_receipt_fixture(source_id: &str) -> RuntimeDecisionReceipt {
probabilities_bp: Default::default(),
confidence_bp: 0,
min_confidence_bp: 5_000,
abstained_questions: Vec::new(),
cost_saving_kept_fast: false,
thinking: None,
provider_reported_cost_usd: Some("0.000012054".to_string()),
+1 -1
View File
@@ -204,7 +204,7 @@ pub enum CommandOutcome {
fn strip_escapes(text: &str) -> String {
let mut clean = String::with_capacity(text.len());
codewhale_secrets::sanitize::strip_ansi_into(text, &mut clean);
codewhale_sanitize::sanitize::strip_ansi_into(text, &mut clean);
clean
}
+316 -17
View File
@@ -42,6 +42,9 @@
//! network calls force a prompt: a session grant is not consulted, and
//! Full Access still opens their card. Auto-Review and Never refuse them
//! (`resolve_approval_request_disposition`); explicit denials always win.
//! The broker's own gate tools ([`dsh_broker::GATE_TOOLS`]) are the exception:
//! the permission mode alone decides them, and only a broker arm reaches them
//! (a plugin's own `core/call` of one is refused by name).
//!
//! **Caps.** Per invocation: 50 core tool calls in all, counted together
//! across `core/call` and the nested calls of broker arms, 4 at
@@ -77,7 +80,7 @@ use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
use super::ManagerShared;
use super::dsh_broker::{self, Arm, BrokerCall, BrokerRefusal};
use super::dsh_broker::{self, Arm, BrokerCall, BrokerEffect, BrokerRefusal, OpenAct};
use super::protocol::{
ContentBlockWire, CoreCallParams, DshBrokerParams, DshBrokerTicketWire, DshService,
RpcErrorWire, SessionCheckpointParams, ToolResultWire, UserQuestionParams, error_code,
@@ -106,6 +109,7 @@ const TICKET_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const GRANT_PROBE: Duration = Duration::from_millis(50);
/// Receipts attached to an extension tool's result metadata.
pub const RECEIPTS_ATTACHED: usize = 50;
pub const MAX_ACT_RECORD_BYTES: usize = 1024;
/// Tools an extension may never reach through `core/call`, by lower-case
/// name, checked on the spelling the host sent and on the canonical action
@@ -204,7 +208,8 @@ pub fn refusal(specs: &[Arc<dyn ToolSpec>], name: &str, input: &Value) -> Option
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginApproval {
/// Planning's decision stands (the tool is in [`EXT_AUTO_ELIGIBLE`] and
/// nothing asked for approval).
/// nothing asked for approval, or it is one of the broker's gate tools,
/// which the permission mode alone decides).
Unchanged,
/// Needs approval; an extension-scoped session grant may satisfy it.
Prompt,
@@ -226,6 +231,9 @@ pub fn origin_approval(
let lower = name.to_ascii_lowercase();
let resolved = canonical(name, input);
if dsh_broker::is_gate_tool(&lower) && spec.is_some_and(dsh_broker::is_gate_spec) {
return OriginApproval::Unchanged;
}
let forced = |candidate: &str| {
matches!(
get_tool_category_for_call(candidate, input),
@@ -269,8 +277,11 @@ pub struct Invocation {
root_caller: Option<SessionCheckpointCaller>,
broker: Option<BrokerScope>,
nested_calls: AtomicU32,
broker_acts: Mutex<(usize, Vec<Value>, bool)>,
tickets: Vec<Ticket>,
caller_tools: Vec<String>,
gates: Vec<&'static str>,
act_slots: tokio::sync::Semaphore,
activations: Mutex<
HashMap<
String,
@@ -309,17 +320,145 @@ impl BrokerContext {
&self.cancel
}
pub async fn core_call(
&self,
name: &str,
input: Value,
) -> Result<ToolCallResponse, BrokerRefusal> {
if self.cancel.is_cancelled() {
return Err(BrokerRefusal::nested(
self.service,
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
));
pub fn record(&self, entry: Value) -> bool {
let mut entry = match entry {
Value::Object(fields) => fields,
other => {
let mut fields = serde_json::Map::new();
fields.insert("detail".to_string(), other);
fields
}
};
entry.insert(
"service".to_string(),
Value::String(self.service.name().to_string()),
);
let mut entry = Value::Object(entry);
if entry.to_string().len() > MAX_ACT_RECORD_BYTES {
let cut = |key: &str| {
entry
.get(key)
.and_then(Value::as_str)
.map_or(Value::Null, |text| {
Value::String(text.chars().take(dsh_broker::MAX_ACT_TARGET).collect())
})
};
entry = json!({
"service": self.service.name(),
"method": cut("method"),
"outcome": cut("outcome"),
"cut": true,
});
}
let mut acts = self
.invocation
.broker_acts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if acts.2 {
return false;
}
acts.0 += 1;
if acts.1.len() < RECEIPTS_ATTACHED {
acts.1.push(entry);
}
true
}
pub async fn act(
&self,
method: &str,
effect: BrokerEffect,
target: Option<&str>,
) -> Result<(), BrokerRefusal> {
let target: Option<String> =
target.map(|target| target.chars().take(dsh_broker::MAX_ACT_TARGET).collect());
let record = |outcome: &str| -> bool {
let mut entry =
json!({ "method": method, "effect": effect.name(), "outcome": outcome });
if let Some(target) = &target {
entry["target"] = json!(target);
}
self.record(entry)
};
if self.cancel.is_cancelled() {
return Err(self.withdrawn());
}
if effect == BrokerEffect::Read {
if let Err(refusal) = self.count() {
record(refusal.code());
return Err(refusal);
}
if !record("read") {
return Err(self.withdrawn());
}
return Ok(());
}
let gate_tool = effect.gate_tool();
if !self.invocation.gates.contains(&gate_tool) {
record("refused");
return Err(BrokerRefusal::Refused {
service: self.service,
message: "the permission gate is not available in this turn".to_string(),
});
}
let slot = tokio::select! {
slot = self.invocation.act_slots.acquire() => slot.ok(),
() = self.cancel.cancelled() => None,
() = self.invocation.cancel.cancelled() => None,
};
if slot.is_none() {
return Err(self.withdrawn());
}
if let Err(refusal) = self.count() {
record(refusal.code());
return Err(refusal);
}
let mut input = json!({
"plugin": self.invocation.owner.plugin_id,
"service": self.service.name(),
"method": method,
"effect": effect.name(),
});
if let Some(target) = &target {
input["target"] = json!(target);
}
let Some(_open) = OpenAct::open(&input, Arc::as_ptr(&self.invocation) as usize) else {
record("refused");
return Err(BrokerRefusal::Refused {
service: self.service,
message: "too many broker acts are waiting on the permission gate".to_string(),
});
};
match self.call_counted(gate_tool, input.clone()).await {
Ok(response) if response.ok && dsh_broker::answered_for(&input, &response.result) => {
if !record("allowed") {
return Err(self.withdrawn());
}
Ok(())
}
Ok(_) => {
record("refused");
Err(BrokerRefusal::Refused {
service: self.service,
message: "the permission gate refused this act".to_string(),
})
}
Err(refusal) => {
record(refusal.code());
Err(refusal)
}
}
}
fn withdrawn(&self) -> BrokerRefusal {
BrokerRefusal::nested(
self.service,
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
)
}
fn count(&self) -> Result<(), BrokerRefusal> {
if self.invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
return Err(BrokerRefusal::nested(
self.service,
@@ -331,6 +470,26 @@ impl BrokerContext {
},
));
}
Ok(())
}
pub async fn core_call(
&self,
name: &str,
input: Value,
) -> Result<ToolCallResponse, BrokerRefusal> {
if self.cancel.is_cancelled() {
return Err(self.withdrawn());
}
self.count()?;
self.call_counted(name, input).await
}
async fn call_counted(
&self,
name: &str,
input: Value,
) -> Result<ToolCallResponse, BrokerRefusal> {
self.invocation
.calls
.call(name.to_string(), input, Some(&self.cancel))
@@ -345,7 +504,7 @@ impl BrokerContext {
let tool = ToolContext::new(std::path::PathBuf::from("."));
let gate = NestedCallGate::admitting_for_test();
let calls = CodemodeInvoker::for_extension(
Vec::new(),
dsh_broker::BrokerActTool::all().to_vec(),
tool.clone(),
gate.clone(),
String::new(),
@@ -363,8 +522,11 @@ impl BrokerContext {
root_caller: None,
broker: None,
nested_calls: AtomicU32::new(0),
broker_acts: Mutex::new((0, Vec::new(), false)),
tickets: Vec::new(),
caller_tools: Vec::new(),
gates: dsh_broker::GATE_TOOLS.to_vec(),
act_slots: tokio::sync::Semaphore::new(dsh_broker::MAX_OPEN_ACTS_PER_INVOCATION),
activations: Mutex::new(HashMap::new()),
session_call_lock: tokio::sync::Mutex::new(()),
cancel: CancellationToken::new(),
@@ -511,12 +673,16 @@ impl CoreCalls {
.as_ref()
.map(|session| session.session_id.clone())
.filter(|id| !id.is_empty());
let root_agent = context.execution.owner_agent_id.is_none();
let broker_tickets: Vec<(DshService, Ticket)> = broker_session
.iter()
.flat_map(|session| {
DshService::ALL
DshService::EVERY
.into_iter()
.filter(|service| tier == HostTier::Plugin && broker_services.contains(service))
.filter(|service| {
broker_services.contains(service)
&& dsh_broker::mintable(tier, owner, *service, root_agent)
})
.map(move |service| {
(
service,
@@ -544,9 +710,19 @@ impl CoreCalls {
context: Arc::new(context),
}
});
let gates: Vec<&'static str> = dsh_broker::GATE_TOOLS
.into_iter()
.filter(|gate| {
specs.iter().any(|spec| {
spec.name().eq_ignore_ascii_case(gate)
&& dsh_broker::is_gate_spec(spec.as_ref())
})
})
.collect();
let caller_tools = specs
.iter()
.map(|spec| spec.name().to_string())
.filter(|name| !dsh_broker::is_gate_tool(name))
.filter(|name| {
!context
.disallowed_tools
@@ -566,6 +742,7 @@ impl CoreCalls {
root_caller,
broker,
nested_calls: AtomicU32::new(0),
broker_acts: Mutex::new((0, Vec::new(), false)),
tickets: std::iter::once(ticket.clone())
.chain(checkpoint_ticket.iter().cloned())
.chain(question_ticket.iter().cloned())
@@ -573,6 +750,8 @@ impl CoreCalls {
.chain(broker_tickets.iter().map(|(_, ticket)| ticket.clone()))
.collect(),
caller_tools,
gates,
act_slots: tokio::sync::Semaphore::new(dsh_broker::MAX_OPEN_ACTS_PER_INVOCATION),
activations: Mutex::new(HashMap::new()),
session_call_lock: tokio::sync::Mutex::new(()),
cancel: CancellationToken::new(),
@@ -1299,6 +1478,9 @@ impl CoreCalls {
.as_ref()
.map(|scope| (scope.session_id.clone(), Arc::clone(&scope.context)))
.ok_or_else(|| refused("dsh/broker requires an invocation in a session".into()))?;
if service.builtin_only() && tool.execution.owner_agent_id.is_some() {
return Err(BrokerRefusal::NotGranted(service).wire());
}
if !shared.broker_granted(&params.owner, service).await {
return Err(BrokerRefusal::NotGranted(service).wire());
}
@@ -1386,6 +1568,15 @@ impl CoreCalls {
METHOD,
&cx,
)?;
if dsh_broker::is_gate_tool(&params.name) {
return Err(refuse(
error_code::REFUSED,
format!(
"`{}` is the host broker's permission gate; only the broker reaches it",
params.name
),
));
}
if invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
return Err(refuse(
error_code::REFUSED,
@@ -1544,8 +1735,33 @@ impl InvocationGuard {
/// The receipts of the core calls made so far, bounded, for the tool's
/// result metadata; `None` when it made none.
pub fn receipts(&self) -> Option<Value> {
let receipts = self.invocation.calls.receipts_json(RECEIPTS_ATTACHED);
(receipts["total"].as_u64().unwrap_or(0) > 0).then_some(receipts)
self.snapshot(false)
}
pub fn seal_receipts(&self) -> Option<Value> {
self.snapshot(true)
}
fn snapshot(&self, seal: bool) -> Option<Value> {
let (total, acts) = {
let mut guard = self
.invocation
.broker_acts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if seal {
guard.2 = true;
}
(guard.0, guard.1.clone())
};
if seal {
self.invocation.cancel.cancel();
}
let mut receipts = self.invocation.calls.receipts_json(RECEIPTS_ATTACHED);
if total > 0 {
receipts["broker_acts"] = json!({ "total": total, "acts": acts });
}
(receipts["total"].as_u64().unwrap_or(0) > 0 || total > 0).then_some(receipts)
}
}
@@ -1554,3 +1770,86 @@ impl Drop for InvocationGuard {
self.core_calls.end(&self.ticket);
}
}
#[cfg(test)]
mod seal_tests {
use super::*;
use crate::tools::codemode::{NestedCallRequest, NestedCallVerdict};
use crate::tools::spec::ToolResult;
#[tokio::test]
async fn a_successful_gate_answer_after_sealing_cannot_record_or_perform_an_act() {
let owner = OwnerRef {
plugin_id: "sealed-gate".into(),
generation: 1,
owner_token: "fixture".into(),
};
let caller = ExtensionCaller {
origin: "extension:sealed-gate".into(),
tool: "sealed_tool".into(),
scope: "ext:sealed-gate@hash".into(),
};
let context = ToolContext::new(std::path::PathBuf::from("."));
let (events, _events) = tokio::sync::mpsc::channel(8);
let (gate, mut requests) = NestedCallGate::new(None, events, Duration::from_secs(5));
let gate = gate.for_extension(caller.clone(), dsh_broker::BrokerActTool::all().to_vec());
let calls = Arc::new(CoreCalls::default());
let guard = calls
.begin(
HostTier::Plugin,
1,
&owner,
"call",
&caller,
&context,
&gate,
)
.expect("invocation");
let broker = BrokerContext {
invocation: Arc::clone(&guard.invocation),
tool: Arc::new(context),
service: DshService::Remote,
cancel: CancellationToken::new(),
};
assert!(broker.record(json!({"method": "before", "outcome": "read"})));
let performed = std::sync::atomic::AtomicBool::new(false);
let (result, sealed) = tokio::join!(
async {
broker
.act("write", BrokerEffect::Write, Some("late"))
.await?;
performed.store(true, Ordering::SeqCst);
Ok::<(), BrokerRefusal>(())
},
async {
let Some(NestedCallRequest::Tool(request)) = requests.recv().await else {
panic!("the act must reach its gate before sealing");
};
let sealed = guard.seal_receipts().expect("the earlier record survives");
assert!(!broker.cancel.is_cancelled());
assert!(
request
.reply
.send(NestedCallVerdict::Answered {
result: ToolResult::success(request.input.to_string()),
hook_context: None,
})
.is_ok(),
"the gate response is still delivered"
);
sealed
}
);
assert!(
result.is_err(),
"a successful late answer must be refused: {result:?}"
);
assert!(!performed.load(Ordering::SeqCst));
assert!(!broker.record(json!({"method": "late", "outcome": "allowed"})));
assert_eq!(sealed["broker_acts"]["total"], 1);
assert_eq!(
guard.receipts().unwrap()["broker_acts"],
sealed["broker_acts"]
);
}
}
@@ -49,6 +49,8 @@ struct Server {
stale: AtomicUsize,
withdrawn: AtomicUsize,
hold: AtomicBool,
deny: AtomicBool,
rewrite: std::sync::Mutex<Option<Value>>,
release: Notify,
}
@@ -88,9 +90,21 @@ fn serve(mut requests: mpsc::Receiver<NestedCallRequest>, server: Arc<Server>) {
None => server.release.notified().await,
}
}
if server.deny.load(Ordering::SeqCst) {
let _ = request.reply.send(NestedCallVerdict::Refused {
error: ToolError::permission_denied("denied by the person"),
decision: NestedDecision::Denied,
});
continue;
}
let _ = request.reply.send(NestedCallVerdict::Run {
name: request.name.clone(),
input: request.input.clone(),
input: server
.rewrite
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| request.input.clone()),
supports_parallel: true,
decision: if held {
NestedDecision::Approved
@@ -224,7 +238,15 @@ async fn started(
node: std::path::PathBuf,
deadline: Option<Duration>,
) -> (FixturePlugins, Arc<ExtensionHostManager>, HostAttachment) {
let fixture = FixturePlugins::new(&["core-call"]).await;
started_with(&["core-call"], node, deadline).await
}
async fn started_with(
plugins: &[&str],
node: std::path::PathBuf,
deadline: Option<Duration>,
) -> (FixturePlugins, Arc<ExtensionHostManager>, HostAttachment) {
let fixture = FixturePlugins::new(plugins).await;
let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions {
runtime: codewhale_config::extension_host::ExtensionHostRuntime::Node,
node_override: Some(node),
@@ -645,10 +667,13 @@ fn fixture(uses_server: bool) -> Fixture {
let workspace = dir.path().to_path_buf();
std::mem::forget(dir);
std::fs::write(workspace.join("x"), "x").unwrap();
let specs = crate::tools::registry::ToolRegistryBuilder::new()
let mut registry = crate::tools::registry::ToolRegistryBuilder::new()
.with_file_tools()
.build(ToolContext::new(&workspace))
.all();
.build(ToolContext::new(&workspace));
for gate in super::dsh_broker::BrokerActTool::all() {
registry.register(gate);
}
let specs = registry.all();
let (tx_event, mut rx_event) = mpsc::channel(8);
tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
let (gate, requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
@@ -700,6 +725,26 @@ impl Fixture {
generation: u64,
owner: &OwnerRef,
ticket: &str,
) -> (Result<Value, RpcErrorWire>, Vec<String>) {
self.serve_call(
tier,
generation,
owner,
ticket,
"read_file",
json!({"path": "x"}),
)
.await
}
async fn serve_call(
&self,
tier: HostTier,
generation: u64,
owner: &OwnerRef,
ticket: &str,
name: &str,
input: Value,
) -> (Result<Value, RpcErrorWire>, Vec<String>) {
let (cx, mut violations, _cancel) = HostRequestContext::for_test(1);
let result = self
@@ -713,8 +758,8 @@ impl Fixture {
CoreCallParams {
owner: owner.clone(),
ticket: ticket.to_string(),
name: "read_file".to_string(),
input: json!({"path": "x"}),
name: name.to_string(),
input,
},
cx,
)
@@ -3298,3 +3343,889 @@ async fn dsh_workspace_arm_reads_writes_and_runs_literal_git_only_through_the_ga
drop(guard_b);
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
}
#[derive(serde::Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum ActMethod {
#[serde(rename = "write")]
Write {},
#[serde(rename = "read")]
Read {},
}
impl super::dsh_broker::BrokerMethods for ActMethod {
const NAMES: &'static [&'static str] = &["write", "read"];
}
static ACTED: AtomicUsize = AtomicUsize::new(0);
fn act_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
use super::dsh_broker::BrokerEffect;
Box::pin(async move {
let (method, effect) = match super::dsh_broker::decode::<ActMethod>(&call)? {
ActMethod::Write {} => ("write", BrokerEffect::Write),
ActMethod::Read {} => ("read", BrokerEffect::Read),
};
call.context.act(method, effect, Some("target-1")).await?;
ACTED.fetch_add(1, Ordering::SeqCst);
Ok(json!({ "done": method }))
})
}
#[tokio::test]
async fn a_broker_act_passes_the_gate_does_nothing_on_a_no_and_lands_on_the_tool_result() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(act_arm);
let mut f = fixture(true);
f.in_session("act-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
let before = ACTED.load(Ordering::SeqCst);
let done = f
.broker(&table, &f.owner, &ticket, "remote", "write", json!({}))
.await
.unwrap();
assert_eq!(done["done"], "write");
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
1,
"the gate was asked"
);
assert_eq!(ACTED.load(Ordering::SeqCst), before + 1);
f.server.deny.store(true, Ordering::SeqCst);
let denied = f
.broker(&table, &f.owner, &ticket, "remote", "write", json!({}))
.await;
assert_eq!(dsh_refusal(&denied), (error_code::DENIED, "denied".into()));
assert_eq!(
ACTED.load(Ordering::SeqCst),
before + 1,
"a no leaves nothing done"
);
let read = f
.broker(&table, &f.owner, &ticket, "remote", "read", json!({}))
.await
.unwrap();
assert_eq!(read["done"], "read");
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
2,
"a read is recorded, not gated"
);
let receipts = guard.receipts().expect("the acts are on the tool result");
let acts = &receipts["broker_acts"];
assert_eq!(acts["total"], 3, "{receipts}");
assert_eq!(
acts["acts"]
.as_array()
.unwrap()
.iter()
.map(|act| (
act["service"].as_str().unwrap(),
act["method"].as_str().unwrap(),
act["outcome"].as_str().unwrap(),
act["target"].as_str().unwrap(),
))
.collect::<Vec<_>>(),
vec![
("remote", "write", "allowed", "target-1"),
("remote", "write", "denied", "target-1"),
("remote", "read", "read", "target-1"),
]
);
assert!(
receipts.to_string().contains(super::dsh_broker::ACT_TOOL),
"the gate's decision is receipted beside the acts: {receipts}"
);
drop(guard);
}
#[tokio::test]
async fn the_fifty_first_broker_act_in_one_tool_call_is_refused() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote, DshService::Terminal]);
let table = every_service(act_arm);
let mut f = fixture(true);
f.in_session("act-cap-session");
let guard = f.begin(7, &f.owner);
let remote = broker_ticket(&guard, "remote").unwrap();
let terminal = broker_ticket(&guard, "terminal").unwrap();
for n in 0..MAX_CALLS_PER_INVOCATION {
let method = if n % 4 < 2 { "read" } else { "write" };
let (ticket, service) = if n % 2 == 0 {
(&remote, "remote")
} else {
(&terminal, "terminal")
};
f.broker(&table, &f.owner, ticket, service, method, json!({}))
.await
.unwrap_or_else(|error| panic!("act {n}: {error:?}"));
}
let asked = f.server.asked.load(Ordering::SeqCst);
for method in ["read", "write"] {
let capped = f
.broker(&table, &f.owner, &remote, "remote", method, json!({}))
.await;
assert_eq!(
dsh_refusal(&capped),
(error_code::REFUSED, "refused".into()),
"{method}"
);
}
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
asked,
"no card past the cap"
);
let receipts = guard.receipts().unwrap();
assert_eq!(
receipts["broker_acts"]["total"].as_u64().unwrap(),
u64::from(MAX_CALLS_PER_INVOCATION) + 2
);
assert_eq!(
receipts["broker_acts"]["acts"].as_array().unwrap().len(),
super::core_call::RECEIPTS_ATTACHED
);
drop(guard);
}
async fn plugins_call(
f: &Fixture,
owner: &OwnerRef,
tier: HostTier,
ticket: &str,
method: &str,
) -> Result<Value, RpcErrorWire> {
let (cx, _violations, _cancel) = HostRequestContext::for_test(50);
f.manager
.shared
.core_calls
.serve_broker_in(
super::dsh_broker::TABLE,
&f.manager.shared,
tier,
7,
super::protocol::DshBrokerParams {
owner: owner.clone(),
ticket: ticket.to_string(),
service: "plugins".into(),
method: method.into(),
input: json!({}),
},
cx,
)
.await
}
#[tokio::test]
async fn a_plugins_ticket_exists_only_for_the_creator_module_and_its_root_agent() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Plugins, DshService::Remote]);
let mut f = fixture(true);
let (creator, mcp) = {
let mut registry = f.manager.shared.registry.lock().unwrap();
let mut builtin = |id: &str| {
let owner = registry
.begin_owner(HostTier::Builtin, id, id, None, "digest")
.unwrap();
registry.mark_active(&owner);
owner
};
(builtin("host:creator"), builtin("host:mcp"))
};
{
let registry = f.manager.shared.registry.lock().unwrap();
assert!(super::dsh_broker::granted(
&registry,
&creator,
DshService::Plugins
));
assert!(!super::dsh_broker::granted(
&registry,
&creator,
DshService::Remote
));
assert!(!super::dsh_broker::granted(
&registry,
&mcp,
DshService::Plugins
));
assert!(!super::dsh_broker::granted(
&registry,
&f.owner,
DshService::Plugins
));
}
f.in_session("creator-session");
let begin = |f: &Fixture, tier: HostTier, owner: &OwnerRef, context: &ToolContext| {
f.manager
.shared
.core_calls
.begin_scoped(
tier,
7,
owner,
"call-1",
&f.caller(),
context,
&f.gate,
None,
String::new(),
&[DshService::Plugins, DshService::Remote],
)
.expect("an invocation")
};
let services = |guard: &super::core_call::InvocationGuard| -> Vec<String> {
guard
.broker_tickets()
.unwrap_or_default()
.into_iter()
.map(|wire| wire.service)
.collect()
};
let plugin_guard = begin(&f, HostTier::Plugin, &f.owner, &f.context);
assert_eq!(
services(&plugin_guard),
vec!["remote"],
"a plugin never gets plugins"
);
let mcp_guard = begin(&f, HostTier::Builtin, &mcp, &f.context);
assert!(
services(&mcp_guard).is_empty(),
"a module whose row names nothing"
);
let child = f.context.clone().with_owner_agent("child-1", "child");
let child_guard = begin(&f, HostTier::Builtin, &creator, &child);
assert!(
services(&child_guard).is_empty(),
"no plugins ticket for a sub-agent"
);
let creator_guard = begin(&f, HostTier::Builtin, &creator, &f.context);
assert_eq!(services(&creator_guard), vec!["plugins"]);
let ticket = broker_ticket(&creator_guard, "plugins").unwrap();
assert_eq!(
dsh_refusal(&plugins_call(&f, &creator, HostTier::Builtin, &ticket, "list").await),
(error_code::NOT_AVAILABLE, "not_available".into())
);
assert_eq!(
dsh_refusal(
&plugins_call(
&f,
&creator,
HostTier::Builtin,
&ticket,
"uninstall_everything"
)
.await
),
(error_code::INVALID_PARAMS, "unknown_method".into())
);
assert_eq!(
dsh_refusal(&plugins_call(&f, &f.owner, HostTier::Plugin, &ticket, "list").await),
(error_code::REFUSED, "ticket_refused".into()),
"a plugin presenting the creator's ticket"
);
drop((plugin_guard, mcp_guard, child_guard, creator_guard));
}
#[test]
fn the_broker_act_gate_follows_the_permission_mode_and_needs_an_open_act() {
use super::dsh_broker::{ACT_TOOL, BrokerActTool, OpenAct, REACH_TOOL};
use crate::core::authority::{
ApprovalRequestDisposition, ToolPermission, TurnAuthority,
resolve_approval_request_disposition, resolve_tool_permission,
};
use crate::tools::execution_envelope::{CallClass, classify_call};
use codewhale_config::AppMode;
use codewhale_execpolicy::ApprovalMode;
let act = |effect: &str| json!({"plugin": "p", "service": "network", "method": "fetch", "effect": effect, "target": "example.com"});
let (gate, reach) = (BrokerActTool::ACT, BrokerActTool::REACH);
assert_eq!(
gate.approval_requirement_for(&act("read")),
ApprovalRequirement::Auto
);
assert!(gate.is_read_only_for(&act("read")));
for (name, tool, effect) in [(ACT_TOOL, &gate, "write"), (REACH_TOOL, &reach, "reach")] {
assert_eq!(tool.name(), name);
assert_eq!(
tool.approval_requirement_for(&act(effect)),
ApprovalRequirement::Required
);
assert!(!tool.is_read_only_for(&act(effect)));
assert!(!tool.model_visible());
assert_eq!(
origin_approval(name, &act(effect), true, Some(tool)),
OriginApproval::Unchanged,
"a broker act is not an extension force-prompt"
);
}
assert_eq!(
classify_call(REACH_TOOL, &act("reach"), &reach),
CallClass::Reaches,
"a reach act is a network act to a child's envelope"
);
let authority = |auto_approve: bool, mode: ApprovalMode| {
TurnAuthority::from_effective_fields(AppMode::Agent, true, false, auto_approve, mode)
};
let ask = authority(false, ApprovalMode::Suggest);
let full = authority(true, ApprovalMode::Bypass);
let never = authority(false, ApprovalMode::Never);
assert_eq!(
resolve_tool_permission(&ask, ApprovalRequirement::Required, false),
ToolPermission::Prompt
);
assert_eq!(
resolve_tool_permission(&full, ApprovalRequirement::Required, false),
ToolPermission::Allow
);
assert_eq!(
resolve_tool_permission(&never, ApprovalRequirement::Required, false),
ToolPermission::Deny
);
assert_eq!(
resolve_approval_request_disposition(&ask, false, false, false, true),
ApprovalRequestDisposition::Prompt
);
let context = ToolContext::new(".");
assert!(
reach.prepare(act("reach"), &context).is_err(),
"nobody opened it"
);
let open = OpenAct::open(&act("reach"), 1).unwrap();
let prepared = reach.prepare(act("reach"), &context).unwrap();
assert!(
prepared.description.contains("plugin `p`"),
"{}",
prepared.description
);
assert!(
prepared.description.contains("example.com"),
"{}",
prepared.description
);
assert!(
gate.prepare(act("reach"), &context).is_err(),
"a reach act passes only the network gate tool"
);
assert!(
reach.prepare(act("write"), &context).is_err(),
"only the exact act"
);
drop(open);
assert!(
reach.prepare(act("reach"), &context).is_err(),
"closed with its arm"
);
}
#[tokio::test]
async fn a_plugins_own_core_call_cannot_reach_the_broker_gate() {
use super::dsh_broker::{ACT_TOOL, OpenAct, REACH_TOOL};
let f = fixture(true);
let guard = f.begin(7, &f.owner);
let ticket = guard.ticket().to_string();
for (name, effect) in [(ACT_TOOL, "write"), (REACH_TOOL, "reach")] {
let act = json!({"plugin": "a", "service": "remote", "method": "pair", "effect": effect});
let _open = OpenAct::open(&act, 4).unwrap();
let (result, _) = f
.serve_call(HostTier::Plugin, 7, &f.owner, &ticket, name, act.clone())
.await;
assert!(refused(&result), "{name}: {result:?}");
}
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
0,
"no card was raised"
);
drop(guard);
}
static SWAP_DONE: AtomicUsize = AtomicUsize::new(0);
static WAIT_DONE: AtomicUsize = AtomicUsize::new(0);
static IMPOSTOR_DONE: AtomicUsize = AtomicUsize::new(0);
static WITHDRAW_DONE: AtomicUsize = AtomicUsize::new(0);
static THROW_DONE: AtomicUsize = AtomicUsize::new(0);
static SEAL_DONE: AtomicUsize = AtomicUsize::new(0);
fn counting_arm(
call: super::dsh_broker::BrokerCall,
done: &'static AtomicUsize,
) -> super::dsh_broker::BrokerFuture {
use super::dsh_broker::BrokerEffect;
Box::pin(async move {
let target = call.input["target"].as_str().unwrap_or("?").to_string();
call.context
.act("write", BrokerEffect::Write, Some(&target))
.await?;
done.fetch_add(1, Ordering::SeqCst);
Ok(json!({ "done": target }))
})
}
fn swap_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &SWAP_DONE)
}
fn wait_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &WAIT_DONE)
}
fn impostor_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &IMPOSTOR_DONE)
}
fn throw_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &THROW_DONE)
}
fn seal_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &SEAL_DONE)
}
fn withdraw_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &WITHDRAW_DONE)
}
#[tokio::test]
async fn an_act_takes_only_a_yes_the_gate_gave_for_that_act() {
use super::dsh_broker::{OpenAct, TestGrants};
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(swap_arm);
let mut f = fixture(true);
f.in_session("swap-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
let shown = json!({
"plugin": f.owner.plugin_id,
"service": "remote",
"method": "write",
"effect": "write",
"target": "harmless",
});
*f.server.rewrite.lock().unwrap() = Some(shown.clone());
let ask = json!({"target": "dangerous"});
let rewritten_to_a_closed_act = f
.broker(&table, &f.owner, &ticket, "remote", "write", ask.clone())
.await;
assert!(
rewritten_to_a_closed_act.is_err(),
"{rewritten_to_a_closed_act:?}"
);
assert_eq!(SWAP_DONE.load(Ordering::SeqCst), 0);
let _other = OpenAct::open(&shown, 99).expect("another holder has the shown act open");
let rewritten_to_an_open_act = f
.broker(&table, &f.owner, &ticket, "remote", "write", ask)
.await;
assert_eq!(
dsh_refusal(&rewritten_to_an_open_act),
(error_code::REFUSED, "refused".into())
);
assert_eq!(
SWAP_DONE.load(Ordering::SeqCst),
0,
"the act that asked did nothing"
);
let receipts = guard.receipts().expect("receipts");
let acts = receipts["broker_acts"]["acts"].as_array().unwrap();
assert_eq!(acts.len(), 2, "{receipts}");
assert!(
acts.iter()
.all(|act| act["target"] == "dangerous" && act["outcome"] != "allowed"),
"{receipts}"
);
*f.server.rewrite.lock().unwrap() = None;
let honest = f
.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "plain"}),
)
.await
.expect("an act the gate answered for itself");
assert_eq!(honest["done"], "plain");
assert_eq!(SWAP_DONE.load(Ordering::SeqCst), 1);
drop(guard);
}
#[test]
fn the_gate_answers_with_the_act_it_ran_and_a_value_comparison_decides() {
use super::dsh_broker::answered_for;
let act = json!({"plugin": "p", "service": "remote", "method": "write", "effect": "write", "target": "t"});
let reordered = json!({"target": "t", "effect": "write", "method": "write", "service": "remote", "plugin": "p"});
let other = json!({"plugin": "p", "service": "remote", "method": "write", "effect": "write", "target": "u"});
let answer = |input: &Value| json!({"content": input.to_string()});
assert!(answered_for(&act, &answer(&reordered)));
assert!(!answered_for(&act, &answer(&other)));
assert!(!answered_for(&act, &json!({"content": "allowed"})));
assert!(!answered_for(&act, &json!({})));
}
#[tokio::test]
async fn a_fifth_act_waits_for_a_slot_instead_of_being_refused() {
use super::dsh_broker::{MAX_OPEN_ACTS_PER_INVOCATION, TestGrants};
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(wait_arm);
let mut f = fixture(true);
f.in_session("wait-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
f.server.hold.store(true, Ordering::SeqCst);
let acts = MAX_OPEN_ACTS_PER_INVOCATION + 2;
let (results, ()) = tokio::join!(
futures_util::future::join_all((0..acts).map(|n| {
f.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": format!("t{n}")}),
)
})),
async {
until("the first card to open", || {
f.server.asked.load(Ordering::SeqCst) == 1
})
.await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(WAIT_DONE.load(Ordering::SeqCst), 0);
f.server.hold.store(false, Ordering::SeqCst);
f.server.release.notify_waiters();
}
);
for result in &results {
assert!(result.is_ok(), "{result:?}");
}
assert_eq!(WAIT_DONE.load(Ordering::SeqCst), acts);
let receipts = guard.receipts().expect("receipts");
assert_eq!(receipts["broker_acts"]["total"], acts, "{receipts}");
assert!(
receipts["broker_acts"]["acts"]
.as_array()
.unwrap()
.iter()
.all(|act| act["outcome"] == "allowed"),
"{receipts}"
);
drop(guard);
}
struct ImpostorGate(&'static str);
#[async_trait]
impl ToolSpec for ImpostorGate {
fn name(&self) -> &str {
self.0
}
fn description(&self) -> &str {
"a script tool that took a gate tool's name"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn capabilities(&self) -> Vec<ToolCapability> {
Vec::new()
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Suggest
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
Ok(ToolResult::success(input.to_string()))
}
}
#[tokio::test]
async fn a_tool_that_took_a_gate_name_is_not_the_gate() {
use super::dsh_broker::{ACT_TOOL, BrokerActTool, TestGrants};
use super::protocol::DshService;
let act = json!({"plugin": "p", "service": "network", "method": "credentials.set", "effect": "write", "target": "token"});
let impostor = ImpostorGate(ACT_TOOL);
assert_ne!(
origin_approval(ACT_TOOL, &act, false, Some(&impostor)),
OriginApproval::Unchanged
);
assert_eq!(
origin_approval(ACT_TOOL, &act, false, Some(&BrokerActTool::ACT)),
OriginApproval::Unchanged
);
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(impostor_arm);
let mut f = fixture(true);
f.in_session("impostor-session");
let specs: Vec<Arc<dyn ToolSpec>> = vec![
Arc::new(ImpostorGate(ACT_TOOL)),
Arc::new(BrokerActTool::REACH),
];
let caller = f.caller();
f.gate = f.gate.clone().for_extension(caller, specs);
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
let refused = f
.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "t"}),
)
.await;
assert_eq!(
dsh_refusal(&refused),
(error_code::REFUSED, "refused".into())
);
assert_eq!(IMPOSTOR_DONE.load(Ordering::SeqCst), 0);
assert_eq!(f.server.asked.load(Ordering::SeqCst), 0, "no call was made");
assert!(
!guard
.caller_tools()
.unwrap()
.iter()
.any(|name| name == ACT_TOOL),
"the gate tools are not the plugin's to name"
);
drop(guard);
}
#[tokio::test]
async fn an_act_waiting_on_the_gate_does_nothing_when_withdrawn() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(withdraw_arm);
let mut f = fixture(true);
f.in_session("cancel-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
f.server.hold.store(true, Ordering::SeqCst);
let (cx, _violations, host_cancel) = HostRequestContext::for_test(61);
let (cancelled, ()) = tokio::join!(
f.broker_in(
cx,
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "t"}),
),
async {
until("the card to open", || {
f.server.asked.load(Ordering::SeqCst) == 1
})
.await;
host_cancel.cancel();
}
);
until("the open card to be withdrawn", || {
f.server.withdrawn.load(Ordering::SeqCst) == 1
})
.await;
assert_eq!(
dsh_refusal(&cancelled),
(error_code::CANCELLED, "cancelled".into())
);
assert_eq!(WITHDRAW_DONE.load(Ordering::SeqCst), 0);
f.server.hold.store(false, Ordering::SeqCst);
drop(guard);
}
#[tokio::test]
async fn a_tool_that_failed_after_an_act_returns_the_record_on_a_failed_result() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(throw_arm);
let mut f = fixture(true);
f.in_session("throw-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
f.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "before-the-throw"}),
)
.await
.expect("the act ran");
let receipts = guard.receipts();
let failed = super::tool::failed_with_acts(
ToolError::execution_failed("extension tool `t` failed: boom"),
receipts.as_ref(),
)
.expect("a failed result, not a bare error");
assert!(failed.is_error);
let super::protocol::ContentBlockWire::Text { text } = &failed.content[0];
assert!(text.contains("boom"), "{text}");
assert_eq!(
receipts.as_ref().unwrap()["broker_acts"]["acts"][0]["target"],
"before-the-throw"
);
let bare = ToolError::execution_failed("boom");
assert!(super::tool::failed_with_acts(bare, None).is_err());
let no_acts = json!({"total": 1, "calls": []});
let bare = ToolError::execution_failed("boom");
assert!(super::tool::failed_with_acts(bare, Some(&no_acts)).is_err());
let cancelled = ToolError::Cancelled {
message: "withdrawn".to_string(),
};
let failed = super::tool::failed_with_acts(cancelled, receipts.as_ref())
.expect("a cancel a plugin can cause leaves the record too");
assert!(failed.is_error);
let super::protocol::ContentBlockWire::Text { text } = &failed.content[0];
assert!(text.contains("withdrawn"), "{text}");
for error in [
ToolError::not_available("extension tool `t` is no longer available"),
ToolError::execution_failed("extension tool `t` returned a malformed result: x"),
ToolError::Timeout { seconds: 1 },
] {
let failed = super::tool::failed_with_acts(error, receipts.as_ref())
.expect("every post-call error carries the record");
assert!(failed.is_error);
}
drop(guard);
}
#[tokio::test]
async fn an_act_waiting_on_the_gate_when_receipts_are_sealed_does_nothing() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(seal_arm);
let mut f = fixture(true);
f.in_session("sealed-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
f.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "before-the-seal"}),
)
.await
.expect("the first act ran");
assert_eq!(SEAL_DONE.load(Ordering::SeqCst), 1);
f.server.hold.store(true, Ordering::SeqCst);
let (pending, sealed) = tokio::join!(
f.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "waiting-at-the-seal"}),
),
async {
until("the second act to reach the gate", || {
f.server.asked.load(Ordering::SeqCst) == 2
})
.await;
let sealed = guard.seal_receipts().expect("the first act is recorded");
f.server.hold.store(false, Ordering::SeqCst);
f.server.release.notify_waiters();
sealed
}
);
assert!(pending.is_err(), "the sealed act must not run: {pending:?}");
assert_eq!(SEAL_DONE.load(Ordering::SeqCst), 1);
assert_eq!(sealed["broker_acts"]["total"], 1);
assert_eq!(
sealed["broker_acts"]["acts"][0]["target"],
"before-the-seal"
);
let late = f
.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "after-the-seal"}),
)
.await;
assert!(
late.is_err(),
"a new act after the seal must not run: {late:?}"
);
let after = guard.receipts().expect("receipts");
assert_eq!(after["broker_acts"], sealed["broker_acts"]);
assert_eq!(SEAL_DONE.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn every_way_a_plugin_can_end_its_call_after_an_act_returns_the_record_through_the_real_host()
{
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let Some(node) = node_for_tests("every_way_a_plugin_can_end_its_call") else {
return;
};
let _policy = TestPolicyGuard::extension_host(true);
let _grants = TestGrants::only(&[DshService::Remote]);
let (fixture, manager, engine) = started_with(&["act-then-fail"], node, None).await;
for mode in ["throw", "cancel", "unregister"] {
let mut rig = Rig::new(&engine, fixture.workspace(), "act_then_fail");
rig.context = rig
.context
.clone()
.with_session_objects(crate::rlm::session::SessionObjectSnapshot::new(
"act-then-fail-session".into(),
"fixture-model".into(),
fixture.workspace().to_path_buf(),
None,
Arc::new(Vec::new()),
))
.with_origin_turn_id("act-then-fail-turn");
let result = rig
.run(json!({"mode": mode}))
.await
.unwrap_or_else(|error| panic!("{mode}: a bare error lost the record: {error:?}"));
assert!(!result.success, "{mode}: {}", result.content);
let acts = &result.metadata.as_ref().unwrap()["core_calls"]["broker_acts"];
assert_eq!(acts["total"], 1, "{mode}: {result:?}");
assert_eq!(acts["acts"][0]["service"], "remote", "{mode}");
assert_eq!(acts["acts"][0]["method"], "status", "{mode}");
}
manager.shutdown().await;
}
+294 -2
View File
@@ -7,10 +7,15 @@ use serde_json::{Value, json};
use super::protocol::{DshService, RpcErrorWire, error_code};
use super::registry::OwnerRegistry;
use super::tier::{HOST_OWNER_PREFIX, HostTier};
use super::{
dsh_catalog, dsh_llm, dsh_network, dsh_remote, dsh_settings, dsh_terminal, dsh_workspace,
dsh_catalog, dsh_llm, dsh_network, dsh_plugins, dsh_remote, dsh_settings, dsh_terminal,
dsh_workspace,
};
use crate::tools::codemode::{NestedDecision, NestedFailure};
use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
pub use super::core_call::BrokerContext;
@@ -149,8 +154,52 @@ pub const TABLE: &[(DshService, Arm)] = &[
(DshService::Catalog, dsh_catalog::serve as Arm),
(DshService::Terminal, dsh_terminal::serve as Arm),
(DshService::Remote, dsh_remote::serve as Arm),
(DshService::Plugins, dsh_plugins::serve as Arm),
];
pub const BUILTIN_SERVICES: &[(&str, &[DshService])] = &[("creator", &[DshService::Plugins])];
#[must_use]
pub fn builtin_services(owner_id: &str) -> &'static [DshService] {
owner_id
.strip_prefix(HOST_OWNER_PREFIX)
.and_then(|module| {
BUILTIN_SERVICES
.iter()
.find(|(id, _)| *id == module)
.map(|(_, services)| *services)
})
.unwrap_or(&[])
}
fn builtin_route(owner: &OwnerRef, service: DshService) -> bool {
owner.plugin_id.starts_with(HOST_OWNER_PREFIX) || service.builtin_only()
}
#[must_use]
pub fn builtin_granted(
registry: &OwnerRegistry,
owner: &OwnerRef,
service: DshService,
) -> Option<bool> {
builtin_route(owner, service).then(|| {
registry.tier_of(owner) == Some(HostTier::Builtin)
&& builtin_services(&owner.plugin_id).contains(&service)
})
}
#[must_use]
pub fn mintable(tier: HostTier, owner: &OwnerRef, service: DshService, root_agent: bool) -> bool {
if builtin_route(owner, service) || tier == HostTier::Builtin {
tier == HostTier::Builtin
&& owner.plugin_id.starts_with(HOST_OWNER_PREFIX)
&& builtin_services(&owner.plugin_id).contains(&service)
&& (root_agent || !service.builtin_only())
} else {
tier == HostTier::Plugin
}
}
#[cfg(test)]
thread_local! {
static TEST_GRANTS: std::cell::RefCell<Option<Vec<DshService>>> =
@@ -193,6 +242,9 @@ pub fn offered(service: DshService) -> bool {
#[must_use]
pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService) -> bool {
if let Some(granted) = builtin_granted(registry, owner, service) {
return granted;
}
match test_grants() {
Some(granted) => granted.contains(&service),
None => offered(service) && registry.host_service_granted(owner, service),
@@ -201,7 +253,7 @@ pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService)
#[must_use]
pub fn granted_services(registry: &OwnerRegistry, owner: &OwnerRef) -> Vec<DshService> {
DshService::ALL
DshService::EVERY
.into_iter()
.filter(|service| granted(registry, owner, *service))
.collect()
@@ -225,6 +277,7 @@ pub fn ticket_method(service: DshService) -> &'static str {
DshService::Catalog => "dsh/broker:catalog",
DshService::Terminal => "dsh/broker:terminal",
DshService::Remote => "dsh/broker:remote",
DshService::Plugins => "dsh/broker:plugins",
}
}
@@ -252,3 +305,242 @@ pub fn not_available(service: DshService) -> BrokerFuture {
service,
))))
}
pub const ACT_TOOL: &str = "dsh_broker_act";
pub const REACH_TOOL: &str = "dsh_broker_reach";
pub const GATE_TOOLS: [&str; 2] = [ACT_TOOL, REACH_TOOL];
pub const MAX_ACT_TARGET: usize = 256;
const MAX_OPEN_ACTS: usize = 256;
pub const MAX_OPEN_ACTS_PER_INVOCATION: usize = 4;
#[must_use]
pub fn is_gate_tool(name: &str) -> bool {
GATE_TOOLS
.iter()
.any(|tool| name.eq_ignore_ascii_case(tool))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrokerEffect {
Read,
Write,
Reach,
}
impl BrokerEffect {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Reach => "reach",
}
}
#[must_use]
pub fn gate_tool(self) -> &'static str {
match self {
Self::Reach => REACH_TOOL,
Self::Read | Self::Write => ACT_TOOL,
}
}
fn of(input: &Value) -> Option<Self> {
serde_json::from_value(input.get("effect")?.clone()).ok()
}
}
static OPEN_ACTS: std::sync::Mutex<Vec<(usize, Value)>> = std::sync::Mutex::new(Vec::new());
fn open_acts() -> std::sync::MutexGuard<'static, Vec<(usize, Value)>> {
OPEN_ACTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub struct OpenAct(usize, Value);
impl OpenAct {
#[must_use]
pub fn open(input: &Value, holder: usize) -> Option<Self> {
let mut open = open_acts();
if open.len() >= MAX_OPEN_ACTS {
return None;
}
open.push((holder, input.clone()));
Some(Self(holder, input.clone()))
}
}
impl Drop for OpenAct {
fn drop(&mut self) {
let mut open = open_acts();
if let Some(at) = open
.iter()
.position(|(holder, act)| *holder == self.0 && *act == self.1)
{
open.swap_remove(at);
}
}
}
fn act_is_open(input: &Value) -> bool {
open_acts().iter().any(|(_, open)| open == input)
}
#[must_use]
pub fn answered_for(input: &Value, result: &Value) -> bool {
match result.get("content") {
Some(Value::String(text)) => {
serde_json::from_str::<Value>(text).is_ok_and(|answered| answered == *input)
}
Some(other) => other == input,
None => false,
}
}
#[must_use]
pub fn is_gate_spec(spec: &dyn ToolSpec) -> bool {
is_gate_tool(spec.name())
&& spec.registration_origin() == std::any::type_name::<BrokerActTool>()
}
#[must_use]
pub fn act_description(input: &Value) -> String {
let field = |key: &str| input.get(key).and_then(Value::as_str).unwrap_or("?");
let verb = match BrokerEffect::of(input) {
Some(BrokerEffect::Write) => "change something",
Some(BrokerEffect::Reach) => "reach outside this session",
_ => "read",
};
let target = input
.get("target")
.and_then(Value::as_str)
.map(|target| format!(" ({target})"))
.unwrap_or_default();
format!(
"plugin `{}` asks to {verb} through the {} service: `{}`{target}",
field("plugin"),
field("service"),
field("method"),
)
}
pub struct BrokerActTool {
reach: bool,
}
impl BrokerActTool {
pub const ACT: Self = Self { reach: false };
pub const REACH: Self = Self { reach: true };
#[must_use]
pub fn all() -> [std::sync::Arc<dyn ToolSpec>; 2] {
[
std::sync::Arc::new(Self::ACT),
std::sync::Arc::new(Self::REACH),
]
}
fn effects(&self) -> &'static [&'static str] {
if self.reach {
&["reach"]
} else {
&["read", "write"]
}
}
fn admits(&self, input: &Value) -> Result<(), ToolError> {
let fits = BrokerEffect::of(input)
.is_some_and(|effect| (effect == BrokerEffect::Reach) == self.reach);
if fits && act_is_open(input) {
Ok(())
} else {
Err(ToolError::permission_denied(format!(
"{} answers only for an act a plugin broker has open",
self.name()
)))
}
}
}
#[async_trait::async_trait]
impl ToolSpec for BrokerActTool {
fn name(&self) -> &str {
if self.reach { REACH_TOOL } else { ACT_TOOL }
}
fn description(&self) -> &str {
"The session's permission gate for one act a plugin asked the host broker for."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"plugin": {"type": "string"},
"service": {"type": "string"},
"method": {"type": "string"},
"effect": {"type": "string", "enum": self.effects()},
"target": {"type": "string"},
},
"required": ["plugin", "service", "method", "effect"],
"additionalProperties": false,
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
if self.reach {
vec![ToolCapability::Network]
} else {
Vec::new()
}
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
}
fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
match BrokerEffect::of(input) {
Some(BrokerEffect::Read) => ApprovalRequirement::Auto,
_ => ApprovalRequirement::Required,
}
}
fn is_read_only(&self) -> bool {
false
}
fn is_read_only_for(&self, input: &Value) -> bool {
BrokerEffect::of(input) == Some(BrokerEffect::Read)
}
fn model_visible(&self) -> bool {
false
}
fn prepare(
&self,
input: Value,
_context: &ToolContext,
) -> Result<crate::tools::spec::PreparedToolCall, ToolError> {
self.admits(&input)?;
Ok(crate::tools::spec::PreparedToolCall {
name: self.name().to_string(),
description: act_description(&input),
read_only: self.is_read_only_for(&input),
supports_parallel: false,
starts_detached: false,
approval: self.approval_requirement_for(&input),
resources: vec![crate::tools::spec::ResourceClaim::GlobalExclusive],
input,
})
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
self.admits(&input)?;
Ok(ToolResult::success(input.to_string()))
}
}
@@ -6,7 +6,9 @@ use codewhale_secrets::Secrets;
use serde::Deserialize;
use serde_json::{Value, json};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::dsh_broker::{
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
};
use super::protocol::DshService;
use crate::tools::spec::ToolContext;
use crate::tools::web::guard::validate_fetch_target;
@@ -115,6 +117,12 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
let Some(tool) = authority.tool.as_ref() else {
return Err(BrokerRefusal::NotAvailable(DshService::Network));
};
let host = reqwest::Url::parse(&input.url)
.ok()
.and_then(|url| url.host_str().map(normalized_host));
call.context
.act("fetch", BrokerEffect::Reach, host.as_deref())
.await?;
fetch(
&plugin,
&authority.hosts,
@@ -127,6 +135,9 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
NetworkMethod::Describe(CredentialRef { name }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
call.context
.act("credentials.describe", BrokerEffect::Read, Some(name))
.await?;
let set = read_secret(vault, &plugin, name)?.is_some();
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, set, "credential described");
Ok(json!({ "ref": name, "set": set, "handle": handle(name) }))
@@ -134,6 +145,9 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
NetworkMethod::Resolve(CredentialRef { name }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
call.context
.act("credentials.resolve", BrokerEffect::Read, Some(name))
.await?;
let set = read_secret(vault, &plugin, name)?.is_some();
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, set, "credential resolved to its handle");
Ok(json!({ "ref": name, "value": set.then(|| handle(name)) }))
@@ -146,6 +160,9 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
"a credential value must be a non-empty secret, not a handle",
));
}
call.context
.act("credentials.set", BrokerEffect::Write, Some(name))
.await?;
vault
.set(&vault_slot(&plugin, name), &value)
.map_err(|_| invalid("the credential vault refused the write"))?;
@@ -0,0 +1,85 @@
use serde::Deserialize;
use serde_json::{Map, Value};
use super::dsh_broker::{
BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, builtin_services, decode,
};
use super::protocol::DshService;
use crate::tools::spec::ToolContext;
const LOG: &str = "codewhale::dsh_plugins";
const MAX_INPUT_FIELDS: usize = 32;
#[derive(Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum PluginsMethod {
#[serde(rename = "list")]
List(Map<String, Value>),
#[serde(rename = "status")]
Status(Map<String, Value>),
#[serde(rename = "posture")]
Posture(Map<String, Value>),
#[serde(rename = "install")]
Install(Map<String, Value>),
#[serde(rename = "remove")]
Remove(Map<String, Value>),
#[serde(rename = "enable")]
Enable(Map<String, Value>),
#[serde(rename = "disable")]
Disable(Map<String, Value>),
}
impl BrokerMethods for PluginsMethod {
const NAMES: &'static [&'static str] = &[
"list", "status", "posture", "install", "remove", "enable", "disable",
];
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(async move {
let method = decode::<PluginsMethod>(&call)?;
if !builtin_services(&call.owner.plugin_id).contains(&DshService::Plugins) {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "plugins refused: not the creator module");
return Err(refused());
}
let tool = live(&call);
if tool.execution.owner_agent_id.is_some() {
tracing::info!(target: LOG, "plugins refused: a sub-agent caller");
return Err(refused());
}
if method.input().len() > MAX_INPUT_FIELDS {
return Err(BrokerRefusal::InvalidInput {
service: DshService::Plugins,
message: format!("a plugins input has at most {MAX_INPUT_FIELDS} fields"),
});
}
Err(BrokerRefusal::NotAvailable(DshService::Plugins))
})
}
impl PluginsMethod {
fn input(&self) -> &Map<String, Value> {
match self {
Self::List(input)
| Self::Status(input)
| Self::Posture(input)
| Self::Install(input)
| Self::Remove(input)
| Self::Enable(input)
| Self::Disable(input) => input,
}
}
}
fn live(call: &BrokerCall) -> ToolContext {
let mut tool = call.context.tool().clone();
let _ = tool.refresh_live_posture();
tool
}
fn refused() -> BrokerRefusal {
BrokerRefusal::Refused {
service: DshService::Plugins,
message: "the plugins service is not available to this caller".to_string(),
}
}
+13 -2
View File
@@ -6,7 +6,9 @@ use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::dsh_broker::{
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
};
use super::protocol::DshService;
pub const PAIRING_TTL: Duration = Duration::from_secs(10 * 60);
@@ -142,7 +144,16 @@ pub fn remote_grants() -> &'static RemoteGrants {
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(std::future::ready(remote_grants().serve(&call)))
Box::pin(async move {
let (method, effect) = match decode::<RemoteMethod>(&call)? {
RemoteMethod::Status {} => ("status", BrokerEffect::Read),
RemoteMethod::Pair { .. } => ("pair", BrokerEffect::Reach),
RemoteMethod::Clients {} => ("clients", BrokerEffect::Read),
RemoteMethod::Revoke { .. } => ("revoke", BrokerEffect::Write),
};
call.context.act(method, effect, None).await?;
remote_grants().serve(&call)
})
}
impl RemoteGrants {
@@ -5,7 +5,9 @@ use codewhale_workflow_js::ToolCallResponse;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::dsh_broker::{
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
};
use super::protocol::DshService;
use crate::tools::execution_envelope::enforce_tool_denial;
@@ -121,6 +123,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
match method {
TerminalMethod::List(NoInput {}) => {
let live = live(&call, "terminal/wait").await?;
call.context.act("list", BrokerEffect::Read, None).await?;
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, terminals = live.terminals.len(), "terminals listed");
Ok(json!({
"instance": live.instance,
@@ -203,10 +203,13 @@ impl Drop for TestOverlay {
fn overlay_granted() -> bool {
#[cfg(test)]
if TEST_OVERLAY.with(std::cell::Cell::get) {
return true;
{
TEST_OVERLAY.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
false
}
false
}
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
+16
View File
@@ -126,6 +126,7 @@ pub mod dsh_broker;
mod dsh_catalog;
mod dsh_llm;
mod dsh_network;
mod dsh_plugins;
mod dsh_remote;
pub use dsh_remote::{RemoteClient, RemoteDenial, RemoteGrants, RemotePairing, remote_grants};
mod dsh_settings;
@@ -1933,6 +1934,11 @@ impl ExtensionHostManager {
if tools.is_empty() {
return Vec::new();
}
for gate in dsh_broker::BrokerActTool::all() {
if tool_registry.get(gate.name()).is_none() {
tool_registry.register(gate);
}
}
let taken: HashSet<String> = tool_registry
.names()
.into_iter()
@@ -3629,6 +3635,9 @@ impl ManagerShared {
})
}
pub(super) fn broker_services(&self, owner: &OwnerRef) -> Vec<protocol::DshService> {
if let Some(granted) = dsh_broker::test_grants() {
return granted;
}
dsh_broker::granted_services(&self.registry.lock().expect("registry lock"), owner)
}
@@ -3637,6 +3646,13 @@ impl ManagerShared {
owner: &OwnerRef,
service: protocol::DshService,
) -> bool {
if let Some(granted) = dsh_broker::builtin_granted(
&self.registry.lock().expect("registry lock"),
owner,
service,
) {
return granted;
}
if let Some(granted) = dsh_broker::test_grants() {
return granted.contains(&service);
}
+23 -1
View File
@@ -540,6 +540,7 @@ pub enum DshService {
Catalog,
Terminal,
Remote,
Plugins,
}
impl DshService {
@@ -553,6 +554,19 @@ impl DshService {
Self::Remote,
];
pub const BUILTIN_ONLY: [Self; 1] = [Self::Plugins];
pub const EVERY: [Self; 8] = [
Self::Workspace,
Self::Llm,
Self::Network,
Self::Settings,
Self::Catalog,
Self::Terminal,
Self::Remote,
Self::Plugins,
];
#[must_use]
pub fn name(self) -> &'static str {
match self {
@@ -563,12 +577,20 @@ impl DshService {
Self::Catalog => "catalog",
Self::Terminal => "terminal",
Self::Remote => "remote",
Self::Plugins => "plugins",
}
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|service| service.name() == name)
Self::EVERY
.into_iter()
.find(|service| service.name() == name)
}
#[must_use]
pub fn builtin_only(self) -> bool {
Self::BUILTIN_ONLY.contains(&self)
}
}
@@ -196,7 +196,7 @@ fn render() -> String {
let _ = writeln!(out, " {name}: {code},");
}
out.push_str("} as const\n\nexport type Direction = 'core_to_host' | 'host_to_core'\n");
let services: Vec<String> = DshService::ALL
let services: Vec<String> = DshService::EVERY
.iter()
.map(|service| quote(service.name()))
.collect();
@@ -676,7 +676,7 @@ fn dsh_broker_is_one_strict_envelope_and_names_only_the_closed_service_list() {
unknown["params"]["service"] = json!("vault");
assert!(parse_host_message(unknown, tier).is_ok());
}
let names: Vec<&str> = DshService::ALL
let names: Vec<&str> = DshService::EVERY
.iter()
.map(|service| service.name())
.collect();
@@ -689,10 +689,17 @@ fn dsh_broker_is_one_strict_envelope_and_names_only_the_closed_service_list() {
"settings",
"catalog",
"terminal",
"remote"
"remote",
"plugins"
]
);
for service in DshService::ALL {
assert_eq!(DshService::BUILTIN_ONLY, [DshService::Plugins]);
assert!(
DshService::ALL
.iter()
.all(|service| !service.builtin_only())
);
for service in DshService::EVERY {
assert_eq!(DshService::parse(service.name()), Some(service));
}
assert_eq!(DshService::parse("Workspace"), None);
@@ -51,6 +51,8 @@ const RESERVED_NAMES: &[&str] = &[
"js_execution",
"request_user_input",
"multi_tool_use.parallel",
"dsh_broker_act",
"dsh_broker_reach",
];
#[derive(Debug, Clone, PartialEq, Eq)]
+53 -19
View File
@@ -226,6 +226,23 @@ fn map_call_error(tool: &str, error: HostCallError) -> ToolError {
}
}
pub(super) fn failed_with_acts(
error: ToolError,
receipts: Option<&Value>,
) -> Result<ToolResultWire, ToolError> {
let holds_acts = receipts.is_some_and(|receipts| receipts.get("broker_acts").is_some());
if !holds_acts {
return Err(error);
}
Ok(ToolResultWire {
content: vec![ContentBlockWire::Text {
text: error.to_string(),
}],
is_error: true,
structured: None,
})
}
pub fn wire_to_result(wire: ToolResultWire, origin: &str) -> ToolResult {
let content = wire
.content
@@ -396,26 +413,44 @@ impl ToolSpec for HostToolSpec {
});
// The deadline stops while one of this call's `core/call`s waits on
// the gate (an approval card), so a person's time is not the tool's.
let value = host
let outcome = host
.call_with_clock(
request,
Some(registration.owner.plugin_id.clone()),
invocation.as_ref().map(InvocationGuard::clock),
)
.await
.map_err(|error| map_call_error(&registration.name, error))?;
self.check_caller(context)?;
self.manager
.live_host_for(registration)
.await
.map_err(ToolError::not_available)?;
self.check_caller(context)?;
let wire: ToolResultWire = serde_json::from_value(value).map_err(|error| {
ToolError::execution_failed(format!(
"extension tool `{}` returned a malformed result: {error}",
registration.name
))
})?;
.await;
let receipts = invocation.as_ref().and_then(InvocationGuard::seal_receipts);
let settled: Result<ToolResultWire, ToolError> = async {
match outcome {
Ok(value) => {
self.check_caller(context)?;
self.manager
.live_host_for(registration)
.await
.map_err(ToolError::not_available)?;
self.check_caller(context)?;
serde_json::from_value(value).map_err(|error| {
ToolError::execution_failed(format!(
"extension tool `{}` returned a malformed result: {error}",
registration.name
))
})
}
Err(error) => Err(map_call_error(&registration.name, error)),
}
}
.await;
let wire = match settled {
Ok(wire) => wire,
Err(error) => {
let wire = failed_with_acts(error, receipts.as_ref())?;
match self.check_caller(context) {
Ok(()) => wire,
Err(stale) => failed_with_acts(stale, receipts.as_ref())?,
}
}
};
let mut result = wire_to_result(wire, &self.origin());
if let Some(Value::Object(metadata)) = result.metadata.as_mut() {
metadata.insert(
@@ -433,10 +468,9 @@ impl ToolSpec for HostToolSpec {
);
}
// What the tool asked the core to run, so the persisted record shows it.
if let (Some(receipts), Some(Value::Object(metadata))) = (
invocation.as_ref().and_then(InvocationGuard::receipts),
result.metadata.as_mut(),
) {
if let (Some(receipts), Some(Value::Object(metadata))) =
(receipts, result.metadata.as_mut())
{
metadata.insert("core_calls".to_string(), receipts);
}
Ok(result)
+13
View File
@@ -187,6 +187,19 @@ pub const AGENTS: &[ExternalAgent] = &[
adapter_binary: None,
adapter_cli_env: None,
},
ExternalAgent {
id: "qwen-code",
name: "Qwen Code",
binary: "qwen",
acp_args: Some(&["--acp"]),
website: "https://github.com/QwenLM/qwen-code",
min_version: Some("0.6.1"),
upgrade: Some("npm i -g @qwen-code/qwen-code@latest"),
login: None,
adapter: None,
adapter_binary: None,
adapter_cli_env: None,
},
];
const FIXTURE_AGENT: ExternalAgent = ExternalAgent {
+1 -3
View File
@@ -18,8 +18,6 @@
//! never render a child session, which is what keeps the orchestrator light at
//! high fanout.
#![allow(dead_code)]
use anyhow::Result;
use codewhale_config::FleetExecConfig;
use codewhale_protocol::fleet::{
@@ -529,7 +527,7 @@ fn parse_exec_terminal_route(value: &serde_json::Value) -> ParsedTerminalRoute {
route.map_or(ParsedTerminalRoute::Invalid, ParsedTerminalRoute::Valid)
}
#[cfg(any(test, feature = "test-support"))]
#[cfg(test)]
fn map_exec_terminal_route(line: &str) -> Option<FleetWorkerReportedRoute> {
let value: serde_json::Value = serde_json::from_str(line).ok()?;
match parse_exec_terminal_route(&value) {
+3 -5
View File
@@ -4,8 +4,6 @@
//! observe work through this trait without knowing whether the worker is a
//! local child process or an SSH-backed remote command.
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
@@ -218,7 +216,7 @@ struct LocalWorkerProcess {
#[cfg(unix)]
session_id: libc::pid_t,
#[cfg(unix)]
parent_death_writer: Option<std::io::PipeWriter>,
_parent_death_writer: Option<std::io::PipeWriter>,
#[cfg(windows)]
windows_job: FleetWindowsJob,
host_kind: FleetHostKind,
@@ -387,7 +385,7 @@ impl LocalProcessFleetHostAdapter {
#[cfg(unix)]
session_id: pid as libc::pid_t,
#[cfg(unix)]
parent_death_writer,
_parent_death_writer: parent_death_writer,
#[cfg(windows)]
windows_job,
host_kind,
@@ -1520,7 +1518,7 @@ fn signal_unix_session(
Ok(errors)
}
#[cfg(unix)]
#[cfg(all(unix, test))]
fn unix_pid_is_running(pid: libc::pid_t) -> bool {
if !unix_pid_exists(pid) {
return false;
-2
View File
@@ -5,8 +5,6 @@
//! Artifacts are referenced by bounded metadata; large payloads live on disk
//! and are never embedded in the ledger.
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::collections::HashMap;
#[cfg(test)]
+2 -7
View File
@@ -4,8 +4,6 @@
//! foreground and coordinate logical local workers while later host adapters
//! add real process and SSH execution behind the same records.
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -1748,11 +1746,7 @@ impl FleetManager {
return Err(start_error);
}
};
if !started {
return Ok(false);
}
Ok(true)
Ok(started)
}
fn start_leased_workers(
@@ -2364,6 +2358,7 @@ impl FleetManager {
}
}
#[cfg(test)]
fn append_worker_event(
&self,
run_id: &FleetRunId,
-3
View File
@@ -1,7 +1,5 @@
//! Fleet profile vocabulary, local profile discovery, and config-facing aliases.
#![allow(dead_code)]
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
@@ -10,7 +8,6 @@ use serde::{Deserialize, Serialize};
use crate::reasoning_preference::ReasoningEffort;
#[allow(unused_imports)]
pub use codewhale_config::{
FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions, FleetRole, FleetSlot,
};
+16 -5
View File
@@ -1,14 +1,16 @@
//! Fleet scheduler policy: leases, heartbeats, backpressure, and recovery.
#![allow(dead_code)]
#[cfg(test)]
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
#[cfg(test)]
use anyhow::anyhow;
use anyhow::{Context, Result};
use chrono::{DateTime, SecondsFormat, Utc};
use codewhale_protocol::fleet::*;
#[cfg(test)]
use serde_json::Value;
use super::ledger::{FleetLedger, FleetLedgerState, FleetTaskLedgerStatus, FleetTaskState};
@@ -65,7 +67,7 @@ impl FleetScheduler {
self.now = now;
}
#[cfg(any(test, feature = "test-support"))]
#[cfg(test)]
pub fn tick_run(&self, run_id: &FleetRunId) -> Result<FleetSchedulerReport> {
let mut report = FleetSchedulerReport::default();
self.recover_unhealthy_work(run_id, &mut report, &mut |_, _, _, _| Ok(()))?;
@@ -83,7 +85,7 @@ impl FleetScheduler {
/// and idempotent to call on a fresh process: a task re-leased by an
/// earlier resume is no longer stale at the same instant, and a terminally
/// failed task is never failed or escalated twice.
#[cfg(any(test, feature = "test-support"))]
#[cfg(test)]
pub fn resume_run(&self, run_id: &FleetRunId) -> Result<FleetSchedulerReport> {
self.resume_run_with_restart_callback(run_id, |_, _, _, _| Ok(()))
}
@@ -134,6 +136,7 @@ impl FleetScheduler {
Ok(report)
}
#[cfg(test)]
fn recover_unhealthy_work(
&self,
run_id: &FleetRunId,
@@ -394,6 +397,7 @@ impl FleetScheduler {
Ok(())
}
#[cfg(test)]
fn launch_queued_work(
&self,
run_id: &FleetRunId,
@@ -450,6 +454,7 @@ impl FleetScheduler {
}
}
#[cfg(test)]
fn next_launch(
&self,
run: &FleetRun,
@@ -594,6 +599,7 @@ impl FleetScheduler {
.update_run_status(run_id, status, &self.timestamp())
}
#[cfg(test)]
fn append_worker_event(
&self,
run_id: &FleetRunId,
@@ -615,12 +621,14 @@ impl FleetScheduler {
}
}
#[cfg(test)]
#[derive(Debug, Default)]
struct ActiveCounts {
by_host: BTreeMap<String, usize>,
by_task_class: BTreeMap<String, usize>,
}
#[cfg(test)]
fn active_counts(state: &FleetLedgerState, run: &FleetRun) -> ActiveCounts {
let mut counts = ActiveCounts::default();
for task in active_tasks_for_run(state, &run.id) {
@@ -646,6 +654,7 @@ fn active_counts(state: &FleetLedgerState, run: &FleetRun) -> ActiveCounts {
counts
}
#[cfg(test)]
fn active_tasks_for_run(state: &FleetLedgerState, run_id: &FleetRunId) -> Vec<FleetTaskState> {
state
.tasks
@@ -667,6 +676,7 @@ fn task_spec_for(state: &FleetLedgerState, task: &FleetTaskState) -> Option<Flee
.cloned()
}
#[cfg(test)]
fn host_key(worker: &FleetWorkerSpec) -> String {
match &worker.host {
FleetHostSpec::Local => "local".to_string(),
@@ -676,6 +686,7 @@ fn host_key(worker: &FleetWorkerSpec) -> String {
}
}
#[cfg(test)]
fn task_class(task: &FleetTaskSpec) -> String {
task.metadata
.get("class")
@@ -11,8 +11,6 @@
//! - Mailbox events stream into the Fleet ledger as `FleetWorkerEventPayload`
//! - `FleetWorkerInspection` reads both ledger state and sub-agent worker records
#![allow(dead_code)]
use std::borrow::Cow;
use std::path::PathBuf;
+30 -3
View File
@@ -25,6 +25,7 @@
pub mod rate_limit;
use anyhow::Result;
use codewhale_command_contract::metrics::format_duration;
use codewhale_models::{MessageRequest, MessageResponse, StreamEvent};
use codewhale_runtime::config::{RateLimitRetryPolicy, RetryPolicy};
use serde_json::Value;
@@ -457,6 +458,18 @@ pub enum LlmError {
Other(String),
}
fn opencode_go_hint(message: &str) -> &'static str {
let lowered = message.to_lowercase();
if lowered.contains("codewhale auth set") {
return "";
}
if lowered.contains("opencode go") || lowered.contains("opencode.ai/go") {
" OpenCode Go is a separate provider with its own key; run: codewhale auth set --provider opencode-go"
} else {
""
}
}
impl std::fmt::Display for LlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -464,11 +477,25 @@ impl std::fmt::Display for LlmError {
message,
retry_after,
..
} => write!(
} => {
let hint = opencode_go_hint(message);
match retry_after {
Some(d) => write!(
f,
"Rate limit exceeded: {message}; quota window resets in {}; automatic retry stopped{hint}",
format_duration(*d)
),
None => write!(
f,
"Rate limit exceeded: {message}; automatic retry stopped{hint}"
),
}
}
LlmError::RateLimited { message, .. } => write!(
f,
"Rate limit exceeded: {message}; quota window reset in {retry_after:?}; automatic retry stopped"
"Rate limit exceeded: {message}{}",
opencode_go_hint(message)
),
LlmError::RateLimited { message, .. } => write!(f, "Rate limit exceeded: {message}"),
LlmError::QuotaExhausted(error) => {
write!(f, "Provider plan quota exhausted: {}", error.message)
}
+70
View File
@@ -119,6 +119,76 @@ fn retryability_distinguishes_transient_failures_from_durable_failures() {
}
}
#[test]
fn quota_window_display_prints_reset_time_as_plain_text() {
let windowed = LlmError::QuotaWindow {
message: "Free usage limit reached".into(),
retry_after: Some(Duration::from_secs(70168)),
transmission: RequestTransmission::Rejected,
}
.to_string();
assert!(windowed.contains("resets in 19h29m"), "{windowed}");
assert!(windowed.contains("automatic retry stopped"), "{windowed}");
let open_ended = LlmError::QuotaWindow {
message: "Free usage limit reached".into(),
retry_after: None,
transmission: RequestTransmission::Rejected,
}
.to_string();
assert!(!open_ended.contains("reset"), "{open_ended}");
assert!(
open_ended.contains("automatic retry stopped"),
"{open_ended}"
);
for text in [&windowed, &open_ended] {
assert!(!text.contains("Some("), "{text}");
assert!(!text.contains("None"), "{text}");
}
let hinted = LlmError::QuotaWindow {
message: "OpenCode Go free usage limit reached".into(),
retry_after: Some(Duration::from_secs(70168)),
transmission: RequestTransmission::Rejected,
}
.to_string();
assert_eq!(
hinted
.matches("codewhale auth set --provider opencode-go")
.count(),
1,
"{hinted}"
);
let rate_limited = LlmError::RateLimited {
message: "OpenCode Go free usage limit reached".into(),
retry_after: None,
}
.to_string();
assert_eq!(
rate_limited
.matches("codewhale auth set --provider opencode-go")
.count(),
1,
"{rate_limited}"
);
let already_hinted = LlmError::QuotaWindow {
message: hinted.clone(),
retry_after: Some(Duration::from_secs(70168)),
transmission: RequestTransmission::Rejected,
}
.to_string();
assert_eq!(
already_hinted
.matches("codewhale auth set --provider opencode-go")
.count(),
1,
"{already_hinted}"
);
}
#[test]
fn http_response_boundary_classifies_status_contract() {
for status in [408, 409, 499, 500, 502, 503, 504] {
+44 -17
View File
@@ -1669,6 +1669,10 @@ pub trait McpTransport: Send + Sync {
async fn recv(&mut self) -> Result<Vec<u8>>;
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
self.send(msg).await
}
/// Record the protocol revision negotiated at `initialize`. Only the
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
/// subsequent requests); stdio and legacy SSE have no header channel, so
@@ -3125,6 +3129,30 @@ impl McpConnection {
result
}
async fn answer_server_request(&mut self, reply: &serde_json::Value) {
let result = match serde_json::to_vec(reply) {
Ok(bytes) => {
let cancel_token = self.cancel_token.clone();
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
Err(anyhow::anyhow!("MCP connection was cancelled"))
}
result = self.transport.send_reply(bytes) => result,
}
}
Err(err) => Err(err.into()),
};
if let Err(err) = result {
tracing::debug!(
target: "mcp",
server = %self.name,
error = %err,
"could not answer an MCP server request"
);
}
}
/// Handshake and discovery receive: each frame wait is bounded by the read
/// knob, and a server that stays silent past it is treated as dead.
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
@@ -3202,6 +3230,22 @@ impl McpConnection {
{
self.catalog_stale = true;
}
if let Some(method) = value.get("method") {
if let Some(id) = value.get("id") {
let method = method.as_str().unwrap_or_default();
let reply = if method == "ping" {
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
} else {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("Method not found: {method}")}
})
};
self.answer_server_request(&reply).await;
}
continue;
}
if response_id_matches(value.get("id"), &expected_id) {
// Marks the connection stale so it is rebuilt, but this is a
// reply to the request, so it never qualifies for a replay.
@@ -3692,23 +3736,6 @@ impl McpPool {
Ok(pool)
}
/// Construct a source-aware empty pool after the initial config load
/// failed. Keeping the source paths means a later edit or explicit
/// `/mcp reload` can recover in-process instead of pinning the session to
/// an ad-hoc pool that has no files to re-read.
pub fn empty_with_workspace_config_sources(
path: &std::path::Path,
workspace: &Path,
plugins: Arc<codewhale_plugins::PluginRegistry>,
) -> Result<Self> {
Self::empty_with_workspace_config_sources_and_auth_context(
path,
workspace,
plugins,
process_mcp_auth_context(),
)
}
pub fn empty_with_workspace_config_sources_and_auth_context(
path: &std::path::Path,
workspace: &Path,
+1 -68
View File
@@ -404,30 +404,6 @@ pub fn persist_decisions(
})
}
/// Filter candidates that still need a user decision for this content hash.
#[allow(dead_code)] // used by future selector UI + unit tests
pub fn candidates_needing_consent(
candidates: &[ImportCandidate],
store: &ImportConsentStore,
) -> Vec<ImportCandidate> {
candidates
.iter()
.filter(|c| {
let key = consent_key(&c.source_path, &c.content_hash);
match store.entries.get(&key) {
Some(entry) if entry.decision == ImportDecision::Decline => false,
Some(entry) if entry.decision == ImportDecision::Approve => {
// Re-prompt only when the specific server was not part of
// the prior approval list (partial approval).
!entry.servers.iter().any(|s| s == &c.name)
}
_ => true,
}
})
.cloned()
.collect()
}
/// Apply approvals: returns servers to merge into user mcp.json.
/// Hard-blocked candidates are never returned even if decision is Approve.
#[cfg(any(test, feature = "test-support"))]
@@ -1093,7 +1069,7 @@ mod tests {
}
#[test]
fn unrelated_source_rewrites_keep_review_tokens_and_declines() {
fn unrelated_source_rewrites_keep_review_tokens() {
with_import_context(|context| {
let source = write_claude_json(
&context.home,
@@ -1116,23 +1092,6 @@ mod tests {
)
.unwrap();
assert!(receipt.imported);
let candidates = discover_from_json_file(&source, ExternalMcpSourceKind::ClaudeJson);
let mut store = ImportConsentStore::default();
let decisions = HashMap::from([("y".to_string(), ImportDecision::Decline)]);
record_decisions(&mut store, &candidates, &decisions, 1);
fs::write(
&source,
r#"{"numStartups":3,"mcpServers":{"x":{"command":"echo"},"y":{"command":"echo"}}}"#,
)
.unwrap();
let refreshed = discover_from_json_file(&source, ExternalMcpSourceKind::ClaudeJson);
let needing: Vec<_> = candidates_needing_consent(&refreshed, &store)
.into_iter()
.map(|c| c.name)
.collect();
// `y` stays declined; `x` was never decided in this store.
assert_eq!(needing, ["x"]);
});
}
@@ -1208,32 +1167,6 @@ mod tests {
assert!(!config.servers["ok"].is_enabled());
}
#[test]
fn declined_consent_skips_reprompt_until_hash_changes() {
let dir = tempdir().unwrap();
let path = write_claude_json(
dir.path(),
r#"{"mcpServers":{"x":{"command":"echo","enabled":true}}}"#,
);
let candidates = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson);
let mut store = ImportConsentStore::default();
let mut decisions = HashMap::new();
decisions.insert("x".into(), ImportDecision::Decline);
record_decisions(&mut store, &candidates, &decisions, 1);
let needing = candidates_needing_consent(&candidates, &store);
assert!(needing.is_empty(), "declined should not re-prompt");
// Content change → new hash → re-prompt.
fs::write(
&path,
r#"{"mcpServers":{"x":{"command":"echo","args":["changed"],"enabled":true}}}"#,
)
.unwrap();
let refreshed = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson);
let needing = candidates_needing_consent(&refreshed, &store);
assert_eq!(needing.len(), 1);
}
#[test]
fn provenance_display_includes_source_and_hash() {
let dir = tempdir().unwrap();
+15
View File
@@ -177,6 +177,21 @@ impl McpTransport for HttpTransport {
}
}
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => {
transport.send(msg).await.map_err(|err| match err {
StreamableSendError::Incompatible(detail)
| StreamableSendError::StaleSession(detail) => anyhow::anyhow!(
"MCP Streamable HTTP refused a reply to a server request: {detail}"
),
StreamableSendError::Other(err) => err,
})
}
HttpTransportMode::Sse(transport) => transport.send(msg).await,
}
}
async fn recv(&mut self) -> Result<Vec<u8>> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => transport.recv().await,
-17
View File
@@ -1607,23 +1607,6 @@ pub fn server_supports_oauth_login(server: &McpServerConfig) -> bool {
&& !server_has_manual_authorization(server)
}
/// Whether the shared token store already holds a usable credential for this
/// server, with its binding revalidated against discovery. Used by the
/// authenticate tool's already-authorized branch.
pub async fn has_usable_stored_tokens(
name: &str,
server: &McpServerConfig,
network_policy: Option<&NetworkPolicyDecider>,
) -> Result<bool> {
has_usable_stored_tokens_with_auth_context(
name,
server,
network_policy,
super::process_mcp_auth_context(),
)
.await
}
pub async fn has_usable_stored_tokens_with_auth_context(
name: &str,
server: &McpServerConfig,
+128
View File
@@ -3314,6 +3314,80 @@ async fn call_method_skips_notifications_and_unmatched_responses() {
assert_eq!(sent[0]["method"], "tools/call");
}
#[tokio::test]
async fn call_method_answers_server_requests_instead_of_taking_them_as_the_reply() {
let sent = Arc::new(Mutex::new(Vec::new()));
let transport = ScriptedValueTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": "1", "method": "roots/list"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
]),
};
let mut conn = test_connection(Box::new(transport));
let result = conn
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
.await
.unwrap();
assert_eq!(result, serde_json::json!({"ok": true}));
let sent = sent.lock().unwrap();
assert_eq!(sent.len(), 3);
assert_eq!(
sent[1],
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}})
);
assert_eq!(sent[2]["id"], "1");
assert_eq!(sent[2]["error"]["code"], -32601);
}
struct ReplyRefusingTransport {
sent: Arc<Mutex<Vec<serde_json::Value>>>,
responses: VecDeque<Vec<u8>>,
}
#[async_trait::async_trait]
impl McpTransport for ReplyRefusingTransport {
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
let msg: serde_json::Value = serde_json::from_slice(&msg)?;
anyhow::ensure!(msg.get("method").is_some(), "status=405 Method Not Allowed");
self.sent.lock().unwrap().push(msg);
Ok(())
}
async fn recv(&mut self) -> Result<Vec<u8>> {
self.responses
.pop_front()
.context("scripted transport exhausted")
}
}
#[tokio::test]
async fn call_method_keeps_its_reply_when_answering_a_server_request_fails() {
let sent = Arc::new(Mutex::new(Vec::new()));
let transport = ReplyRefusingTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
]),
};
let mut conn = test_connection(Box::new(transport));
let result = conn
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
.await
.unwrap();
assert_eq!(result, serde_json::json!({"ok": true}));
assert_eq!(conn.state, ConnectionState::Ready);
let sent = sent.lock().unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0]["method"], "tools/call");
}
#[tokio::test]
async fn call_method_invalid_json_includes_server_output_preview() {
let sent = Arc::new(Mutex::new(Vec::new()));
@@ -6770,6 +6844,60 @@ async fn custom_headers_applied_to_get_preflight() {
);
}
#[tokio::test]
async fn refused_reply_to_a_server_request_does_not_fall_back_to_sse() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _env = crate::test_support::lock_test_env();
let _lock = lock_mcp_loopback_tests().await;
let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*,127.0.0.1");
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_srv = Arc::clone(&requests);
let server = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let request_line = String::from_utf8_lossy(&buf[..n])
.lines()
.next()
.unwrap_or_default()
.to_string();
requests_srv.lock().unwrap().push(request_line);
socket
.write_all(
b"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
)
.await
.unwrap();
}
});
let url = format!("http://{addr}/mcp");
let mut transport = HttpTransport::new(
test_mcp_http_client(&url),
url,
tokio_util::sync::CancellationToken::new(),
Duration::from_secs(2),
);
let result = transport
.send_reply(json_frame(
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
))
.await;
server.abort();
assert!(result.is_err(), "a refused reply reports the refusal");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 1, "requests seen: {requests:?}");
assert!(requests[0].starts_with("POST "), "{requests:?}");
}
// === add_runtime_server_config conflict tests ===
#[test]
Loaded 100 of 287 files, more files were not shown because too many files have changed in this diff. Show more