Compare commits

...
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 af2ac21b36 feat(runtime): conversation modes standard and creator, agent_preset on threads (CW-8049)
GET /v1/modes lists the built-in table; agent_preset is stored on the thread
record and the terminal session file and returned in the thread summary.
Absent means standard. Set on POST /v1/threads or PATCH while the thread has
no turn; unknown, unavailable, locked and executor cases are typed refusals
(unknown_mode, mode_unavailable, mode_locked,
mode_not_supported_with_executor). Creator is gated on host-runtime
availability only (no plugin_creator key exists). Stores and exposes the mode;
what Creator loads is CR-01/CR-04. One-line test-literal fix in
crates/tui/src/tui/ui/tests.rs for the new SavedSession field.

Checks (farm, Linux x86_64): nextest engine+app-server filtered, 12 run, 12 passed, 0 failed
(7 of them the new mode tests); clippy -D warnings --all-targets on engine,
app-server, tui: exit 0. Local: cargo fmt --check and preflight --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 11:56:43 -08:00
CodeWhale BotandClaude Sonnet 5.5 f86aac6275 fix(web): home posture note names the frames that show the model and Ask setting; the OrcaRouter offer returns below the first screen; changelog gating sentence corrected (CW-8048 HOME-2 version B, repair 1)
The note under the hero picture said the frames before and after show the
model name and the Ask setting; the Edit frame next to Approval shows
neither. It now says the Prompt and Result frames show them, which the
capture's flags confirm at both widths, in all 18 locales and the GT catalog.

Founder decision 156: the dated OrcaRouter offer strip from CW-7933 is
restored unchanged (partner-promo.ts, markup and styles from cc0d42bf4a,
English only, referral disclosure, days left computed at render, hidden
after 2026-11-21), placed after the model section, so the first screen is
as accepted.

The CHANGELOG said the page names several agents only while the release has
it; only the facts row is gated. Corrected, and it now says the offer strip
stays below the first screen. TUI changelog resynced.

Checks: npm test 1301 passed + 7 node, tsc, lint, check:locales, check:tokens
and build all exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 10:34:25 -08:00
CodeWhale BotandClaude Sonnet 5.5 c390b981b7 docs(changelog): the home page line says what the rebuild removed (CW-8048 HOME-2 version B)
The earlier home reorganization (CW-7933) is replaced by version B, which no
longer carries the dated OrcaRouter offer strip or the browser-filled GitHub
community panel; that entry described a page that no longer exists, so it is
folded into the rebuild's line. TUI changelog slice resynced.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 10:34:21 -08:00
CodeWhale BotandClaude Sonnet 5.5 fef5762aac fix(web): Arabic home opening sentence reads right to left; changelog line for the home page (CW-8048 HOME-2 version B)
The site sets unicode-bidi: plaintext on RTL paragraphs, so a paragraph
that opens with the Latin brand name took a left-to-right base and the
Arabic intro came out reversed around "Codewhale" and "Rust." Wrapping the
brand in bdi isolates it and the paragraph takes the Arabic direction.
Measured by word positions in the rendered /ar page before the fix.

Adds the CHANGELOG line for the rebuilt home page and syncs the TUI
changelog slice.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 10:34:17 -08:00
CodeWhale BotandClaude Opus 5.5 35e5e150e3 feat(web): home first screen says what Codewhale is, with the line kept as the headline (CW-8048 HOME-2 version B)
The first build put about a dozen things on the first screen. This keeps
five: the founder's line, one sentence that says what Codewhale is, the
install command, the released terminal session as one wide picture, and a
row of facts from data (Rust, licence, provider routes and models of the
published release, many agents when released). The route moves to one
smaller section lower down and carries the whale; the belief paragraph and
the text-only agents section are removed.

New home strings in 17 locales are machine drafts. Not yet run: npm test,
lint, build (version comparison commit; checks run on the recommended one).
tsc --noEmit: exit 0. check-locales: PASS.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 10:34:13 -08:00
CodeWhale BotandClaude Fable 5.2 abcdd8ec3c fix(web): label the Chinese pages as having no native review yet (CW-8048 HOME)
Chinese ships with the same footer line as the other translated locales
until a native reader has reviewed it. English stays the reference.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 10:34:09 -08:00
CodeWhale BotandClaude Fable 5.2 cdb2679825 feat(web): the home page draws the whole product as one route (CW-8048 HOME)
The home is rebuilt on the chosen direction. Under the two-sentence
headline the product is one line on the horizon: solid through the parts
that are released, dashed and dotted through the ones that are not, and off
the edge of the page. Line style, station shape and a word carry each state,
from product-map.ts; colour never does alone. The whale rests where the solid
line ends and is never drawn over a part that is not released. The drawing is
hidden from assistive technology and an ordered list beside it says the same
thing.

The terminal that hangs from the Terminal app station is the released
v0.10.2 capture as selectable text. It opens on the approval step, with a
real tablist (Prompt, Edit, Approval, Result) that arrow keys, Home and End
move through. Its caption comes from capture-caption.ts, and frames that do
not show the model name or the Ask setting say so. Section 3 carries no
frame: no released capture shows several agents, and a development-build
frame is not true of the released version.

On a phone the first screen holds the headline, a compact route with the
whale, and the install box; the full list of parts appears once, in section
4, as a printed tree. The install box puts Copy in the choice row so the
command has the whole line, and on a phone the command breaks at the pipe.

Every visible word comes from home.ts. Eight keys nothing renders are removed
in all 18 locales; six frame summaries and a plural-aware contributor label
are added. The translations of the new keys are machine drafts, like the
rest of those locales. The merged list shows titles and faces and no times;
the date the count was read stays a title and screen-reader text.

The footer sits on the page's own ground under one hairline, as four columns
that fold into four disclosures on a phone, and the mirror links leave it for
/install#mirrors. With the navy band gone the stage roles and sea tokens go
too. The home's sky is the page ground itself, so the header paints the same
water and there is no seam under it. export-design-tokens.py now exports the
design kit's surface light, which the sky uses.

Removed with the old home: Strata, LivingHeroWhale and its light lattice,
the model chips, newest list, work-pattern grid, plugins heading, install
band and stats renderers, and the partner strip with its expiry helper. The
home reads only the facts and the committed activity snapshot, so it renders
with no request to GitHub and revalidates like /install.

Whale B's geometry is vendored unchanged with its provenance.

Checks, from web/: npm test 7 + 1291 passed (90 files), 0 failed; lint, tsc,
check:locales, check:tokens, check:docs, check:facts and build all pass.
Measured on the rendered page in en, zh and ar, light and dark, at 1440 and
375: no horizontal overflow from 320 to 1920 px in all 18 locales; site text
contrast at least 5.08:1; no animation under reduced motion.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 10:34:05 -08:00
CodeWhale BotandClaude Sonnet 5.5 4b8a300097 fix(git): pass GIT_NO_LAZY_FETCH instead of --no-lazy-fetch (CW-8055)
The Git tool's review_scope action passed --no-lazy-fetch on every
command; git older than 2.44 rejects that option, so the action failed on
older git. The same protection is available as the GIT_NO_LAZY_FETCH
environment variable, which newer git honors and older git ignores. The
argument and environment are built in apply_review_git_options.

Checks: farm nextest -p codewhale-engine --lib git_tool: 8 run, 8 passed;
farm clippy -p codewhale-engine --all-targets -D warnings: clean;
cargo fmt --all -- --check and scripts/preflight.sh --check: OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 07:23:37 -08:00
CodeWhale BotandClaude Opus 5.5 99eb5c7769 feat(extension-host): fill the dsh/broker workspace arm over the existing file and Git tools (CW-7970)
The workspace arm serves four typed methods: read, list, write and exec.
Any other method is an unknown_method refusal and unknown fields are
invalid_input. Nothing is passed through.

Each act is one nested core call through the invocation's own context:
read_file, list_dir, write_file, or the Git tool's review_scope. So the
session's permission mode decides it like any other agent action (Ask
asks, Auto-Review judges, Full Access proceeds, Plan and deny lists
refuse), the per-invocation cap of 50 nested calls is the runaway
ceiling, and every act is receipted on the tool result.

Before the card the arm refuses when the live posture's deny list or a
child's grant would refuse the direct tool, and confines every path with
the existing resolve_strict_authority_path: relative, or absolute under
the workspace; no parent traversal; no symlink at any component, even
one pointing inside. The core tool receives the canonical path.
Confinement runs on the blocking pool behind a four-permit gate and stops
waiting on cancel.

exec takes a literal argv. argv[0] must be exactly git, and the rest
(after an optional --no-optional-locks) must equal one of the six
read-only metadata shapes the Git tool already runs, with cwd at the
workspace root if given. No shell, program path, -c or extra flag. The
Git tool runs it process-tree contained under a deadline and the call's
cancel token.

write is the read-write overlay. It is refused unless the plugin holds
the reviewed overlay grant, checked before any path is read; there is
no production source for that grant yet, so it is refused outside
tests. In the overlay, the workspace root and any .git or .codewhale
segment are refused. Writes are bounded to 1 MiB.

Refusals tell the plugin only what it needs; paths and gate messages go
to the log. The arm holds no state beyond the call.

src/dsh/workspace.ts is the matching facade, with bounds checked locally.
It is not registered and nothing is advertised: no service is granted.

Two seam tests that expected every unfilled stub to answer not_available
now skip the workspace arm, as they skip the network, remote and terminal
arms. The blocking-call budget records the two canonicalize calls in the
sync helpers that run only under spawn_blocking.

Known limits: a path is checked by the arm and resolved again by the core
tool, so a symlink swapped in between by a process that can write the
workspace is not caught here. Unmodified synchronous node:fs and
child_process calls stay refused by the Native sandbox; no OS sandbox
grant is added. The Git tool passes --no-lazy-fetch, which git older
than 2.44 rejects (exit 129), so exec fails on such hosts.

Checks (Linux farm): cargo nextest run --locked -p codewhale-engine --lib
-E 'test(extension_host)': 214 run, 214 passed, 6989 skipped, including
the seven new dsh_workspace unit tests and the new workspace broker test.
cargo clippy --locked -p codewhale-engine --all-targets -- -D warnings:
clean. Both on the tree rebased onto the grant slice. Extension host:
typecheck clean, build exit 0, node 455 tests 448 pass 7 skipped, bun 455
tests 449 pass 6 skip. cargo fmt --check and scripts/preflight.sh --check
pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 06:47:26 -08:00
CodeWhale BotandClaude Opus 5.5 0e425e0f8e fix(plugins): the agent's review is decided on the record at write time, and an agent's record belongs to its project (CW-8053)
Repair round 2.

- trust_reviewed now compares, inside the locked state closure, the
  generation on disk with the one the registry read, for an agent
  approver, and refuses with "state or review changed" when they differ.
  check_approver's "no review held for every project" rule read the
  registry's in-memory copy, so a registry read before a person's review
  (a long-lived terminal session, or a race) let the agent replace that
  review (re-review probe E). A plugin with no entry is generation 0 on
  both sides, so a first approval after an install still passes.
- Ruling R22: check_approver refuses the agent when the plugin's valid
  record is an agent's of another project (outside_reach); that agent
  asks the person. Before, the agent of B could take over A's agent
  record and turn the plugin off in A.

Tests: the_agent_cannot_replace_a_review_made_after_its_registry_was_read
and the_agent_of_another_project_cannot_take_over_an_agents_record. On
343a721580 both fail (farm run 20261111T132431Z-gaps-r2before-2bc61b:
15 run, 13 passed, 2 failed, unwrap_err on Ok). After: farm run
20261111T132426Z-gaps-r2test-b0f9bb, 438 run, 438 passed, 6990 skipped
(codewhale-plugins 231, engine extension_host 207). Clippy -D warnings
(20261111T132428Z-gaps-r2clippy-cadf9e) exit 0. preflight --check OK,
cargo fmt --check clean. Docs sentence names the other project's agent;
agent-docs regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:38 -08:00
CodeWhale BotandClaude Opus 5.5 cf6b8dc2af fix(plugins): the agent never replaces a review held for every project, and a linked project folder is not the agent's (CW-8053)
Repair round 1, per coordinator ruling R21 (amends R4's "a folder in the
workspace"). An install lands in the user's plugin folder, so the scope
test of dec4b298c1 refused exactly the plugin R4 is about, and it let a
linked <project>/.codewhale reach outside the project. check_approver now
holds what the crate can know:

- no workspace-folder scope test;
- refuses the agent for a built-in plugin;
- refuses the agent when the plugin's current record is valid and belongs
  to the reviewed cohort (a person, the command line, or a review from
  before 0.10.3): ask the person. This also closes the review's finding 3,
  where the agent's trust_reviewed replaced a person's record and walked
  around enable_by's refusal;
- for a Workspace-scoped plugin, requires its canonical folder to sit
  inside the canonicalized project; a canonicalize failure refuses.

enable_by keeps its record test (a record the agent made for this
project, not outside_reach) ahead of check_approver. CR-03 and the command
line check that the agent wrote and installed the plugin.

Tests: the_agent_never_replaces_a_review_held_for_every_project (probe D,
the command line, a pre-0.10.3 receipt, a built-in) and, on unix,
a_project_plugin_folder_that_resolves_outside_the_project_is_not_the_agents
(symlinked .codewhale refused; a real project folder accepted). The test
fixtures write plugins to the user folder again; the engine fixture
revokes the person's review before the agent reviews. Docs sentence
rewritten to R21; agent-docs regenerated.

Boat farm run 20261111T120302Z-gaps-r1test-7bff0d: 436 run, 436 passed,
6990 skipped (codewhale-plugins 229, engine extension_host 207).
scripts/preflight.sh --check OK; cargo fmt --check clean.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:34 -08:00
CodeWhale BotandClaude Opus 5.5 d22a678c31 docs(plugins): say which plugins the agent may approve or turn on, and that the command line grants no service (CW-8053)
web/data/agent-docs.generated.json regenerated with derive-facts.
scripts/preflight.sh --check: OK.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:30 -08:00
CodeWhale BotandClaude Opus 5.5 d69d83ad27 fix(runtime-api): record a review or turn-on from the app as the app, not the terminal (CW-8053)
The plugins route called trust and enable, which record
{person, via: terminal}. It now calls trust_reviewed and enable_by with
PersonVia::App. The receipt stays in review_history, so the label is fixed
before the tag. The native-UI route test reads the record back.

Boat farm run 20261111T102948Z-gaps-test-4768b2: app-server plugin tests
11 run, 11 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:27 -08:00
CodeWhale BotandClaude Opus 5.5 52521af86a fix(extension-host): a plugin whose review record cannot be read at the scan is not loaded (CW-8053)
desired_owners read the record with .ok(), so a read error between
active_component_sources and this read inserted the owner with an empty
grant and its cohort unknown (G6 fail-open). A read error now keeps the
plugin out with a diagnostic naming the reason, and the scan also refuses
when the in-memory snapshot says the agent approved it. The loop is split
into desired_owners_from so a test can break the record between the two
reads.

Test: a_record_that_cannot_be_read_at_the_scan_loads_no_owner (turns the
plugin off from another registry between the reads). Boat farm run
20261111T102948Z-gaps-test-4768b2: 447 run, 447 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:23 -08:00
CodeWhale BotandClaude Opus 5.5 6118b9c097 fix(plugins): the agent approves and turns on only a plugin folder in its own project, and a command line approval grants nothing (CW-8053)
Closes three gaps the grant slice's review found in the crate itself, so
they hold whatever a caller does:

- enable_by now checks an agent approver as trust_reviewed does (its own
  project, the thread, turn and tool call) and refuses when the plugin's
  record was approved for every project (by a person or the command line)
  or by the agent of another project. Before, an agent of project B could
  turn on a plugin limited to project A, and an agent with no call identity
  could turn on a plugin a person reviewed, which then ran everywhere.
  R4 and section 148.5: outside the agent's scope the caller asks the person
  through the normal card, so the approver it passes is then a person.
- check_approver refuses an agent approval unless the plugin was found in
  the workspace's own plugin folder (scope Workspace), R4's "a folder in the
  workspace". Before, the agent's review of a user-scope plugin a person had
  reviewed replaced that review.
- PluginApprover::CommandLine with a review token is refused: the command
  line shows no review, so it cannot record a host-service grant. Without a
  token it records trust as before. A command line process carrying the
  agent's mark is the agent (R14) and is checked as the agent.

The engine's grant test fixture writes its plugins into the workspace
plugin folder, because its agent-approved plugin must now live there.

Tests: four new or changed in host_service_tests (the three gaps, and the
command line test, whose agent now turns on only its own record). Boat farm
run 20261111T102948Z-gaps-test-4768b2: 447 run, 447 passed, 7837 skipped
(codewhale-plugins 228, engine extension_host 207, app-server plugin 11).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 05:56:19 -08:00
CodeWhale BotandClaude Sonnet 5.5 d48d4b0883 fix(acp): a transport failure mid-turn still settles the turn and saves its checkpoint (CW-8043 repair 1)
A failed output write (including the 30 s write limit), a non-EOF read error and
an over-8 MiB non-response frame used to end the connection and drop the prompt
task, so the interrupted turn never reached drive_prompt's settle and
checkpoint save. Each now records the failure, sends the prompt Eof and
notifies shutdown exactly as a clean EOF does; the output pump keeps draining
and discarding, the read error ends the input stream, and the oversize sink
frame is dropped. serve_connection returns the recorded error after the prompt
has settled.

Tests: the guard that used a failing emit closure (unreachable in production)
is restored to a real failing writer through serve_connection, with a failing
reader, a failing writer at the permission request, and an oversize-update sink
test. The three end-to-end tests fail on the previous commit (0 passed, 3
failed) and pass now.

Farm: lib acp_server 43 passed, acp_journey 2, acp_conformance 1,
single_turn_loop 14, clippy exit 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 05:51:11 -08:00
CodeWhale BotandClaude Sonnet 5.5 00e3327004 refactor(acp): serve ACP on the official SDK agent connection (CW-8043 ACP-SDK-S1)
CapturedAcpFrontend::serve keeps its signature and now drives an
agent-client-protocol v1 agent over our own bounded Lines transport: the SDK
owns framing, ids, ordering and error envelopes. Replaces the handwritten
read loop (AcpServer::serve), AcpDispatch, the write_* JSON helpers,
JsonRpcResponseIdPolicy, is_jsonrpc_response and the permission id counter.

- Inbound: BoundedLines stream, blank lines skipped, an over-limit line is
  answered -32700 with the connection kept; daemon/input_closed (sent without
  a jsonrpc member) ends the stream as EOF before the SDK sees it.
- Outbound: channel sink drained into the borrowed writer; an over-8 MiB
  response becomes the same -32603, anything else ends the connection.
- Handlers are catch-alls over Value params, so today's validators, session
  cap, posture checks and Codewhale methods are unchanged and requests before
  initialize are still served. A prompt runs in cx.spawn behind a
  synchronously set busy flag; cancel, permission answers and EOF reach
  project_turn through an inbox. Every spawned task and callback returns
  Ok, prompt failures answer -32603 through the responder.
- Frames are built from the official v1 types. modelSelection and models are
  gone, list createdAt/messageCount move to _meta.codewhale, root sessionId
  stays on session/load. Terminal auth methods only for clients that enabled
  them, a permission request always carries text, notifications are never
  answered, session/list, config setters and provider methods are served
  while a turn runs, and the unused session cursor is deleted.

Tests changed: initialize_* (typed builder; modelSelection asserted absent),
the four wire-helper tests (replaced by one connection test), the image
update test (typed update), Rig::prompt and permission cases (run the real
agent over a duplex), status lists (omitted pending default counts as
pending). Added: oversize inbound, oversize outbound, session cap, mid-turn
requests. Conformance allowlist shrinks to the load sessionId row.

Farm (Linux): acp_journey 2 passed, acp_conformance 1 passed, lib acp_server
40 passed, core single_turn_loop 14 passed, clippy -D warnings clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 05:51:07 -08:00
CodeWhale BotandClaude Sonnet 5.5 29e9f0835c feat(skills): bundled skills can carry files; ship the four DSH creator skills (CR-01)
A bundled skill was one text body. It can now carry files beside SKILL.md
(references/, templates/): install_companion_files writes each one only when
absent, so a file the person edited is never overwritten on upgrade, and an
edited SKILL.md still stops that skill's install as before. The four DSH
creator skills ship as generation 21 system skills, with every file equal to
the CR-00 pin; skills-catalog-matrix.json gains the four rows.

load_skill prints DSH's body unchanged, then 'Base directory for this skill',
then a 'Host notes: Codewhale' block, for an unedited bundled copy that
carries files or notes (bundled_skill_load_context). The notes are
Codewhale's own text, one short file per skill, and hold only what section 2.4
of the design says the agent is told instead of DSH's sentence.

Gating: the plugin_creator switch does not exist yet. The skills are visible
now; CREATOR_SKILL_NAMES lets the switch's slice hide them by adding the names
to the effective bundled 'disabled' list, with no file moved (a test proves
the policy hides them). Not met here: reading a template with the read tool
in Ask without approval needs the owned skills dir in
ToolContext.trusted_external_paths (engine.rs, not mine); requested in the
review packet.

Checks (farm, Linux): codewhale-skills nextest 188/188 passed; clippy
-D warnings clean on codewhale-skills (all targets) and codewhale-engine
(lib, tests); codewhale-engine tools::skill nextest 16/16 passed. One
existing test updated: the marker for a customised copy now moves to 21
because the new skills install. Local: cargo fmt --check, preflight --check.

Squashed with repair round 1 (03e4bfd7cd) at landing, per the second review (CW/artifacts/creator-mode-20261111/CR-00-01/REVIEW-r2.md): the four creator skills are hidden by default behind set_creator_skills_enabled (process-wide, off, no caller; MODE-01 replaces it with a per-thread flag); every installed skill folder carries DeepSeek's MIT LICENSE; no new code comments; CHANGELOG corrected. Review farm run on 1e2106e63a: 204 of 204.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 04:51:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 99b55300d2 feat(creator): pin DeepSeek Harness creator files at 0.2.2-rc.1 with notices (CR-00)
Byte-for-byte copies, so Codewhale hosts DSH's own Creator text and tool code
rather than a rewrite: the 33 files of the four dsh-agent-preset skills, the
dsh-plugin-manager tools.js and dsh-tool-cordis index.js. Each pin has an
UPSTREAM.json naming package, version, upstream path and SHA-256 with
verbatim: true, plus the MIT licence text and a THIRD_PARTY_NOTICES section
naming the four packages. .gitattributes keeps LF so Windows checkouts hash
the same.

The dsh-sandbox approveEscalation excerpt is recorded as pending in
UPSTREAM.json: that package is not in the unpacked app and needs the
published tarball.

Checks: node --test creator-pin.test.mjs 3/3; cargo nextest run -p
codewhale-skills (farm) includes dsh_creator_pin 3/3, whole crate 188/188;
diff -r against the source skills folder empty.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 04:51:32 -08:00
CodeWhale BotandClaude Fable 5.2 1e2106e63a docs(plugins): say how a host service is asked for, granted, shared and refused, and who approved (CW-8012)
EXTENSIONS.md gains the host services section with who may approve, the
reach of each approver, that the agent's mark is a label it can remove
in Full Access, and the per-kind sharing rule. The CHANGELOG line says
plainly that Codewhale 0.10.2 turns plugins off on a state file in which
a plugin was reviewed or turned on by this release.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:21:41 -08:00
CodeWhale BotandClaude Fable 5.2 d1ea4bcecc feat(extension-host): a broker service is given only while every loaded plugin holds the same reviewed grant (CW-8012)
The global granted list is gone. A broker ticket is minted, and
redeemed, only when the service is offered by this build, the caller is
the current plugin-tier owner with a reviewed authority, no plugin
activation loaded into the shared host process has since left it, and
every live plugin-tier owner holds the service in its review record.

Plugins share one host process and loaded code cannot be unloaded, so
the rule counts what the process has loaded: once an owner is revoked,
superseded, failed or faulted, no service is given until that process
exits. The host is then retired by the existing planned restart when
idle, and reconcile loads only what is turned on. A built-in module
never gets a broker ticket.

Mint reads the owner record, which carries the grant read from disk when
the owner began. Redeem checks the rule again and re-reads the persisted
record of every loaded plugin, repeats that every 50 ms while the arm
runs, and once more before the answer is released, so a plugin turned
off, revoked or reviewed again from any process loses the service for
tickets already minted and for a call in flight.

A plugin that is refused gets one answer, not_granted, whether its own
review lacks the service or another plugin's does. The plugin that
withholds a service is named in host diagnostics only. A spent broker
ticket is typed limit instead of ticket_refused.

Limits shared by the host: a ticket revoked in the last 10 seconds is
refused but no longer counts toward the burst that ends the host, and
one cap of 50 nested core calls per tool call now covers core/call and
broker arms together.

The reconcile scan reads the reviewed record once per plugin (approver
and grant). An owner whose record says the agent approved it is not
loaded into the reviewed host: it fails closed with one diagnostic that
asks for a person's review. So the owner registry holds only the
reviewed cohort and its sharing rule, departed flag and withheld report
are that cohort's; agent-approved plugins get their own host later.

Checks (Boat farm, on this tree): nextest plugins + extension_host 427
run, 427 passed, 6981 skipped; clippy plugins and engine --all-targets
-D warnings exit 0; check app-server and tui --all-targets exit 0.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:21:37 -08:00
CodeWhale BotandClaude Fable 5.2 bb07a62382 feat(plugins): a review records the host services a plugin may use, who approved and who turned it on (CW-8012)
A plugin asks for host services in its manifest
(capabilities.host_services, the seven broker service names). The list
is part of the inventory and enters the capability hash only when it is
not empty, so every bundle that asks for nothing keeps its hash and its
review, and a bundle that changes what it asks for needs a new review.

A review grants: TrustReceipt gains version, approved_by and
host_services. trust_reviewed is the one writer. It takes the token of
the service review that was shown and recomputes that review under the
state lock; a different token refuses and records nothing. trust(name)
records a person in the terminal with no grant.

approved_by is one tagged shape: a person (via terminal, app or an
approval card), the agent (posture full_access, auto_review or
command_line, with the project and, for the first two, the thread, turn
and tool call), or the command line. PluginApprover::command_line gives
the agent's record when the calling process carries the agent's mark and
the command line's otherwise. An agent approver is refused without a
review token, without its call identity, or for a project other than
the registry's own, before anything is staged. enabled_by beside
enabled records who turned a plugin on and is cleared when it is turned
off or reviewed again. No per-service state is stored.

Cohorts: a record approved by the agent belongs to that project's agent
cohort, every other record to the reviewed cohort. The review lists as
peers only records of the same cohort, and its token covers the cohort.
A record approved by the agent is active only in the project it names
(LoadedPlugin::outside_reach, one more term in active()).

A grant is read back only for the exact reviewed content: enabled, same
generation and hashes, receipt version 2, every granted name among the
asked names, and the capability hash recomputed from the recorded
inventory. A receipt written before this change grants nothing. A
built-in review carried across an upgrade keeps the older receipt shape
when its prior review recorded no approver.

HOST_SERVICE_INJECTS is the one table from a DSH inject name to a host
service, and it is empty: this build offers nothing.

v0.10.2 refuses unknown fields in the state file. A test reads this
build's files through copies of v0.10.2's types: readable until a plugin
is reviewed or turned on here, then refused (unknown field version or
enabled_by).

Checks (Boat farm, Linux x86_64, on this tree): cargo nextest run -p
codewhale-plugins -p codewhale-engine --lib with the plugins package and
extension_host tests: 427 run, 427 passed (225 plugins, 202 engine),
6981 skipped. cargo clippy -p codewhale-plugins -p codewhale-engine
--all-targets -D warnings: exit 0. cargo check -p codewhale-app-server
-p codewhale-tui --all-targets: exit 0. Local: cargo fmt --check and
scripts/preflight.sh --check pass.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 02:21:32 -08:00
CodeWhale BotandClaude Opus 5.5 63acfcc807 docs(sandbox): say what a Fleet worker reads and when the launcher check holds; changelog line for the launcher (CW-8021)
From the third review (artifacts/queue-dispatch-20261111/CW-8021-S1/REVIEW-r3.md,
verdict land). Finding 1: docs/SANDBOX.md said a Fleet local worker reads only
the default managed path; it reads the user config's managed_config_path and
then the default path, so a user config can still point a worker at a missing
file (forwarding the variable to workers is a follow-up before slice 2).
Finding 4: the runtime info route reads the live configuration; a check made
once at start does not cover a later reload. Finding 2: the CHANGELOG line now
says a managed configuration named by the variable applies on every load and
a plugin-declared provider fails to load under it.

Checks: scripts/preflight.sh --check OK; agent docs regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:27:34 -08:00
CodeWhale BotandClaude Sonnet 5.5 abc81f25a6 fix(config): read the host-named managed file before the user file's path (CW-8021)
apply_managed_overrides took its path from config.managed_config_path, which
the environment override sets only after the provider identity resolves. A
user file whose provider is registered later by a provider plugin returned
early, so CODEWHALE_MANAGED_CONFIG_PATH was skipped and the launcher vanished
without an error. The variable is now read first (managed_path_from_env),
then the user file's path, then the default. Test: the late-provider load
either keeps the launcher or fails, never succeeds without it.

Also asserts sandbox.kind and sandbox.tool_launcher in the runtime-info test
and documents the sandbox object in RUNTIME_API.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:24:25 -08:00
CodeWhale BotandClaude Fable 5.2 c4db3b438f chore(sandbox): record the launcher mask checks in the blocking-call budget (CW-8021)
The bubblewrap command builder gained two synchronous filesystem reads
for the tool launcher: one `metadata` per ancestor to decide whether the
launcher's user can search a directory, and one `canonicalize` per denied
path before lifting its mask. Both run only when a launcher is configured
and sit in the same synchronous builder that already canonicalizes its
roots, so the budget for crates/sandbox/src/bwrap.rs rises from 1 to 3
`std::fs` sites instead of moving the builder off the calling thread.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 01:24:18 -08:00
CodeWhale BotandClaude Fable 5.2 ee32ec12be fix(sandbox): accept tool_launcher only from the host-named managed file (CW-8021)
Review repair for the tool launcher key.

- The launcher is read only when the managed file is the one named by
  CODEWHALE_MANAGED_CONFIG_PATH or the default managed path. A sealed file
  that a user config reaches through `managed_config_path` is refused, so
  the code now matches the error text and docs/SANDBOX.md.
- The sandboxed `sandbox_mode` that must sit beside the launcher is read
  from the managed file itself. A user config's `sandbox_mode` no longer
  satisfies it.
- A project config that sets `tool_launcher` stops the launch with a
  project-config error instead of being silently dropped by the overlay
  whitelist.
- docs/SANDBOX.md states all three, and tells a host that relies on the
  launcher to set CODEWHALE_MANAGED_CONFIG_PATH and check
  `sandbox.tool_launcher` in runtime info: without the variable a user
  config can point `managed_config_path` at a missing file and the Engine
  starts with no launcher.

Tests: one runtime test for the path refusal and the managed-only
sandbox_mode, one TUI test for the project refusal.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 01:24:16 -08:00
CodeWhale BotandClaude Opus 5.5 2652f60607 feat(sandbox): run tool commands as another user through a managed-only launcher (CW-8021)
A host that keeps tool commands away from the Engine's own files can now
name a `tool_launcher` argv prefix (for example `sudo -n -u <user> --
<exec-wrapper>`) in its managed configuration. The sandbox manager puts it
in front of bubblewrap, so the command becomes
`<launcher...> /usr/bin/bwrap ... -- <program>`: the uid change happens
before confinement, which is the only order that works, because every
unprivileged confinement sets no-new-privileges and a setuid launcher
inside it cannot switch user. Without the key nothing changes.

Config (crates/runtime): the key is `#[serde(skip)]`, so no config
document can set it. `apply_managed_overrides` reads it from the raw
managed file, beside `strip_external_credential_consent`, and refuses it
on non-Linux, in a managed profile, when it is not an absolute-program
argv, when the Engine's uid can replace the file through it or any
non-sticky writable ancestor, or when the managed `sandbox_mode` is not
`workspace-write` or `read-only`. A user config or user profile naming it
is a load error that points at the managed layer.

Sandbox (crates/sandbox), per the P0 review's conditions:
- Fail closed. With a launcher, `try_prepare` refuses instead of running
  unwrapped: a policy that skips the sandbox (`danger-full-access`,
  `external-sandbox`), `prefer_bwrap = false`, a failed launcher probe or a
  non-Linux host. `prepare` keeps its infallible shape for callers that
  cannot return an error (prepare_runner, the extension host) and turns a
  refusal into a process that prints the reason and exits 126.
- Probe through the launcher. `bwrap::probe_launcher` runs the launcher
  once for its uid and groups, then one wrapped `/bin/true` with the deny
  masks, and caches success only.
- Masks for the launcher's user, on every command. bwrap running as that
  user cannot create a mask under a directory it cannot search ("Can't
  mkdir ... Permission denied"), and the built-in deny list names such
  paths on a multi-user host. Each denied path is masked at the highest ancestor the
  launcher's user cannot search (the P0 review's R4 rule); writable roots,
  protected descendants, extra roots, device roots and readable paths it
  cannot reach are dropped. Deny exceptions are not applied.
- Private /tmp. Under a launcher the host /tmp and TMPDIR are not bound;
  each command gets a fresh tmpfs, so the tool user cannot leave files for
  the Engine's user.
- PTY: `spawn_pty` finds the bwrap element and the first `--` after it,
  because the launcher carries its own `--`.

Escalation clamp (crates/engine/src/tools/shell.rs): before this change
an approved `danger-full-access` escalation turned a managed
`workspace-write` call into DangerFullAccess, which the manager runs
unwrapped. Under a launcher the Bash tool now refuses that escalation
before the approval prompt, and the manager refuses it at execution.

Wiring: the key travels with the deny-list wiring in
runtime_threads/manager.rs, core/engine.rs (EngineConfig), the TUI
`sandbox run` command, exec_agent.rs and frame.rs (one EngineConfig line
needed to compile). `GET /v1/runtime/info` gains
`sandbox: { kind, tool_launcher }`.

Network (D2-A, Engine side only): a managed `sandbox_network_access =
true` with managed `sandbox_mode = "workspace-write"` already yields a
networked workspace-write policy under Ask; a test now pins that. No
default changes.

Known limits are written beside `SandboxManager::set_tool_launcher` and
in docs/SANDBOX.md "Running tools as another user". A real uid change is
not proved here: the farm runs as one user. The `/usr/bin/env` launcher
test proves the prefix, NoNewPrivs and the workspace write.

Checks (farm, Linux x86_64):
- cargo test --locked -p codewhale-sandbox -p codewhale-runtime:
  runtime 918 passed, 0 failed, 2 ignored; sandbox 106 passed, 0 failed
  (rerun on the final sandbox source: 106 passed; the /usr/bin/env
  launcher test ran, not skipped).
- cargo test --locked -p codewhale-engine --lib (authority tests, sandbox
  hint tests, launcher clamp): 22 passed, 0 failed.
- cargo test --locked -p codewhale-app-server --lib runtime_info, after
  rebasing onto 34a29a0f2a: 8 passed, 0 failed.
- cargo clippy --locked -p codewhale-sandbox -p codewhale-runtime
  -p codewhale-engine -p codewhale-app-server -p codewhale-tui
  --all-targets -D warnings: clean (before the rebase, which applied
  without conflicts).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:24:10 -08:00
CodeWhale BotandClaude Opus 5.5 3791637a39 chore(changelog): record receipt token counts and the terminal cost line, tokens only for threads, and sync the TUI changelog slice (CW-8032)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:08:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 cdf6dff9cf fix(receipts): a thread receipt carries tokens only, with no provider-call count (CW-8032)
A Runtime thread's receipt counted turns and printed them as provider calls,
which they are not. thread_tokens no longer sets the count, and both
docs/RECEIPTS.md samples drop it. The opening sentence now says what is true
for this change: tokens on every receipt, the cost line on terminal sessions
only; a thread receipt's cost follows in a later slice.

Regenerated web/data/agent-docs.generated.json.

Checks, rebased on origin/main fc78dc86d8 (Boat farm, Linux x86_64, local-test
evidence only): receipts::tests, metrics::, debug:: lib tests passed 43, 3, 28,
23 and 106 across the five crates, 0 failed, 0 ignored; clippy --all-targets
-D warnings on the same five crates exit 0. Locally: cargo fmt --all --check
and scripts/preflight.sh --check OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:08:08 -08:00
CodeWhale BotandClaude Sonnet 5.5 735469ffc1 feat(receipts): carry token counts and the /cost line; count saved-session usage in metrics (CW-8032)
A receipt gains an additive usage section on codewhale.receipt/v1: provider
token counts and the report /cost prints, built by the same formatter
(cost_report) so wording and numbers cannot drift. An unpriced route reads
"Estimated total: unknown" with its reason; an amount appears only when a
turn had an authoritative price. A single-turn receipt leaves usage out and
says so; a Runtime thread receipt carries tokens and points to the usage
endpoint for cost.

codewhale metrics now counts per-call usage saved in terminal sessions on its
own line instead of saying none was recorded.

The /cost key map and replacement helper move from the TUI into the engine so
the CLI receipt renders the same English text. docs/RECEIPTS.md (and zh_hans)
say where cost lives and name the terminal-session gaps next to the example.

Checks (Boat farm, Linux x86_64, local-test evidence only): receipts and
metrics lib tests passed 43, 3, 28, 23 and 106 across the five crates, 0 failed.
web check-facts OK after regenerating agent-docs.generated.json.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 01:08:04 -08:00
CodeWhale BotandClaude Fable 5.2 3020f4306b feat(extension-host): fill the dsh/broker terminal arm over the existing terminal tools (CW-7994)
The terminal arm serves six typed methods: list, run, send, wait, cancel
and reset. Any other method is an unknown_method refusal and unknown fields
are invalid_input. Nothing is passed through.

The owner stays tools/terminal_session. run, send, wait, cancel and reset
are each one nested core call on the existing terminal/* tool through the
invocation's own context, so planning, deny lists, the approval card, the
live posture, a child agent's grant, the per-invocation call cap and the
receipt on the tool result are the ones a core/call already has. list is a
read of the same registry, scoped to the invocation's workspace and thread.

A plugin's terminals are named dsh:<plugin id>:<name> inside the thread's
own owner, so a plugin cannot name the model's shells or another plugin's,
while the thread's existing terminal routes, resize, kill, delete-thread
cleanup and the restart instance id keep working on them unchanged.

Before the card, the arm refuses when the caller's shell policy or deny
list would refuse the direct tool (re-read from the live posture), when the
named terminal is not this plugin's, and when the plugin already holds 8
terminals in the session. Names, commands, input and timeouts are bounded.
Registry reads run on the blocking pool behind a four-permit gate and stop
waiting when the call is cancelled. A call whose cancel has fired does
nothing. Gate refusals reach the plugin as a short typed refusal; the
detail goes to the log. On platforms without the terminal owner every
method answers not_available after decoding.

Not reachable in production: no service is granted, the adapter
(src/dsh/terminal.ts) is not registered and nothing is advertised.

Known limits: list leaves a log line, not a receipt on the tool result
(arms cannot add one). A plugin's shells are not ended when the plugin is
disabled or revoked; they end with the thread. A failed command returns
its output without the exit code. No resize, kill or non-consuming read
for the plugin itself.

The two seam tests that expected every stub to answer not_available now
skip the terminal arm as they skip the network arm.

Checks (Linux farm): cargo nextest run --locked -p codewhale-engine --lib
extension_host: 191 run, 191 passed, 6979 skipped, including the four new
terminal tests. cargo clippy --locked -p codewhale-engine --all-targets
-- -D warnings: clean (run before a two-line test-only change). Extension
host: typecheck and build clean, node 451 tests 444 pass 7 skipped, bun 451
tests 445 pass 6 skip.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 01:04:55 -08:00
CodeWhale BotandClaude Opus 5.5 5a352597ad fix(deps): move rustix to 1.1.6 for RUSTSEC-2026-0486 (xattr functions mark unwritten bytes as initialized)
cargo-deny's advisories job failed on ci/coord-push6 (run 42991087859) with
RUSTSEC-2026-0486, unsound, against rustix 1.1.4, which main also pins. The
advisory's solution is rustix >= 1.1.6. `cargo update -p rustix --precise 1.1.6`
also re-resolved windows-sys for nine crates that share it (colored, errno,
fd-lock, nu-ansi-term, terminal_size and others), each within its declared
range. Lockfile only.

Checks: hosted CI on this exact commit (cargo-deny, Windows, macOS, Linux).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 01:00:48 -08:00
CodeWhale BotandClaude Opus 5.5 0fb6595178 chore(changelog): record the ACP client port onto the official SDK and sync the TUI changelog slice (CW-8044)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:54:41 -08:00
CodeWhale BotandClaude Fable 5.2 8b55f157a4 test(acp): the conformance check names the kept session/set_model path (CW-8044 ACP-SDK-S2)
The client still sends session/set_model to an agent that offers only the
pre-v1 models object. The check never saw that frame, so the deviation was
kept without being listed. A third leg drives the fixture agent with a plan
flag that offers the old models object, applies the model through it, and
allowlists the two frames that result: the client's session/set_model request
and the fixture's models field in its session/new response.

The two existing entries stay: the options fixture sends two fields that
exist only behind unstable schema features, and each entry names its feature.

Check on Linux x86_64: the_acp_client_and_its_fixture_agents_speak_official_v1_shapes
passed in the same 27-test run as the port.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:54:24 -08:00
CodeWhale BotandClaude Fable 5.2 f194324c1e refactor(acp): drive external agents through the official ACP SDK (CW-8044 ACP-SDK-S2)
The Engine's one ACP client keeps its whole public API and replaces only the
wire. Each client now runs one named connection thread with a current-thread
runtime that drives the official agent-client-protocol v1 client over the
worker's pipes, behind the shared bounded line reader. The fleet host still
owns the process; the SDK is given pipes only, and no process, stdio or
unstable feature is enabled. The caller's thread keeps its loop: one request
at a time, the same deadline and the same 25 ms startup-abort poll, now over
an event channel instead of raw lines.

What we send is built from the official types (initialize, session/new, load,
resume, set_config_option, set_mode, prompt, session/cancel, the permission
outcome and the elicitation decline). session/set_model stays hand-built: it
is a pre-v1 method with no official type, sent only to an agent that offers
the old models object. What an agent sends is still read as JSON, so legacy
fields keep working.

Every handler and response callback on the connection thread returns Ok and
reports failure to the caller as an event or through the responder, because
an Err there ends the whole connection. A request is consumed by its response
callback and never dropped, so a missed deadline sends nothing to the agent.

Wire changes: request ids are UUID strings; fields at their defaults may now
appear; session/resume no longer sends an empty mcpServers; a malformed
response from an agent is dropped, so its request fails at the deadline; a
malformed line that is not a response is answered with an id-null -32600.

Behavior changes beyond the wire:
- Sign-in is detected by the -32000 error code only, no longer by any error
  whose text contains "auth".
- A permission request the agent withdraws with $/cancel_request is answered
  cancelled, never with a selection. The handler is not interrupted; that
  needs a change to the handler interface.
- An oversized or non-UTF-8 line ends the connection with the shared reader's
  refusal text.
- An agent's error text now includes its string data, bounded.

Tests. invalid_acp_envelopes_and_protocol_versions_are_refused changes: the
case with no jsonrpc member and the case with neither result nor error now
fail at a 3 s deadline instead of at once, because the SDK drops a malformed
response; the protocol version 2 case is unchanged. Added: a line over 8 MiB
ends the connection unparsed; another session's update is ignored and its
permission request answered cancelled; a withdrawn permission request is
answered cancelled; a missed deadline sends no $/cancel_request and ids are
strings; and a runtime test that an agent ignoring session/cancel is stopped
after the 10 s grace with the killed reason, using a new fixture plan flag
(the runtime launches the fixture with an empty environment, so the flag is
read from the plan file like the fixture's other switches).

Checks on Linux x86_64: cargo nextest run -p codewhale-engine --lib --test
acp_client_conformance fleet::acp external_agent the_acp_client: 27 run,
27 passed. cargo clippy -p codewhale-engine -p codewhale-app-server --tests:
clean. Windows was not compiled.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:54:20 -08:00
CodeWhale BotandClaude Opus 5.5 fabd6bebc4 docs(plugins): correct the Windows state publish comment the grant fix made false (CW-7956)
The comment above the retry loop in publish_private_state_file still said
every holder of the stable file lets go quickly and that only the rename is
re-attempted. Since fc78dc86d8 a plugin UI grant can hold the file for up to
an hour, and access denied falls back to std::fs::rename with a flush through
a handle opened beforehand. Wording from the review in
artifacts/win-plugin-ui-20261111/REVIEW.md, question 8.

Known limit, recorded here since the fix's commit message omitted it: Windows
10 before 1709 and Server 2016 lack POSIX rename, so the 409 remains there for
as long as a grant lives.

Checks: rustfmt --check on the file. Comment-only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 00:16:25 -08:00
CodeWhale BotandClaude Fable 5.2 5bf5035dd0 chore(changelog): record the released-tag docs for agents and sync the TUI changelog slice (CW-8048, CW-8047)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 00:08:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 26137d8fa2 fix(web): keep one copy of the release snapshot in the worker, carry it through the release workflow (CW-8048 DOCS-AT-TAG, repair 1)
- The snapshot and the topic builder move to lib/release-docs.ts, imported
  only by /llms-full.txt and /docs-markdown. llms.txt reads a small
  agent-docs.release-index.json (tag, present and omitted slugs) that the same
  script writes and the check compares with the snapshot. agent-docs.ts is
  back to the main-branch file only, so the docs pages no longer bundle it.
- release.yml, job sync-release-record only: run derive-release-docs after
  derive-facts, check-release-docs in the prove step, and carry the snapshot
  and its index in the proposed paths.
- derive-release-docs now exits 1 when the tag is not in the checkout and the
  committed snapshot is for a different release than the published one.
- web/maintainer/derived.txt no longer lists the snapshot: that job checks out
  without tags and cannot regenerate it.
- The zh runtime-reference description drops the words for "main branch".
- A source missing at the tag reads "<path> was added after <tag> and is not
  part of this release; the document below is what <tag> has."

Checks: npm test 91 files / 1304 tests, lint, tsc, check:locales, check:docs,
check:facts, build, and scripts/preflight.sh --check pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:08:10 -08:00
CodeWhale BotandClaude Sonnet 5.5 1c13f0bf5a feat(web): serve /llms-full.txt and /docs-markdown from the documents at the released tag (CW-8048 DOCS-AT-TAG)
The machine-readable docs embedded main-branch documents under a label that
said so, so they could describe unreleased work. They now serve a committed
snapshot of the documents as they were at the published release tag.

- web/data/agent-docs.release.json is produced by derive-release-docs.mjs from
  `git show <tag>:<path>` for every registered docs source, and records the tag
  it was made from. Run by prebuild; with no tag in the checkout it keeps the
  committed file and warns, as the release baseline does.
- check-release-docs.mjs (in check:facts and preflight --check) fails when the
  snapshot tag differs from the published release read by facts-lib, when a
  hash does not match, when a registered topic is missing, and, if the tag
  resolves, when the snapshot differs from the tagged source.
- data/agent-docs.generated.json and its check are unchanged and still feed
  the docs pages.
- A topic with no source at the tag is omitted from the routes and the index.
  A topic with some sources keeps them and says which file does not exist at
  the tag: today only docs/RUNTIME_ROUTES.md.
- The runtime reference description no longer says "main branch".

Checks: npm test 91 files / 1302 tests, lint, tsc, check:locales, build, and
scripts/preflight.sh --check pass.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-11 00:08:06 -08:00
CodeWhale BotandClaude Sonnet 5.5 1606a4bccf fix(app-server): discard a dead socket owner's record only when the store this start selected is gone (CW-8047)
After a hard kill, the Engine's socket owner record can outlive both the
process and its data directory. Every later start then failed with
"Failed to resolve ... tasks/runtime".

Host startup now passes its selected store into the owner probe. The
record is retired only when all of these hold: the owning process is
dead, the record's data directory equals the selected store's root in
the form RuntimeStoreBinding::for_store_dir records
(checked_runtime_store_root, which creates nothing), and that root is
definitively NotFound. The only path inspected is the selected store's
own; the recorded path is compared, never followed. A record naming
another store, or the selected store while it still exists, keeps the
existing refusal and stays on disk, which is what the test
stale_publication_cannot_select_another_store_or_execution_scope
protects.

admit_runtime_host passes the store it already holds: one call-site line
in runtime_api.rs.

The regression probes first with a different selected store while the
named store is gone and requires the record to stay, then with the named
store and requires the discard.

Known limits. For a store that is gone, the root can only be normalised
lexically, while a host records the symlink-resolved root. A selected
store spelled through a symlink (a home under macOS /tmp or
/var/folders), one spelled in another letter case, and a Windows
verbatim root do not match, so those starts keep the existing refusal.
A store that is only temporarily absent, such as an unmounted volume,
now gets a first start with an empty store, as it already does after a
clean stop; no store is deleted.

This squashes the first change and its repair after review.

Checks (Linux x86_64 build farm, cargo nextest, CARGO_INCREMENTAL=0,
RUST_MIN_STACK=8388608):
- run 20261111T063638Z-stalefix-nextest-fc68fe: 58 passed, 0 failed
- control with the previous probe behaviour, run
  20261111T064458Z-stalefix-control-523e53: 16 passed, 1 failed (the
  regression, as intended)
- clippy for the crate with CI's flags, run
  20261111T070931Z-stalefix-clippy2-ba438c: exit 0
- independent review rerun on a later base, run
  20261111T073145Z-rvstale2-nextest-216f38: 58 run, 58 passed
Not run: Windows, macOS, hosted CI, an installed binary.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:45:37 -08:00
CodeWhale BotandClaude Fable 5.2 b2ef69d52f chore(changelog): record the extension host re-review and sync the TUI changelog slice (CW-8000, CW-7941)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:26 -08:00
CodeWhale BotandClaude Fable 5.2 ac3f62967e docs(acp): the runtime API reference says which tools an ACP session can use (CW-7941)
The trusted local ACP profile now offers reviewed plugin tools and the tools
of MCP servers configured in Codewhale, under the same permission setting.
The reference still said MCP was unavailable on this transport, which
contradicted docs/EXTENSIONS.md. Client-supplied MCP servers stay unavailable.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:25 -08:00
CodeWhale BotandClaude Fable 5.2 5b034e9e39 test(acp): read the plugin's workspace from its tool result in the ACP journey (CW-7941)
The journey looked for the workspace path as a substring of the frames
re-serialized as JSON. A Windows path contains backslashes, which JSON
escapes, so that substring can never match there, and the three-OS suite runs
this test on every landing. The test now parses the plugin's completed tool
result, takes its workspace value and compares it with the session's
workspace after canonicalizing both, so it also holds where the temporary
directory is reached through a symlink or a short name.

Checks on the stack tip (farm, Linux x86_64, cargo nextest; run
20261111T062016Z-acpplugland-tests-74e101: 97 run, 97 passed, 0 failed):
- acp_journey: 2 passed, 0 failed
Not run: Windows and macOS, which are the platforms this change is for.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:24 -08:00
CodeWhale BotandClaude Fable 5.2 aa3d423546 fix(acp): a child engine gets no MCP tools from the ACP tool build (CW-7941)
The ordinary tool build drops MCP tools for a child engine. The ACP build now
does the same: for a child it adds no MCP tool to the catalog or to the turn's
allow list. No child engine runs an ACP turn today (children use the Child
profile and ACP turns offer no spawn tool), so this closes a path that cannot
be reached yet and keeps the two builds from drifting apart.

acp_tool_build_with_mcp becomes pub(super) so the child-host tests can call
it. The new test child_engine_gets_no_mcp_tools_from_the_acp_tool_build hands
the same live MCP tool to a root ACP engine (in the catalog, on the allow
list) and to a child engine (in neither).

Checks on the stack tip (farm, Linux x86_64, cargo nextest; run
20261111T062016Z-acpplugland-tests-74e101: 97 run, 97 passed, 0 failed):
- codewhale-engine tests matching acp, host_profile or child_host:
  40 passed, 0 failed, the new test among them
- cargo clippy --workspace --all-targets --all-features --locked (CI flags):
  exit 0, no lint output (run 20261111T062101Z-acpplugland-clippy-4c7ea7)
Not run: the new test against the build without this change. It asserts on
the three values the change alters (the MCP names, the catalog and the allow
list), and the root half shows they are set when the engine is not a child.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 3ab0c48182 feat(acp): lift the ACP profile's blocks on extension and MCP tools (CW-7941)
- engine.rs: the ACP branch calls the async acp_turn_tool_build.
- runtime_threads/manager.rs: an ACP host no longer force-disables the mcp feature.
- tools/registry.rs: enforce_tool_authority admits extension-host tools.
- turn_loop.rs, tool_execution.rs: MCP tools are not refused for lacking a registry spec in an ACP turn.

Checks on the stack tip (farm, Linux x86_64, cargo nextest, CARGO_INCREMENTAL=0,
RUST_MIN_STACK=8388608; run 20261111T062016Z-acpplugland-tests-74e101:
97 run, 97 passed, 0 failed):
- acp_journey: 2 passed, 0 failed
- acp_conformance: 1 passed, 0 failed
- acp_client_conformance: 1 passed, 0 failed
- single_turn_loop: 14 passed, 0 failed
- codewhale-app-server tests matching acp_server: 39 passed, 0 failed
- codewhale-engine tests matching acp, host_profile or child_host:
  40 passed, 0 failed
- cargo clippy --workspace --all-targets --all-features --locked (CI flags):
  exit 0, no lint output (run 20261111T062101Z-acpplugland-clippy-4c7ea7)
Not run: Windows, macOS, hosted CI, a real editor.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:21 -08:00
CodeWhale BotandClaude Sonnet 5.5 02f1734ecd feat(acp): attach the extension host and configured MCP servers to ACP sessions (CW-7941)
The ACP tool build now installs the same extension-host plugin tools and the
session's connected MCP tools that an ordinary turn gets, through the same
host attachment and MCP pool; nothing new loads or registers tools. Plugin and
MCP calls reach the editor as ACP tool calls and ask through the permission
setting. initialize advertises the real state under agentCapabilities._meta
(plugins and configuredMcpServers follow the extension_host and mcp flags;
client-supplied mcpServers stay refused). acp_journey drives one fixture plugin
tool and one fixture MCP tool from an ACP prompt.

Landing: the journey test connects through the shared ACP test client
(tests/acp_support) that the schema check introduced, the ACP line in
CHANGELOG.md says what is now attached, and the agent docs are regenerated
for the EXTENSIONS.md sentence. This commit does not build alone: the next
one calls acp_turn_tool_build.

Checks on the stack tip (farm, Linux x86_64, cargo nextest, CARGO_INCREMENTAL=0,
RUST_MIN_STACK=8388608; run 20261111T062016Z-acpplugland-tests-74e101:
97 run, 97 passed, 0 failed):
- acp_journey: 2 passed, 0 failed
  (editor_drives_a_session_through_permission_cancel_and_load,
  plugin_and_mcp_tools_run_as_acp_tool_calls_through_the_permission_dial)
- acp_conformance: 1 passed, 0 failed
- acp_client_conformance: 1 passed, 0 failed
- single_turn_loop: 14 passed, 0 failed
- codewhale-app-server tests matching acp_server: 39 passed, 0 failed
- codewhale-engine tests matching acp, host_profile or child_host:
  40 passed, 0 failed
- cargo clippy --workspace --all-targets --all-features --locked (CI flags):
  exit 0, no lint output (run 20261111T062101Z-acpplugland-clippy-4c7ea7)
- Mac: cargo fmt --all -- --check clean; scripts/preflight.sh --check OK
Not run: Windows, macOS, hosted CI, a real editor.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:20 -08:00
CodeWhale BotandClaude Opus 5.5 9c7cd07bfe feat(extension-host): fill the remote DSH broker with host-presented pairing grants bound to plugin and session (CW-8000)
DSH-13. The remote arm decodes four typed methods (status, pair, clients,
revoke) and refuses any other method as unknown_method. A plugin never
receives a pairing secret or a client token: pair returns only a grant id,
the host reads the secret through RemoteGrants::awaiting_host to present
it to the person, a remote client redeems it once (a replay is refused),
and RemoteGrants::authorize admits that client only for the session the
grant was made in. Grants are bound to plugin id and session, so another
plugin or another session cannot list or revoke them, and they outlive the
invocation that asked, so a later invocation in the same session can
still revoke them. Pairings expire after 10 minutes, clients after 30, and
at most 4 pairings may await the host per plugin and session. No public
listener or tunnel exists: status says so and listen/tunnel are unknown
methods.

Known limits: the Runtime API does not yet read or redeem these grants
(exact request in the review packet), and whether a plugin may use the
remote service at all is not checked before the arm (SEAM-CONTEXT).

The TypeScript side adds the remote adapter (ctx.remote) that forwards
each method through host.broker with a typed payload, plus its
compatibility name and regenerated dist.

Checks, on the landing tip (this commit with the test commit after it):
- Linux x86_64: sh scripts/with-hermetic-test-home.sh cargo nextest run
  --locked --no-fail-fast -p codewhale-engine -E 'test(extension_host)'
  191 tests run: 191 passed (1 slow), 6981 skipped
- Linux x86_64: cargo clippy --workspace --all-targets --all-features
  --locked -- -D warnings -A clippy::uninlined_format_args
  -A clippy::too_many_arguments -A clippy::unnecessary_map_or
  exit 0, no warnings
- extension-host: npm run typecheck exit 0; npm run build exit 0, tree
  unchanged; npm test 451 tests, 444 pass, 0 fail, 7 skipped

Tests under the broker grant check (folded in from the landing):

The remote arm lands on top of the one grant check: broker tickets are
minted only for granted services, and no service is granted outside
tests. Two tests have to say so:

- the remote journey test grants workspace and remote for its own thread
  with TestGrants, as the network arm's test does, so it gets the remote
  ticket it pairs with and the workspace ticket it presents as the wrong
  service;
- the grant test's "the landed stub still refuses" assertion skips remote
  as it skips network: the filled arm decodes first and answers
  unknown_method for the probe method. The remote journey test asserts
  that refusal through the real table.

No production change. The remote service is granted to no plugin, so no
remote ticket is minted and the arm cannot be reached until a plugin's
reviewed record grants it.

Checks, on this commit (Linux x86_64): the engine's extension_host tests
under cargo nextest, 191 tests run: 191 passed (1 slow), 6981 skipped.
The remote journey test, the grant test and the ticket test passed by
name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:41:18 -08:00
CodeWhale BotandClaude Fable 5.2 fc78dc86d8 fix(plugins): replace plugin state on Windows while a plugin UI grant holds the file open (CW-7956)
On Windows, disabling or revoking a plugin whose UI had just been opened was
refused with 409:

    disabled failed for 'panel': failed to atomically and durably persist
    ...\runtime\plugins\state.json: Access is denied. (0x80070005)

A plugin UI grant keeps the plugin state file open for up to an hour, so that
every asset request can prove the state file is still the one the grant was
minted against. The Windows publication replaced the file with MoveFileExW, and
Windows refuses that replacement for as long as any handle is open on the
destination, whatever its share mode. The bounded retry around it was sized for
a scanner or an ACL pass that lets go within about a second, so it ran out and
the refusal surfaced. While any grant was live, every state change of every
plugin was refused, from this process or another: trust, enable, disable,
revoke and the state prune of an uninstall.

When MoveFileExW answers access denied, publication now opens the hardened
temporary and falls back to std::fs::rename, which replaces the destination
with POSIX rename semantics. A reader that shares delete access keeps its
handle on the superseded file and no longer blocks the replacement, which is
what Unix already did. That path does not write through, so the published file
is flushed through the handle opened before the rename, and a flush failure is
reported. A holder that denies delete sharing still gets the bounded retry and
then the refusal, with the stable file untouched.

The grant code is unchanged. After a replacement the grant's handle identifies
the superseded file, its identity check fails, and the grant is stale, as on
Unix.

Not covered: a volume without POSIX rename (FAT, exFAT, some network shares)
keeps the old refusal while a grant is open.

The plugin UI tests now print the response body when disable, enable or revoke
is refused; a failing run used to show only the status.

No released version is affected: the grant arrived after v0.10.2, so there is
no CHANGELOG line.

Evidence
- Hosted Windows before the fix (run 42972872196, the two test changes alone):
  the new registry test failed with "failed to atomically and durably persist
  ...\state.json: Access is denied. (0x80070005)" (3 run, 2 passed, 1 failed),
  and plugin_ui ran 3, passed 1, failed 2 with the 409 above for disable and
  the same error for revoke.
- Hosted Windows with this change on main ecfbdbccaa (run 42978481553):
  codewhale-plugins --lib 200 run, 200 passed, 1 skipped; plugin_ui 3 run, 3
  passed under nextest in the hermetic test home, and "test result: ok. 3
  passed; 0 failed" under cargo test. Run 42975064244 gave the same result on
  main 8ddd9025c0.
- Linux build farm with this change on main ecfbdbccaa: nextest 214 run, 214
  passed (3 plugin_ui, 211 codewhale-plugins). On main 8ddd9025c0, clippy for
  both crates with all targets and all features was clean.
- cargo fmt --all -- --check and scripts/preflight.sh --check pass.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:35:19 -08:00
CodeWhale BotandClaude Fable 5.2 ecfbdbccaa chore(changelog): record the website text for agents and sync the TUI changelog slice (CW-8048)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:04:14 -08:00
CodeWhale BotandClaude Fable 5.2 74ebae88db fix(web): label the main-branch documents in /docs-markdown and /llms-full.txt with the published tag (CW-8048 SHARE-TEXT)
Every /docs-markdown topic except install embeds the repository's
documents as they are on main, and /llms-full.txt carries all of them after
the llms.txt text. Those documents describe behaviour the published release
does not have (a mode set without Operate, and commands and settings that
exist only in source), and nothing in the served text said so: the modes
topic was introduced with the released description and then contradicted
by the document under it.

Each of those documents now starts with the sentence the reference pages
already show for a main-branch document, naming the published tag, and a
link to the repository at that tag. The sentence is the existing
REFERENCE_GUIDE_COPY.scope string; nothing is added to a dictionary. It is
left out when the release is unknown, as on the reference pages.

This labels the statements; it does not remove them. Serving the documents
as they are at the release tag needs the generator in
scripts/docs-sources-lib.mjs to read the tag, which this change does not
touch. A link per document at the tag is not printed because a document can
exist on main and not at the tag.

Test: one more case in lib/llms-txt.test.ts counts the label in the full
text: once for every topic except install. Removing the label fails it.

Checks from web/: tsc --noEmit and eslint are clean; vitest on the four
files that read these texts, 403 of 403.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:04:10 -08:00
CodeWhale BotandClaude Fable 5.2 9f5241604d feat(web): llms.txt gives the release, the install steps and each part's state from the pages' own content (CW-8048 SHARE-TEXT)
An agent asked how to install Codewhale and what it can do today reads
/llms.txt. The file now opens with the locked headline and subhead and says
"The latest release is v{version}." from the published-release object.

The install text is rendered from lib/content/install.ts, the module the
/install page renders: one command per platform and channel, what the
installer adds to PATH and the opt-out, the verify command and its output,
the three first steps, usage counting and how to turn it off, update,
removal, known problems, the other channels and the mirrors. The page's own
conditions pick the wording: the uninstall command appears only when the
published release has it, and the version-free strings are used when the
release is unknown.

The six parts of the product are printed with their state words from
lib/content/product-map.ts and the locked map strings. A row whose computed
state no longer matches the state its sentence was written for prints the
state without the sentence, so a desktop release object flips that line to
"Released" and drops "no public download yet".

/docs-markdown/install.md serves the same install text as a page, in place
of the main-branch install guide, with the facts stamp at its end. Like
/install, it links the full guide at the release tag. /llms-full.txt
carries the new head and that page.

Removed from llms.txt because they contradict the released version or the
copy rules:
- the old identity line and summary sentence;
- the publish date on the release line (the changelog carries it); the date
  the release was read stays, inside the provenance stamp;
- the "Unreleased on main" list of changelog headlines;
- the entries for pages that now redirect (product, runtime, constitution,
  roadmap, feed, digest, community) and for /signin, which the sitemap no
  longer lists;
- the hand-typed states on the /app entry, which now prints the typed
  surface states the page prints.
The install entries use the install page's own description.

Not changed: the routes stay static and read the build's facts, while
/install revalidates against the runtime facts snapshot, so after a new
release the page can name it before the text does, until the next deploy.
The licence, default-model and sandbox lines still read the source tree.

Tests: lib/llms-txt.test.ts is rewritten around invariants (9 tests): the
head and the index, no link to a redirected route, the version equals the
release object and the source version never appears, every statement the
rendered /install page makes from the install content is in both texts,
each map row with its computed state and a fixture desktop release flipping
only the desktop line, no price, plan or date outside the stamp, unknown
stays unknown, and the routes. The three assertions in
lib/facts-invariants.test.ts that quoted the old release line now quote the
new one. Nine single-line mutations of the builders each fail one of these
tests.

Checks from web/: prebuild, check:facts, check:docs (29 topics) and
check:locales pass; tsc --noEmit and eslint are clean; node --test 7 of 7;
vitest 90 files, 1294 of 1294; next build succeeds.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 23:04:05 -08:00
CodeWhale BotandClaude Fable 5.2 b3bc59f70e chore(changelog): sync the TUI changelog slice for the catalog, seam context and offline fix landing (CW-8038, CW-8012, CW-8031)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 22:08:13 -08:00
CodeWhale BotandClaude Sonnet 5.5 02833c4d01 refactor(acp): add the official ACP SDK dependency and share the bounded line reader (ACP-SDK-S0)
Adds agent-client-protocol =5.2.0 (default-features = false; no process,
stdio or unstable_* feature) as a workspace dependency of codewhale-engine
and codewhale-app-server, ground for porting both ACP directions onto it.

Moves BoundedLines, FrameRefusal and is_frame_refusal from the app-server
stdio module into codewhale_util::bounded_lines so the server and the client
directions share one 8 MiB reader. stdio.rs re-exports the three items, so
its call sites are unchanged, and a const assertion keeps the new
MAX_FRAME_BYTES equal to MAX_RUNTIME_IMAGE_BODY_BYTES. The two framing tests
that read the private pending buffer move next to the reader.

Lockfile: 11 packages added, none existing moved. The resolver also
re-pointed Windows-only windows-sys edges and tempfile's getrandom edge;
those edges were restored to the previously locked versions, so the only
removed lines are schemars/indexmap name disambiguation. cargo deny bans and
licenses stay ok with 10 duplicate warnings before and after.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 22:08:10 -08:00
CodeWhale BotandClaude Opus 5.5 445f73b2fa test(acp): check both ACP directions against the official v1 schema types (CW-8039)
Adds agent-client-protocol-schema =1.17.0 (default features off, stable
v1 only) as a dev-dependency of codewhale-app-server and codewhale-engine;
`cargo tree -e normal` shows no runtime path to it.

A shared checker (crates/engine/tests/support/acp_conformance.rs)
deserializes every frame into the official v1 type for its method,
re-serializes it and fails on any key the official type drops (outside
_meta), on a non-v1 method, and on a reply to no outstanding request.
Known deviations live in one allowlist per test; a new deviation or an
allowlisted one that disappears fails the test.

- Server: acp_conformance drives the real ACP frontend through
  initialize, $/cancel_request, error paths, session/new, set_mode,
  set_config_option, a prompt with a tool call, permission and plan tool,
  a cancelled prompt, session/list and session/load with replay, with
  requests built from the official types. 7 allowlisted deviations.
- Client: acp_client_conformance runs fleet::acp::AcpClient against both
  fixture agents through a capture wrapper (acp-tee.mjs) without touching
  the client or fixtures. Our client's frames and acp-agent.mjs are clean;
  acp-options-agent.mjs sends two unstable-only fields (allowlisted).
- acp_journey now shares the harness in tests/acp_support.

No runtime code or wire behavior changes. Findings and the
schema-vs-SDK decision table are in the task artifacts.

acp-tee.mjs starts a fixture agent script as a child and appends both
directions of its newline-framed stdio, in arrival order, to a log file:
"C <line>" for what the client wrote, "A <line>" for what the agent wrote.

The client test calls new_session and reopen_session with REF-09's
identity and additional-directories arguments (empty list).

Lockfile: only the schema crate's packages are added (agent-client-
protocol-schema, bs58, hashbrown 0.12.3, indexmap 1.9.3, schemars 0.9.0,
serde_with, serde_with_macros, time-macros) plus the indexmap/schemars
name disambiguation and the deranged/time feature edges; no existing
package moves. The resolver's re-pointing of Windows-only windows-sys
edges and tempfile's getrandom edge was restored to the locked versions.

Checks on the stack tip with ACP-SDK-S0 (Rust sources and lockfile
identical to farm runs 20261111T052504Z-acpland3-tests-edecfa and
-052503Z-acpland3-clippy-5bd65b; main has since moved in web/ and docs
only; Boat farm, Linux, RUST_MIN_STACK=8388608):
- acp_conformance: 1 passed, 0 failed
- acp_client_conformance: 1 passed, 0 failed (node v24 present, no skip)
- acp_journey: 1 passed, 0 failed
- codewhale-engine --lib fleet::acp::: 6 passed, 0 failed
- cargo clippy --workspace --all-targets --all-features --locked (CI
  flags): exit 0, no lint output
- Mac: cargo fmt --all -- --check clean; scripts/preflight.sh --check OK;
  cargo deny --offline check licenses bans ok, 10 duplicate warnings,
  same set as base; cargo metadata --locked --offline ok

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:08:06 -08:00
CodeWhale BotandClaude Sonnet 5.5 f803886cf2 tui: return the oldest held message to the composer on route activation (CW-8031)
Review repair: rustfmt wrap of the new test line, oldest-first draft pop so a
backlog is not sent out of order, ordering test added, tautological
rejected-key test removed (failed_*_activation rollback tests cover it).

The doc comment on cancel_queued_draft_edit no longer says the draft goes
back where pop_last_queued_into_draft took it from; it goes back at the tail.

Checks (Linux x86_64, on the landing stack):
- cargo test --locked -p codewhale-tui --lib -- route_activation offline_label
  tui::ui::tests::failed_custom_provider_activation
  tui::ui::tests::failed_native_xai_oauth queued
  test result: ok. 38 passed; 0 failed; 0 ignored; 5706 filtered out
- cargo fmt --all -- --check: clean

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 22:08:03 -08:00
CodeWhale BotandClaude Sonnet 5.5 12f917248b tui: connecting a route after "explore offline" ends offline queueing (CW-8031)
choose_offline_explore sets offline_mode, but route activation through
switch_provider only cleared the explore label, so every later message was
held as "Saved for later" until restart. clear_offline_explore_on_route_activation
now also clears offline_mode and returns a held message to the composer; the
rejected-key path (apply.rs) still sets offline_mode and is unchanged.

Checks (Boat farm, RUST_MIN_STACK=64MiB, cargo test -p codewhale-tui --lib,
filters tui::onboarding, route_activation_after_offline, tui::ui::tests::failed_,
provider_, reselecting): 68 passed, 0 failed. Without RUST_MIN_STACK two
failed_*_activation tests overflow the stack on unmodified main too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 22:07:59 -08:00
CodeWhale BotandClaude Opus 5.5 a89c045238 feat(extension-host): fill the dsh/broker network arm with per-host egress and an opaque credential vault (CW-7979)
The network arm decodes four typed methods (fetch, credentials.describe,
credentials.resolve, credentials.set); any other method is a typed
unknown_method refusal and malformed input is invalid_input. No pass-through.

Authority is a NetworkAuthority value: the plugin's reviewed network hosts,
the invocation's ToolContext (its network policy decider) and the credential
vault. BrokerCall carries none of these yet (SEAM-CONTEXT has not landed), so
serve() runs with a withheld authority and every method that would act
refuses with not_available. serve_with() holds the whole behaviour and is
what SEAM-CONTEXT's fields plug into; nothing is advertised to plugins.

fetch reuses the existing owners: the host must exactly match a reviewed
network host; then tools::web::guard::validate_fetch_target applies the
session's deny list and approval posture and the SSRF/DNS guard, and the
connection is pinned to the validated address through
codewhale_net::guard::guarded_reqwest_client_builder (no ambient proxy).
Redirects are followed manually, at most five, only within the requested
origin (scheme, host, port), and each hop is re-validated, so a redirect,
rename or rebinding never widens the grant.

Credentials live in codewhale_secrets under dsh-plugin:<plugin>:<ref>.
resolve and describe return only the handle codewhale-credential:<ref>;
fetch substitutes the secret into header values only after the hop passed
egress, refuses handles in the URL, and scrubs the secret back to its handle
in response headers and body. Logs name plugin, host, ref and status only.

TypeScript: src/dsh/network.ts adds credentialsAdapter (resolve, describe,
set, fetch) and brokeredFetch over AdapterHost.broker, unregistered until
the Rust authority is wired.

core_call_tests.rs: the seam's all-services not_available loop skips
network (its arm now answers unknown_method for "anything"), and one new
regression drives the real TABLE through serve_broker_in with minted
tickets: unknown method, withheld authority, another plugin, another
service's ticket, the core ticket, an ended invocation, a revoked owner.

Checks (farm, Linux x86_64, base 4133ed98ec):
- sh scripts/with-hermetic-test-home.sh cargo test --locked -p codewhale-engine --lib -- extension_host
  test result: ok. 182 passed; 0 failed; 0 ignored; 6975 filtered out
- extension-host: npm run typecheck exit 0; npm run build exit 0, tree unchanged;
  npm test 448 tests, 441 pass, 0 fail, 7 skipped; npm run test:bun 442 pass, 6 skip, 0 fail

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:07:56 -08:00
CodeWhale BotandClaude Opus 5.5 f67d1b49d2 feat(extension-host): broker arms act with their invocation's context, cancellation, gate and grant (CW-8012)
DSH-16 SEAM-CONTEXT. A DSH broker arm used to get only owner, session,
service, method and input. It now acts inside the invocation whose ticket
was redeemed, and nowhere else. This is additive: the Arm type,
BrokerFuture, every serve signature and the seven dsh_<service>.rs stubs
are unchanged.

- BrokerCall gains one field, `context: BrokerContext` (defined in
  core_call.rs and re-exported from dsh_broker):
  - tool(): the invocation's own ToolContext, captured at begin_scoped,
    with its nested-call gate removed. It carries the workspace, sandbox,
    approval and trust posture, disallowed tools and session objects.
    Nothing in the host's params feeds it.
  - cancel(): the call's token. It is a child of the invocation's token
    and fires when the host cancels the request, the invocation ends,
    the owner is revoked, the host exits, or serve_broker_in returns.
    So work an arm spawns (processes, tasks) can clean up after itself.
  - core_call(name, input): a nested core tool call through the
    invocation's CodemodeInvoker. It takes the same refusal list, gate,
    origin approval, receipts and 4-at-once cap as core/call, and the
    call's token withdraws it, open approval card included. All brokers
    of one invocation share a cap of 50 such calls. Failures map to the
    typed BrokerRefusal::Denied or ::Refused.
- Grant: dsh_broker::granted(service) is the one check. It is applied
  when broker tickets are minted (only granted services are ticketed, no
  longer all seven), and again in serve_broker_in before the arm runs.
  An ungranted service is refused with the existing NotAvailable,
  identical to an unbuilt arm, so the refusal reveals nothing. The
  production grant list is empty, so no broker ticket is minted today.
  Plugin authors see the same behavior as before: every broker service
  refused. Tests set grants with dsh_broker::TestGrants. The Engine holds
  no per-plugin declaration of the broker services a plugin uses (inject
  lists are checked only by the TS host at activation). The source for a
  per-plugin grant is a coordinator/founder question in the review
  packet.

Known limits: the 50 nested-call cap is separate from the core/call
ticket's 50 uses. cancel() is per broker call, so long-lived resources
(terminals) need an owner-scoped lifetime from their own Runtime owner.
TestGrants is thread-local, so a broker test runs on a current-thread
runtime.

Checks (farm, base 4133ed98ec, these three files shipped):
- with-hermetic-test-home cargo test --locked -p codewhale-engine --lib
  -- extension_host: 181 passed, 0 failed, 0 ignored, 6975 filtered out
  (run 20261111T034028Z-seamctx-test-8d29a5). Five new tests and the
  updated seam test pass by name.
- cargo clippy --locked -p codewhale-engine --all-targets -- -D warnings:
  exit 0, 0 warnings (run 20261111T034030Z-seamctx-clippy-ace2ad).
- bash scripts/preflight.sh --check: preflight: OK (local).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:07:52 -08:00
CodeWhale BotandClaude Sonnet 5.5 501e60d8ce feat(catalog): offer qwen4-max-preview on the Model Studio Token Plan
Adds the id the Token Plan Supported Models table lists to the reviewed
roster, public list and Token Plan picker, both dialects. The vendor page
https://qwencloud.com/pricing/token-plan (verified 2026-11-11, also read by
the founder 2026-11-10) lists qwen4-max-preview as New with Text Generation,
Reasoning Model and Visual Understanding. Default stays qwen3.8-max.

The page publishes no context window, maximum output, tool calling or
thinking control, so none is recorded. The intrinsic row leaves reasoning
unset and the Token Plan transport no longer claims reasoning support, so
neither dialect sends a thinking field: the OpenAI dialect strips every
thinking field for an id in neither Model Studio thinking list
(apply_modelstudio_route_reasoning_controls in chat.rs), and on the
Anthropic dialect thinking_capable is false, so build_anthropic_body sends
neither a thinking block nor output_config, for any effort including off.
Visual Understanding is vendor-stated, so the transport records
image_input as the sibling Token Plan rows do. The Model Studio Messages
test now pins that no thinking field is sent for this id.

The Coding Plan completion rosters were derived from the Token Plan
transports; they now list their seven ids explicitly so the preview is not
offered there, and the Coding Plan rows in PROVIDERS.md, zh_hans/PROVIDERS.md
and config.example.toml say it omits qwen4-max-preview. The Token Plan rows
in both PROVIDERS.md copies say what is and is not recorded for it. Agent
docs and website facts regenerated.

Not exercised against the live endpoint (founder decision, no live calls):
that the endpoint accepts the id on each dialect, its limits, tool calling
and how it thinks are unproven.

Amended at landing per the coordinator's vendor read: reasoning dropped
from the intrinsic row, image input recorded, receipt cites the verified
vendor page.

Checks: seed render --check ok; seed age ok (0.3 days); catalog_models_dev_test 21 tests OK; web check-facts OK; check-docs PASS; check-runtime-routes PASS; preflight --check OK. Farm (Linux x86_64), stack tested on main a37d7fdd27; the rebased tree differs from the tested one only by main's own commits up to 1f9ad30c1f: cargo nextest run -p codewhale-config -p codewhale-models -p codewhale-engine (engine filtered to client::anthropic), RUST_MIN_STACK=8388608, 989 passed / 0 failed (config lib 834, configured_models 9, engine client::anthropic 38, models 108), run 20261111T043921Z-catalog8038final-test-193af4; CI-style clippy -D warnings --all-targets --all-features for config, models and engine, run 20261111T043925Z-catalog8038final-clippy-40cd61, exit 0, no warnings. The extended modelstudio_messages_body_requests_thinking_with_budget passed in that run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 22:07:49 -08:00
261 changed files with 21200 additions and 3591 deletions

No files matched your search

+6
View File
@@ -65,6 +65,12 @@ crates/engine/extension-host/dist/** text eol=lf
crates/engine/tests/fixtures/extension_host/** text eol=lf
crates/engine/extension-host/src/dsh/dsh-message-compat.js text eol=lf
# DeepSeek Harness creator pins are compared byte for byte against recorded
# SHA-256 values (UPSTREAM.json) and embedded with include_bytes!(); a CRLF
# Windows checkout must not rewrite any of them.
crates/skills/assets/dsh-creator/** text eol=lf
crates/engine/extension-host/src/dsh/upstream/creator/** text eol=lf
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
*.rs text eol=lf
+4
View File
@@ -612,6 +612,7 @@ jobs:
set -euo pipefail
node web/scripts/sync-latest-release.mjs
node web/scripts/derive-facts.mjs
node web/scripts/derive-release-docs.mjs
- name: Prove the record passes the web fact gates
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -620,6 +621,7 @@ jobs:
node web/scripts/sync-latest-release.mjs --check
node web/scripts/check-cloud-facts.mjs
node web/scripts/check-facts.mjs
node web/scripts/check-release-docs.mjs
- name: Propose the record to the default branch
env:
TAG: ${{ needs.resolve.outputs.tag }}
@@ -633,6 +635,8 @@ jobs:
docs/cloud-facts/stable.json
web/lib/facts.generated.ts
web/data/agent-docs.generated.json
web/data/agent-docs.release.json
web/data/agent-docs.release-index.json
web/data/features.generated.json
)
if git diff --quiet -- "${paths[@]}"; then
+19 -1
View File
@@ -7,12 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` is now applied on every load; a configuration whose provider comes from a plugin no longer loads with the managed file skipped, it fails to load instead.
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) unmodified, with their reference and template files. A bundled skill can now carry files: `load_skill` prints the skill's base directory and a short Codewhale host-notes block after DSH's text. A copy you edited is never overwritten on upgrade. Source-level only: the Creator plugin switch and agent entry that make these useful land in later slices.
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) bundled, unmodified, with their reference and template files and DeepSeek's MIT notice beside each. They stay hidden from the model until Creator mode. A bundled skill can now carry files; `load_skill` prints its base directory and a short Codewhale host-notes block, and a file you edited is never overwritten on upgrade.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the page says several agents share a task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other tools. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Conversation modes (Engine):** `GET /v1/modes` lists two built-in modes, `standard` (default) and `creator`, each with `id`, `name`, `description`, `source` and `available` (plus `reason` when not; Creator is listed unavailable, "not ready in this build", until it loads its own tools, and then also when no Node.js or Bun plugin runtime is found). A thread's mode is `agent_preset`, set on `POST /v1/threads` or by `PATCH /v1/threads/{id}` until the thread has a turn, saved on the thread record and the terminal session file and shown in `GET /v1/threads/summary`; records without it load as `standard`. An unknown or unavailable id, a change after the first turn, or a mode together with another vendor's agent is refused with `error.code` `unknown_mode`, `mode_unavailable`, `mode_locked` or `mode_not_supported_with_executor`. Source only: Creator loads nothing extra yet and no menu uses it.
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the page says several agents share a task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other tools. This replaces the earlier reorganization of the home page, so the dated OrcaRouter offer strip and the community panel whose GitHub numbers filled in from the browser are no longer on it; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the facts row lists many agents on one task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other coding agents. This replaces the earlier reorganization of the home page, so the community panel whose GitHub numbers filled in from the browser is no longer on it, and the dated OrcaRouter offer strip, English only, with its referral disclosure, stays below the first screen until it ends; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Website models and plugins:** /models leads with recently added models, three ways to connect (DeepSeek, first-party providers, any OpenAI-compatible endpoint) and a catalog grouped by maker; Ollama and OpenRouter are listed but no longer featured. /plugins leads with "Everything is a plugin" (credited to DeepSeek Harness) and shows MCP connectors from the connection catalog.
- **Model catalog:** Claude Fable 5.2 (`claude-fable-5-2`) and Gemini 4 Carbon (`gemini-4-carbon`) are in the bundled catalog, and a daily catalog-refresh workflow proposes new first-party models from models.dev as a pull request. The workflow has not run in GitHub Actions yet.
- **Gemini 4 output limit:** Gemini 4 Carbon and Gemini 4 Argon now allow 65,536 output tokens per reply, up from 32,768, matching Google's published limit.
- **Anthropic xhigh effort:** Choosing xhigh effort on an Anthropic model that documents it, such as Claude Fable 5.2 or Claude Opus 4.8, now sends xhigh instead of max, so the model uses and bills the level you picked.
- **Public model list:** The public model list gains GPT-6.1 Terra, Kimi K3.1 Pro (and its high-speed variant), MiniMax M3.1 Flash and Mistral Large 4, each marked unreviewed.
- **Model Studio Token Plan:** Token Plan users can now pick `qwen4-max-preview` on both the OpenAI and Anthropic dialects. It was added from the model table Alibaba publishes on its Token Plan pricing page and has not been exercised against the live endpoint. Alibaba publishes no context window, output limit, tool calling or thinking control for it, so Codewhale records image input only and sends it no thinking switch on either dialect. The Coding Plan does not list it.
- **Website brand and app pages:** the header, footer and favicon use the black-and-white Codewhale mark, keeping the colored whale for character moments. New /app and /docs/account pages say plainly that the desktop app is a development build, the browser app is not open yet, and how sign-in works where it exists. The terminal demo was recaptured from the 0.10.3 source.
- **DSH plugin client review:** the DSH importer now reviews a plugin's client (`dsh.client`) files: the review lists every file the client loads with its SHA-256 and which modules the host must provide, and says exactly why a client stays disabled (for example an npm package that is not in the bundle, or a module loaded by a computed specifier). Plugin UI is still not shown; that needs the Engine client bootstrap.
- **`codewhale exec` prompt input:** `codewhale exec "prompt"` no longer hangs when stdin is an open pipe that sends nothing (it continues after 2 s and says so, and a slow producer can be included by piping the prompt as well), and a flag typed after the prompt in full-width or typographic-dash form is rejected like `--auto` instead of being sent to the model as text. Tested on Unix only; no Windows, hosted CI or real-provider run.
@@ -37,11 +49,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- **Docs for agents match the release:** `/llms-full.txt` and `/docs-markdown/*` now serve the documents as they were at the published release tag, from a committed snapshot that is checked against the published release.
- **Extension host (experimental): trusted plugins ask for review once more.** With `extension_host` turned on, plugins you already trusted are shown for review again after this update, because the capability table they were reviewed against gained the `remote` service. No plugin can use that service yet: none is granted it.
- **Website text for agents:** llms.txt now gives agents the install steps for the released version, the current release and the state of each part of Codewhale, from the same content as the pages. `/docs-markdown/install.md` serves those install steps as a page, and the main-branch documents beside it now say which release they can run ahead of.
- **Website design:** codewhale.net now uses the desktop app's 5.3 light and dark colour roles and Geist / Geist Mono, replacing the warm-paper palette and Shannon Sans; the vendored design copy is synced to 5.3.0 and the whale-road ocean bands are unchanged.
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Plugins, DSH and MCP tools are not yet attached to ACP-driven sessions. A real-editor journey is not yet proved.
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Sessions an editor drives now run the plugin tools (TypeScript and DSH, when the extension host is on) and the MCP servers configured in Codewhale; their calls show as ACP tool calls and ask through the session's permission setting, and `initialize` reports the state under `agentCapabilities._meta.codewhale`. MCP servers an editor passes in `mcpServers` and configured shell hooks still do not run in ACP sessions. A real-editor journey is not yet proved.
- Extension host: the `sessions` and `systemPrompt` services now come from a reviewed DSH adapter instead of inline shims, and `sessions.flush` refuses an empty checkpoint ticket. Session reads and events and the `agent/pre-step` hook are still not available to plugins. (CW-7959)
- Extension host: DSH service adapters now have one ticketed `dsh/broker` request to the core, each ticket is bound to the plugin, the service and the session, and every service (workspace, llm, network, settings, catalog, terminal and remote) refuses with a typed `not_available` until its broker lands. Nothing new is offered to plugins yet. (CW-8012)
- **Plugin host services and who approved:** a plugin can now be granted a Codewhale host service, and only by a review that shows it. The review names each service and the plugins it is shared with, and every review records who approved it: you, the agent (in Full Access, in Auto-Review, or from a shell command carrying the agent's mark), or the command line with `--yes`; who turned the plugin on is recorded too. Plugins share one process, so a service works only while every loaded plugin of the same kind holds the same grant. A plugin the agent approved is active only in its own project and its code does not run beside the plugins you reviewed until you review it. No service is offered yet, and a plugin reviewed before this release holds no grant until it is reviewed again. Once a plugin has been reviewed or turned on in this release, Codewhale 0.10.2 and earlier cannot read the plugin state file and turn every plugin off when run on the same home folder. (CW-8012)
- Fleet member starts are now durable: replaying an operation key after a timeout or restart returns the original worker, and GET /v1/threads/{id}/members/operations/{key} reports the outcome. A member start now answers with `state` `accepted` (200) or `unknown` (202) instead of `started` or `queued`. Verified by local HTTP tests on Linux; a real Engine restart is simulated, not exercised. (CW-7964)
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
@@ -52,9 +68,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
- The install page now gives the exact steps for the released version on every platform, including what the installer adds to PATH, how to turn off usage counting, and how to remove it, and the install command no longer gets cut off on phones.
- After choosing "explore offline" on first run, connecting a provider or Custom endpoint with /provider now takes effect immediately; messages are no longer held as "Saved for later" until restart, and a message held while offline returns to the composer.
- The pet browser view opens with a one-time code instead of the long-lived owner token, so the token no longer appears in the URL or the launcher command line. The code lasts 30 seconds and works once; the owner token itself is still stored in the cookie and connection file and is not rotated. Not run in a real browser.
- On Windows with Caps Lock on, Ctrl+E moves to the end of the line again instead of opening the file tree; the file tree now needs an explicit Shift (Ctrl+Shift+E or Cmd+Shift+E), so a terminal that reports an uppercase E with Ctrl but no Shift flag can no longer toggle it. The Caps Lock key event is simulated from the input library source; no native Windows run.
- External agents installed through npm on Windows (`claude.cmd`, `codex.cmd`, ...) are now found instead of the unrunnable shell shim, `GET /v1/agents/external` says whether an agent answered with an unsupported protocol rather than reporting every failure as no answer, and a hung `--version` or handshake probe no longer leaves the agent's child processes running. Verified on macOS and Linux; the Windows code has never been compiled or run, and no agent row is qualified by this change.
Generated
+169 -25
View File
@@ -19,6 +19,48 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "agent-client-protocol"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32339d83e679c8092ec69d2f18e84448f4acf09b46a753e48f3e9265f920acb1"
dependencies = [
"agent-client-protocol-derive",
"agent-client-protocol-schema",
"anyhow",
"futures",
"futures-concurrency",
"rustc-hash",
"serde",
"serde_json",
"tracing",
"uuid",
]
[[package]]
name = "agent-client-protocol-derive"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05a39fd0d81daab854d25144e82e31006b87c554818beb695f0edb576e9c54c4"
dependencies = [
"quote",
"syn 3.0.9",
]
[[package]]
name = "agent-client-protocol-schema"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fd595be39fc4f9c8170872e5d10d81b44cb6dca44bcc66daba0d34412d4237d"
dependencies = [
"anyhow",
"derive_more",
"serde",
"serde_json",
"serde_with",
"strum",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -601,6 +643,15 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.13.0"
@@ -882,6 +933,8 @@ dependencies = [
name = "codewhale-app-server"
version = "0.10.3"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"async-stream",
"async-trait",
@@ -966,7 +1019,7 @@ version = "0.10.3"
dependencies = [
"chrono",
"jsonschema",
"schemars",
"schemars 1.2.2",
"serde",
"serde_json",
]
@@ -1070,7 +1123,7 @@ dependencies = [
"libc",
"ring",
"rust_decimal",
"schemars",
"schemars 1.2.2",
"semver",
"serde",
"serde_json",
@@ -1123,6 +1176,8 @@ dependencies = [
name = "codewhale-engine"
version = "0.10.3"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"arboard",
"async-stream",
@@ -1194,7 +1249,7 @@ dependencies = [
"rmcp",
"rusqlite",
"rustls",
"schemars",
"schemars 1.2.2",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -1830,7 +1885,7 @@ dependencies = [
"rquickjs",
"rusqlite",
"rustls",
"schemars",
"schemars 1.2.2",
"semver",
"serde",
"serde_json",
@@ -1944,7 +1999,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -2389,6 +2444,9 @@ name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]]
name = "derive_more"
@@ -2468,7 +2526,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -2609,7 +2667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -2712,7 +2770,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -2859,6 +2917,19 @@ dependencies = [
"futures-sink",
]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
dependencies = [
"fixedbitset 0.5.7",
"futures-core",
"futures-lite",
"pin-project",
"smallvec",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -3723,7 +3794,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
"indexmap",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
@@ -3750,6 +3821,12 @@ dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -4156,6 +4233,17 @@ dependencies = [
"quick-error",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -4905,7 +4993,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -5346,7 +5434,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset 0.5.7",
"hashbrown 0.15.5",
"indexmap",
"indexmap 2.14.0",
]
[[package]]
@@ -6078,13 +6166,13 @@ dependencies = [
"async-trait",
"chrono",
"futures",
"indexmap",
"indexmap 2.14.0",
"libc",
"oauth2",
"pastey",
"pin-project-lite",
"reqwest",
"schemars",
"schemars 1.2.2",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -6126,7 +6214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a2ea9adb03b669c933482ff740b683cd36be7cef23891657a8ccb850401d286"
dependencies = [
"convert_case 0.12.0",
"indexmap",
"indexmap 2.14.0",
"proc-macro-crate",
"proc-macro2",
"quote",
@@ -6251,15 +6339,15 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.4"
version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -6315,7 +6403,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -6384,6 +6472,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
@@ -6553,7 +6653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"foldhash 0.2.0",
"indexmap",
"indexmap 2.14.0",
"itoa",
"memchr",
"serde",
@@ -6613,6 +6713,39 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [
"base64 0.23.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serial2"
version = "0.2.38"
@@ -7080,7 +7213,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
@@ -7112,7 +7245,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -7265,6 +7398,7 @@ dependencies = [
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
@@ -7273,6 +7407,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
@@ -7414,7 +7558,7 @@ version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -7447,7 +7591,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
@@ -7461,7 +7605,7 @@ version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -8220,7 +8364,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]
+2
View File
@@ -73,6 +73,8 @@ repository = "https://github.com/codewhale-hq/CodeWhale"
warnings = "deny"
[workspace.dependencies]
agent-client-protocol = { version = "=5.2.0", default-features = false }
agent-client-protocol-schema = { version = "=1.17.0", default-features = false }
anyhow = "1.0.100"
async-trait = "0.1.89"
axum = { version = "0.8.5", features = ["json", "ws"] }
+38
View File
@@ -164,6 +164,44 @@ The host bundle's own copy of these notices is `dist/LICENSES.txt`, generated
from the bundler's metafile; Codewhale writes it beside the bundle it
materialises (`~/.codewhale/extension-host/<sha256>/LICENSES.txt`).
## DeepSeek Harness Creator files (`dsh-agent-preset` skills, `dsh-plugin-manager`, `dsh-tool-cordis`, `dsh-sandbox`) — MIT
Codewhale ships, unmodified, the four authoring skills of
`@deepseek-ai/dsh-agent-preset` 0.2.2-rc.1
(`crates/skills/assets/dsh-creator/`), and keeps byte-for-byte copies of
`@deepseek-ai/dsh-plugin-manager` 0.2.2-rc.1 `lib/types/tools.js` and
`@deepseek-ai/dsh-tool-cordis` 0.2.2-rc.1 `lib/index.js`
(`crates/engine/extension-host/src/dsh/upstream/creator/`).
`@deepseek-ai/dsh-sandbox` 0.2.2-rc.1 is named for the approval excerpt that
slice will add. Each package is published under the licence text below, and
each pin's `UPSTREAM.json` records the SHA-256 of every file.
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
```
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Model Context Protocol SDK and bundled dependencies
The pinned builtin MCP bundle (`crates/engine/extension-host/dist/builtin/mcp.mjs`)
+3 -2
View File
@@ -924,7 +924,7 @@ base_url = "https://api.deepseek.com/beta"
# (https://bailian.console.aliyun.com/)
#
# Token Plan Personal and Team share the same AP-Southeast (Singapore) endpoint.
# Available text/coding models: qwen3.8-max, qwen3.8-max-preview, qwen3.7-plus,
# Available text/coding models: qwen3.8-max, qwen4-max-preview, qwen3.7-plus,
# qwen3.7-max, qwen3.6-flash, deepseek-v4-pro, deepseek-v4-flash-0731, glm-5.2
#
# Provider aliases: modelstudio-token-plan, modelstudio_token_plan,
@@ -944,7 +944,8 @@ base_url = "https://api.deepseek.com/beta"
# (https://bailian.console.aliyun.com/)
#
# Coding Plan uses a separate international endpoint (coding-intl.dashscope).
# Same model catalog as the Token Plan.
# Same model ids as the Token Plan except qwen4-max-preview, which is
# Token Plan only.
#
# Provider aliases: modelstudio-coding-plan, modelstudio_coding_plan,
# alibaba-coding-plan, dashscope-coding-plan
+2
View File
@@ -26,6 +26,7 @@ default = []
test-support = ["codewhale-engine/test-support"]
[dependencies]
agent-client-protocol.workspace = true
codewhale-account-cli = { path = "../account-cli", version = "0.10.3" }
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
anyhow.workspace = true
@@ -94,6 +95,7 @@ urlencoding = "2.1"
webbrowser = "1.0"
[dev-dependencies]
agent-client-protocol-schema.workspace = true
rusqlite.workspace = true
tempfile.workspace = true
tower = "0.5"
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,441 @@
use super::*;
use agent_client_protocol::v1::{ConnectionTo, Responder};
use agent_client_protocol::{Agent, Client, Lines, UntypedMessage};
use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, MAX_FRAME_BYTES};
use futures_util::{Sink, Stream};
use std::io;
use std::sync::{MutexGuard, PoisonError};
use tokio::sync::{Notify, mpsc};
const PROMPT_BUSY: &str = "a session/prompt turn is already in progress";
const OUTGOING_QUEUE_FRAMES: usize = 256;
const OVERSIZE_RESPONSE: &str =
"ACP response exceeds the 8 MiB transport limit; inspect canonical state without replay";
type Cx = ConnectionTo<Client>;
pub(super) struct Shared {
server: tokio::sync::Mutex<AcpServer>,
prompt: std::sync::Mutex<Option<mpsc::UnboundedSender<Inbound>>>,
idle: Notify,
ending: Notify,
failure: std::sync::Mutex<Option<anyhow::Error>>,
}
struct PromptGuard(Arc<Shared>);
impl Drop for PromptGuard {
fn drop(&mut self) {
self.0.release();
}
}
impl PromptGuard {
fn finish(self, responder: Responder<Value>, result: std::result::Result<Value, SdkError>) {
let mut slot = self.0.slot();
let _ = responder.respond_with_result(result);
*slot = None;
drop(slot);
self.0.idle.notify_waiters();
}
}
impl Shared {
pub(super) fn new(server: AcpServer) -> Self {
Self {
server: tokio::sync::Mutex::new(server),
prompt: std::sync::Mutex::new(None),
idle: Notify::new(),
ending: Notify::new(),
failure: std::sync::Mutex::new(None),
}
}
pub(super) fn fail(&self, error: anyhow::Error) {
self.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_or_insert(error);
self.tell_prompt(Inbound::Eof);
self.ending.notify_one();
}
pub(super) fn take_failure(&self) -> Option<anyhow::Error> {
self.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
}
#[cfg(test)]
pub(super) fn into_server(self) -> AcpServer {
self.server.into_inner()
}
fn slot(&self) -> MutexGuard<'_, Option<mpsc::UnboundedSender<Inbound>>> {
self.prompt.lock().unwrap_or_else(PoisonError::into_inner)
}
fn prompt_active(&self) -> bool {
self.slot().is_some()
}
fn tell_prompt(&self, message: Inbound) {
if let Some(inbox) = self.slot().as_ref() {
let _ = inbox.send(message);
}
}
fn release(&self) {
*self.slot() = None;
self.idle.notify_waiters();
}
async fn wait_idle(&self) {
loop {
let notified = self.idle.notified();
if !self.prompt_active() {
return;
}
notified.await;
}
}
fn cancel(&self, params: &Value) {
if let Some(session) = params.get("sessionId").and_then(Value::as_str) {
self.tell_prompt(Inbound::Cancel {
session: session.to_string(),
});
}
}
async fn request(
self: &Arc<Self>,
method: String,
params: Value,
responder: Responder<Value>,
cx: Cx,
) {
match method.as_str() {
"session/cancel" => {
self.cancel(&params);
let _ = responder.respond(Value::Null);
}
"session/prompt" => self.start_prompt(params, responder, cx).await,
_ if self.prompt_active() => self.during_prompt(&cx, method, params, responder),
"shutdown" => {
let _ = responder.respond(Value::Null);
self.ending.notify_one();
}
"session/load" => self.load(params, responder, cx).await,
_ => {
let result = self
.server
.lock()
.await
.handle_request(&method, params)
.await;
let _ = responder.respond_with_result(result);
}
}
}
fn during_prompt(
self: &Arc<Self>,
cx: &Cx,
method: String,
params: Value,
responder: Responder<Value>,
) {
let served = matches!(
method.as_str(),
"session/list"
| "session/set_config_option"
| "session/set_mode"
| "session/set_model"
| "session/listProviders"
| "session/currentModel"
| "session/selectModel"
);
if !served {
let error = if matches!(
method.as_str(),
"initialize" | "session/new" | "session/load" | "shutdown"
) {
SdkError::new(-32603, PROMPT_BUSY)
} else {
SdkError::method_not_found().data(Value::String(method))
};
let _ = responder.respond_with_error(error);
return;
}
let shared = Arc::clone(self);
let _ = cx.spawn(async move {
let result = shared
.server
.lock()
.await
.handle_request(&method, params)
.await;
let _ = responder.respond_with_result(result);
Ok(())
});
}
async fn load(&self, params: Value, responder: Responder<Value>, cx: Cx) {
let mut server = self.server.lock().await;
let result = async {
let loaded = server.load_session(params).await.map_err(SdkError::from)?;
if let Some(session) = loaded.get("sessionId").and_then(Value::as_str) {
let updates = server
.history_updates(session)
.await
.map_err(|error| SdkError::new(-32603, error.to_string()))?;
for update in updates {
cx.send_notification(acp::SessionNotification::new(
session.to_string(),
update,
))?;
}
}
Ok::<_, SdkError>(loaded)
}
.await;
drop(server);
let _ = responder.respond_with_result(result);
}
async fn start_prompt(self: &Arc<Self>, params: Value, responder: Responder<Value>, cx: Cx) {
if self.prompt_active() {
let _ = responder.respond_with_error(SdkError::new(-32603, PROMPT_BUSY));
return;
}
let admitted = {
let server = self.server.lock().await;
server.validate_prompt(&params).map(|(session, prompt)| {
let thread = server.sessions[&session].thread_id.clone();
(session, thread, prompt, server.prompt_run())
})
};
let (session, thread, prompt, run) = match admitted {
Ok(admitted) => admitted,
Err(error) => {
let _ = responder.respond_with_error(error.into());
return;
}
};
let (answers, mut inbox) = mpsc::unbounded_channel();
*self.slot() = Some(answers.clone());
let guard = PromptGuard(Arc::clone(self));
let task_cx = cx.clone();
let _ = cx.spawn(async move {
let result = {
let mut emit = |outgoing: Outgoing| -> Result<()> {
match outgoing {
Outgoing::Update(notification) => task_cx
.send_notification(notification)
.map_err(|error| anyhow!("ACP update was not sent: {}", error.message)),
Outgoing::Permission { key, request } => {
let answers = answers.clone();
task_cx
.send_request(request)
.on_receiving_result(async move |reply| {
let gone = matches!(
&reply,
Err(error)
if agent_client_protocol::is_incoming_transport_closed(error)
|| agent_client_protocol::is_connection_terminated(error)
);
if !gone {
let _ = answers.send(Inbound::Permission {
key,
allow: permission_allowed(&reply),
});
}
Ok(())
})
.map_err(|error| {
anyhow!("ACP permission request was not sent: {}", error.message)
})
}
}
};
run.drive_prompt(&session, &thread, prompt, &mut emit, &mut inbox)
.await
};
let result = match result {
Ok(stop) => to_wire(&acp::PromptResponse::new(stop)).map_err(SdkError::from),
Err(error) => Err(SdkError::new(
-32603,
codewhale_engine::client::redact_model_bound_text(&error.to_string(), &[]),
)),
};
guard.finish(responder, result);
Ok(())
});
}
}
fn permission_allowed(
reply: &std::result::Result<acp::RequestPermissionResponse, SdkError>,
) -> bool {
matches!(
reply,
Ok(response) if matches!(
&response.outcome,
acp::RequestPermissionOutcome::Selected(selected)
if &*selected.option_id.0 == "allow-once"
)
)
}
fn closes_input(line: &str) -> bool {
line.len() <= 256
&& line.contains("daemon/input_closed")
&& serde_json::from_str::<Value>(line)
.is_ok_and(|message| crate::is_control_input_closed(&message))
}
fn incoming_lines(
input: Box<dyn AsyncBufRead + Send + Unpin>,
shared: Arc<Shared>,
) -> impl Stream<Item = io::Result<String>> + Send + 'static {
futures_util::stream::unfold(
(BoundedLines::new(input), shared),
|(mut lines, shared)| async move {
loop {
match lines.next_line().await {
Ok(Some(line)) if line.trim().is_empty() => continue,
Ok(Some(line)) if closes_input(&line) => return None,
Ok(Some(line)) => return Some((Ok(line), (lines, shared))),
Ok(None) => return None,
Err(error) if crate::stdio::is_frame_refusal(&error) => {
return Some((Ok(error.to_string()), (lines, shared)));
}
Err(error) => {
shared.fail(anyhow!("ACP input failed: {error}"));
return None;
}
}
}
},
)
}
pub(super) fn bound_outgoing(line: String) -> io::Result<String> {
if line.len() <= MAX_FRAME_BYTES {
return Ok(line);
}
let refused = || io::Error::new(io::ErrorKind::InvalidData, FrameRefusal::TooLarge);
let Ok(Value::Object(frame)) = serde_json::from_str::<Value>(&line) else {
return Err(refused());
};
if frame.contains_key("method") || !frame.contains_key("id") {
return Err(refused());
}
let failure = |id: Value| {
json!({"jsonrpc":"2.0","id":id,"error":{"code":-32603,"message":OVERSIZE_RESPONSE}})
.to_string()
};
let replaced = failure(frame["id"].clone());
Ok(if replaced.len() <= MAX_FRAME_BYTES {
replaced
} else {
failure(Value::Null)
})
}
pub(super) fn outgoing_lines(
frames: mpsc::Sender<String>,
shared: Arc<Shared>,
) -> impl Sink<String, Error = io::Error> + Send + 'static {
futures_util::sink::unfold(
(frames, shared),
|(frames, shared), line: String| async move {
let line = match bound_outgoing(line) {
Ok(line) => line,
Err(error) => {
shared.fail(anyhow!("ACP output refused: {error}"));
return Ok((frames, shared));
}
};
frames
.send(line)
.await
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ACP output closed"))?;
Ok::<_, io::Error>((frames, shared))
},
)
}
pub(super) async fn serve_connection<'a>(
server: AcpServer,
input: Box<dyn AsyncBufRead + Send + Unpin>,
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
) -> Result<Arc<Shared>> {
let shared = Arc::new(Shared::new(server));
let (frames, mut queued) = mpsc::channel::<String>(OUTGOING_QUEUE_FRAMES);
let transport = Lines::new(
outgoing_lines(frames, Arc::clone(&shared)),
incoming_lines(input, Arc::clone(&shared)),
);
let requests = Arc::clone(&shared);
let notifications = Arc::clone(&shared);
let finishing = Arc::clone(&shared);
let connection = Agent
.v1()
.on_receive_request(
async move |request: UntypedMessage, responder, cx| {
let (method, params) = request.into_parts();
requests.request(method, params, responder, cx).await;
Ok(())
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_notification(
async move |notification: UntypedMessage, _cx| {
let (method, params) = notification.into_parts();
if method == "session/cancel" {
notifications.cancel(&params);
}
Ok(())
},
agent_client_protocol::on_receive_notification!(),
)
.connect_with(transport, async move |cx| {
tokio::select! {
() = cx.incoming_closed() => {}
() = finishing.ending.notified() => {}
}
finishing.tell_prompt(Inbound::Eof);
finishing.wait_idle().await;
Ok(())
});
let connection = async {
match connection.await {
Err(error) if agent_client_protocol::is_incoming_transport_closed(&error) => Ok(()),
other => other.map_err(|error| anyhow!("ACP connection failed: {}", error.message)),
}
};
let failing = Arc::clone(&shared);
let pump = async {
let mut broken = false;
while let Some(frame) = queued.recv().await {
if broken {
continue;
}
if let Err(error) =
crate::stdio::write_control_line(&mut output, frame.as_bytes()).await
{
broken = true;
failing.fail(error);
}
}
};
let (ended, ()) = tokio::join!(connection, pump);
drop(failing);
if let Some(error) = shared.take_failure() {
return Err(error);
}
ended?;
Ok(shared)
}
+147 -194
View File
@@ -5,12 +5,17 @@ use codewhale_engine::runtime_threads::{
manager::ExternalApprovalDecision, manager::StartTurnRequest,
};
use std::time::Duration;
use tokio::sync::mpsc::UnboundedReceiver;
struct Permission {
thread_id: String,
turn_id: String,
approval_id: String,
execution_id: String,
}
pub(super) struct PromptRun {
pub(super) runtime: Arc<RuntimeThreadManager>,
pub(super) sessions_dir: PathBuf,
}
struct Projection<'a> {
session: &'a str,
thread: &'a str,
@@ -42,21 +47,20 @@ impl Drop for TurnClaim {
}
}
}
impl AcpServer {
pub(super) async fn drive_prompt<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
&mut self,
params: Value,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
) -> Result<&'static str> {
let (session_id, prompt) = self
.validate_prompt(&params)
.map_err(|error| anyhow!(error.message))?;
let binding = self
.sessions
.get(&session_id)
.ok_or_else(|| anyhow!("unknown sessionId"))?;
let thread_id = binding.thread_id.clone();
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
}
impl PromptRun {
pub(super) async fn drive_prompt(
&self,
session_id: &str,
thread_id: &str,
prompt: String,
emit: Emit<'_>,
inbox: &mut UnboundedReceiver<Inbound>,
) -> Result<acp::StopReason> {
let session_id = session_id.to_string();
let thread_id = thread_id.to_string();
// Subscribe before snapshot/admission; replay deduplicates by seq.
let mut receiver = self.runtime.subscribe_events();
let cursor = self.runtime.get_thread_detail(&thread_id).await?.latest_seq;
@@ -86,7 +90,7 @@ impl AcpServer {
permissions: HashMap::new(),
};
let terminal = match self
.project_turn(&mut projection, &mut receiver, reader, writer)
.project_turn(&mut projection, &mut receiver, inbox, emit)
.await
{
Ok(terminal) => terminal,
@@ -144,9 +148,6 @@ impl AcpServer {
}
};
claim.armed = false;
if let Some(binding) = self.sessions.get_mut(&session_id) {
binding.cursor = projection.cursor;
}
self.save_checkpoint(&thread_id, &session_id).await?;
if terminal
.model_request_diagnostics
@@ -154,11 +155,13 @@ impl AcpServer {
.and_then(|facts| facts.stop_reason)
== Some(RuntimeTurnStopReason::StepBudgetExhausted)
{
return Ok("max_turn_requests");
return Ok(acp::StopReason::MaxTurnRequests);
}
match terminal.status {
RuntimeTurnStatus::Completed => Ok("end_turn"),
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => Ok("cancelled"),
RuntimeTurnStatus::Completed => Ok(acp::StopReason::EndTurn),
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => {
Ok(acp::StopReason::Cancelled)
}
_ => Err(anyhow!(
"{}",
terminal.error.unwrap_or_else(
@@ -209,12 +212,12 @@ impl AcpServer {
})?;
Ok(())
}
async fn project_turn<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
async fn project_turn(
&self,
projection: &mut Projection<'_>,
receiver: &mut tokio::sync::broadcast::Receiver<RuntimeEventRecord>,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
inbox: &mut UnboundedReceiver<Inbound>,
emit: Emit<'_>,
) -> Result<TurnRecord> {
let session = projection.session;
let thread = projection.thread;
@@ -232,45 +235,26 @@ impl AcpServer {
}
tokio::select! {
() = async { match cancel_deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, None => std::future::pending().await } } => return Err(anyhow!("Core cancellation remains nonterminal; ACP cannot report a fabricated cancelled receipt")),
line = reader.next_line(), if !eof => {
let line = match line {
Ok(line) => line,
Err(error) if crate::stdio::is_frame_refusal(&error) => {
write_jsonrpc_error(writer, None, -32700, error.to_string()).await?;
continue;
message = inbox.recv(), if !eof => {
match message {
Some(Inbound::Cancel { session: cancelled_session }) => {
if cancelled_session == session {
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
}
}
Err(error) => return Err(error.into()),
};
let Some(line) = line else {
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline = Some(tokio::time::Instant::now() + Duration::from_secs(30)); continue;
};
if line.trim().is_empty() { continue; }
let message: Value = match serde_json::from_str(&line) {
Ok(message) => message,
Err(error) => { write_jsonrpc_error(writer, None, -32700, format!("invalid json: {error}")).await?; continue; }
};
if crate::is_control_input_closed(&message) {
eof=true; cancelled=true; projection.permissions.clear();self.interrupt_claimed_turn(thread,turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now()+Duration::from_secs(30));continue;
}
let response_id = message.get("id").cloned().map(|id| self.response_id_policy.response_id(id));
if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { write_jsonrpc_error(writer, response_id, -32600, "jsonrpc version must be 2.0").await?; continue; }
if is_jsonrpc_response(&message) {
if !cancelled && let Some(id) = message.get("id").and_then(Value::as_str) && let Some(permission) = projection.permissions.remove(id) { self.answer_permission(&permission, &message).await?; }
continue;
}
if message.get("method").and_then(Value::as_str) == Some("session/cancel") {
if message.pointer("/params/sessionId").and_then(Value::as_str) == Some(session) {
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
Some(Inbound::Permission { key, allow }) => {
if !cancelled && let Some(permission) = projection.permissions.remove(&key) { self.answer_permission(&permission, allow).await?; }
}
Some(Inbound::Eof) | None => {
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
}
if let Some(id) = response_id { write_jsonrpc_result(writer, id, json!(null)).await?; }
} else if let Some(id) = response_id { write_jsonrpc_error(writer, Some(id), -32603, "a session/prompt turn is already in progress").await?; }
}
}
record = async { replay_batch.pop_front().expect("nonempty bounded replay batch") }, if !replay_batch.is_empty() => {
projection.emit = !cancelled && !eof;
if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); }
if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); }
}
batch = async { replay.as_mut().expect("active replay").batches.recv().await }, if replay.is_some() && replay_batch.is_empty() => {
match batch {
@@ -280,7 +264,7 @@ impl AcpServer {
}
event = receiver.recv(), if replay.is_none() && replay_batch.is_empty() => {
match event {
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); } },
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); } },
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let captured = self.runtime.replay_events(thread, Some(projection.cursor), None).await?;
if captured.base_seq > projection.cursor { return Err(anyhow!("ACP replay no longer covers the cursor; projection is incomplete")); }
@@ -292,7 +276,7 @@ impl AcpServer {
}
}
}
async fn answer_permission(&self, permission: &Permission, response: &Value) -> Result<()> {
async fn answer_permission(&self, permission: &Permission, allow: bool) -> Result<()> {
let detail = self
.runtime
.get_thread_detail(&permission.thread_id)
@@ -304,15 +288,6 @@ impl AcpServer {
}) {
return Ok(());
}
let allow = response.get("error").is_none()
&& response
.pointer("/result/outcome/outcome")
.and_then(Value::as_str)
== Some("selected")
&& response
.pointer("/result/outcome/optionId")
.and_then(Value::as_str)
== Some("allow-once");
self.runtime
.deliver_approval_from(
&permission.approval_id,
@@ -327,11 +302,11 @@ impl AcpServer {
.await?;
Ok(())
}
async fn project_event<W: AsyncWrite + Unpin>(
async fn project_event(
&self,
projection: &mut Projection<'_>,
record: &RuntimeEventRecord,
writer: &mut W,
out: Emit<'_>,
) -> Result<Option<TurnRecord>> {
let session = projection.session;
let thread = projection.thread;
@@ -353,7 +328,7 @@ impl AcpServer {
if emit && p.get("kind").and_then(Value::as_str) == Some("agent_message") =>
{
if let Some(text) = p.get("delta").and_then(Value::as_str) {
write_session_update(writer, session, text.to_string()).await?;
out(update(session, agent_text(text.to_string())))?;
}
}
"item.started" => {
@@ -373,7 +348,10 @@ impl AcpServer {
input: tool.get("input").cloned().unwrap_or(Value::Null),
};
if emit {
write_tool_call_start(writer, session, &call).await?;
out(update(
session,
acp::SessionUpdate::ToolCall(tool_call_start(&call)),
))?;
}
calls.insert(id, call);
}
@@ -386,7 +364,15 @@ impl AcpServer {
let call = calls
.get(id)
.ok_or_else(|| anyhow!("Dispatch projection lacks its admitted start"))?;
write_tool_call_update(writer, session, call, "in_progress", None).await?;
out(update(
session,
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
call,
acp::ToolCallStatus::InProgress,
None,
&[],
)),
))?;
}
"item.completed" | "item.failed" | "item.interrupted" if emit => {
if let Some(item) = p.get("item")
@@ -403,19 +389,19 @@ impl AcpServer {
.unwrap_or_default();
let status = if item.get("status").and_then(Value::as_str) == Some("completed")
{
"completed"
acp::ToolCallStatus::Completed
} else {
"failed"
acp::ToolCallStatus::Failed
};
write_tool_call_update_with_blocks(
writer,
out(update(
session,
&call,
status,
item.get("detail").and_then(Value::as_str),
&blocks,
)
.await?;
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
&call,
status,
item.get("detail").and_then(Value::as_str),
&blocks,
)),
))?;
}
}
"approval.required" if emit => {
@@ -436,11 +422,41 @@ impl AcpServer {
let call = calls
.get(execution)
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
let id = format!(
"codewhale-permission-{}",
NEXT_ACP_PERMISSION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
let id = uuid::Uuid::new_v4().to_string();
let title = tool_call_title(call);
let text = p
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty())
.map_or_else(|| title.clone(), str::to_string);
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)]),
),
vec![
acp::PermissionOption::new(
"allow-once",
"Allow once",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
"reject-once",
"Reject",
acp::PermissionOptionKind::RejectOnce,
),
],
);
write_json_line(writer, json!({"jsonrpc":"2.0","id":id,"method":"session/request_permission","params":{"sessionId":session,"toolCall":{"toolCallId":execution,"title":tool_call_title(call),"kind":tool_call_kind(call),"status":"pending","rawInput":call.input,"content":[{"type":"content","content":{"type":"text","text":p.get("description")}}]},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}})).await?;
out(Outgoing::Permission {
key: id.clone(),
request,
})?;
permissions.insert(
id,
Permission {
@@ -489,6 +505,22 @@ impl AcpServer {
mod tests {
use super::super::tests::{Rig, fixture_config};
use super::*;
fn frame(outgoing: Outgoing) -> Value {
match outgoing {
Outgoing::Update(notification) => {
json!({"jsonrpc":"2.0","method":"session/update","params":serde_json::to_value(notification).unwrap()})
}
Outgoing::Permission { key, request } => {
json!({"jsonrpc":"2.0","id":key,"method":"session/request_permission","params":serde_json::to_value(request).unwrap()})
}
}
}
fn recording(out: &mut Vec<Value>) -> impl FnMut(Outgoing) -> Result<()> + Send + '_ {
move |outgoing| {
out.push(frame(outgoing));
Ok(())
}
}
fn event(seq: u64, thread: &str, turn: &str, name: &str, payload: Value) -> RuntimeEventRecord {
serde_json::from_value(json!({"seq":seq,"timestamp":chrono::Utc::now(),"thread_id":thread,"turn_id":turn,"event":name,"payload":payload})).unwrap()
}
@@ -606,14 +638,15 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output = Vec::new();
let mut output: Vec<Value> = Vec::new();
// Project the actual Runtime records, not synthetic ACP frames.
for record in rig.server.runtime.events_since(&thread, None)? {
rig.server
.project_event(&mut projection, &record, &mut output)
.prompt_run()
.project_event(&mut projection, &record, &mut recording(&mut output))
.await?;
}
let wire = super::super::tests::parse_lines(output);
let wire = output;
let requests: Vec<_> = wire
.iter()
.filter(|frame| frame["method"] == "session/request_permission")
@@ -635,12 +668,8 @@ mod tests {
)
.ok_or_else(|| anyhow!("ACP permission was not retained"))?;
rig.server
.answer_permission(
&permission,
&json!({
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}
}),
)
.prompt_run()
.answer_permission(&permission, true)
.await?;
assert_eq!(
tokio::time::timeout(Duration::from_secs(10), engine.recv_approval_event())
@@ -758,7 +787,7 @@ mod tests {
})
.await??;
let mut terminal = None;
let mut settled_output = Vec::new();
let mut settled_output: Vec<Value> = Vec::new();
for record in rig
.server
.runtime
@@ -766,7 +795,12 @@ mod tests {
{
if let Some(completed) = rig
.server
.project_event(&mut projection, &record, &mut settled_output)
.prompt_run()
.project_event(
&mut projection,
&record,
&mut recording(&mut settled_output),
)
.await?
{
terminal = Some(completed);
@@ -781,7 +815,7 @@ mod tests {
assert!(projection.permissions.is_empty());
assert!(projection.calls.is_empty());
assert!(
!super::super::tests::parse_lines(settled_output)
!settled_output
.iter()
.any(|frame| frame["method"] == "session/request_permission")
);
@@ -804,7 +838,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output = Vec::new();
let mut output: Vec<Value> = Vec::new();
let start = event(
1,
&thread,
@@ -813,11 +847,13 @@ mod tests {
json!({"tool":{"id":"core-call","name":"read","input":{"path":"x"}}}),
);
rig.server
.project_event(&mut p, &start, &mut output)
.prompt_run()
.project_event(&mut p, &start, &mut recording(&mut output))
.await?;
let before = output.len();
rig.server
.project_event(&mut p, &start, &mut output)
.prompt_run()
.project_event(&mut p, &start, &mut recording(&mut output))
.await?;
assert_eq!(output.len(), before);
let dispatch = event(
@@ -829,7 +865,8 @@ mod tests {
);
assert!(
rig.server
.project_event(&mut p, &dispatch, &mut output)
.prompt_run()
.project_event(&mut p, &dispatch, &mut recording(&mut output))
.await
.is_err()
);
@@ -850,7 +887,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output = Vec::new();
let mut output: Vec<Value> = Vec::new();
for record in [
event(
1,
@@ -882,7 +919,8 @@ mod tests {
),
] {
rig.server
.project_event(&mut p, &record, &mut output)
.prompt_run()
.project_event(&mut p, &record, &mut recording(&mut output))
.await?;
}
assert!(output.is_empty());
@@ -904,7 +942,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output = Vec::new();
let mut output: Vec<Value> = Vec::new();
let failure = event(
1,
&thread,
@@ -914,7 +952,8 @@ mod tests {
);
let error = rig
.server
.project_event(&mut p, &failure, &mut output)
.prompt_run()
.project_event(&mut p, &failure, &mut recording(&mut output))
.await
.unwrap_err();
assert!(error.to_string().contains("no terminal receipt"));
@@ -922,90 +961,4 @@ mod tests {
rig.close().await;
Ok(())
}
struct FailedAnswerWriter(Vec<u8>);
impl AsyncWrite for FailedAnswerWriter {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
bytes: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
if String::from_utf8_lossy(bytes).contains("agent_message_chunk") {
return std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"fixture client disappeared after tool effect",
)));
}
self.0.extend_from_slice(bytes);
std::task::Poll::Ready(Ok(bytes.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
#[tokio::test(flavor = "current_thread")]
async fn failed_transport_after_completed_write_retains_actual_core_terminal_and_checkpoint()
-> Result<()> {
use codewhale_engine::llm_client::mock::canned;
let mut config = fixture_config();
config.yolo = Some(true);
let mut rig = Rig::new(
config,
vec![
canned::tool_call_turn(
"write",
"write",
r#"{"path":"writer-failure.txt","content":"completed"}"#,
),
canned::simple_text_turn("final answer"),
],
)?;
let id = rig.new_session().await;
let (client, input) = tokio::io::duplex(1024);
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(input));
let mut writer = FailedAnswerWriter(Vec::new());
let error = tokio::time::timeout(
Duration::from_secs(20),
rig.server.drive_prompt(
json!({"sessionId":id,"prompt":"write then answer"}),
&mut reader,
&mut writer,
),
)
.await?
.unwrap_err();
drop(client);
assert!(error.to_string().contains("partial receipts retained"));
assert_eq!(
std::fs::read_to_string(rig.workspace.join("writer-failure.txt"))?,
"completed"
);
let store = codewhale_engine::session_manager::SessionManager::new(
rig.server.sessions_dir.clone(),
)?;
let saved = store.load_session(&id)?;
assert!(
saved
.messages
.iter()
.flat_map(|m| &m.content)
.any(|b| matches!(b, codewhale_models::ContentBlock::ToolResult { .. }))
);
let thread = &rig.server.sessions[&id].thread_id;
let detail = rig.server.runtime.get_thread_detail(thread).await?;
assert!(!matches!(
detail.turns.last().unwrap().status,
RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress
));
rig.close().await;
Ok(())
}
}
+41 -2
View File
@@ -227,6 +227,36 @@ mod platform {
);
Ok(())
}
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
let gone =
codewhale_engine::runtime_threads::checked_runtime_store_root(selected_store)
.is_ok_and(|selected| {
selected == self.receipt().data_dir
&& matches!(
selected.symlink_metadata(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
)
});
if !gone || !matches!(self.revalidate_receipt(), Ok(true)) {
return Ok(false);
}
anyhow::ensure!(
self.publication.parent.retire_private_receipt(
&self.publication.receipt_name,
&self.publication.file
)?,
PublicationChangedBeforeAttach
);
Ok(true)
}
async fn discard_if_store_gone(
self: &Arc<Self>,
selected_store: &std::path::Path,
) -> Result<bool> {
let held = self.clone();
let selected_store = selected_store.to_path_buf();
owner_work(move || held.discard_if_store_gone_blocking(selected_store)).await
}
/// Caller retains the real Runtime lease; this checks evidence only.
pub(crate) fn revalidate_receipt(&self) -> Result<bool> {
if !self.publication.receipt_is_current()? {
@@ -345,8 +375,9 @@ mod platform {
pub async fn probe_owner_for_host_startup(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
selected_store: &std::path::Path,
) -> Result<HostOwnerProbe> {
probe_frontend(
match probe_frontend(
config_path,
selected,
crate::daemon_socket::AttachFrontend::Control,
@@ -355,7 +386,15 @@ mod platform {
None,
None,
)
.await
.await?
{
HostOwnerProbe::Unavailable(publication)
if publication.discard_if_store_gone(selected_store).await? =>
{
Ok(HostOwnerProbe::Absent)
}
probe => Ok(probe),
}
}
pub async fn connect(
+4
View File
@@ -1349,6 +1349,7 @@ mod platform {
let config = root.join("config.toml");
std::fs::write(&config, "")?;
let parent = PrivateDirectory::admit(path.parent().unwrap())?;
std::fs::create_dir_all(root.join("runtime"))?;
let stale = std::os::unix::net::UnixListener::bind(&path)?;
let identity = parent.socket_identity("daemon.sock")?.unwrap();
parent.protect_socket("daemon.sock", identity)?;
@@ -1383,6 +1384,7 @@ mod platform {
crate::daemon_client::probe_owner_for_host_startup(
Some(config.clone()),
Some(path.clone()),
&owner.data_dir,
)
.await?
else {
@@ -1392,6 +1394,7 @@ mod platform {
let observed_hook = observed.clone();
let selected = path.clone();
let selected_config = config.clone();
let selected_store = owner.data_dir.clone();
let stale_capture = captured.clone();
BEFORE_OWNER_REBIND.with(|slot| {
*slot.borrow_mut() = Some(Box::new(move |endpoint| {
@@ -1412,6 +1415,7 @@ mod platform {
crate::daemon_client::probe_owner_for_host_startup(
Some(selected_config),
Some(endpoint),
&selected_store,
)
.await
.unwrap(),
+1
View File
@@ -668,6 +668,7 @@ pub async fn run_exec_agent(
execution_config.subagent_heartbeat_timeout_secs_for_provider(&effective_identity),
),
prefer_bwrap: execution_config.prefers_bwrap(),
tool_launcher: execution_config.tool_launcher.clone(),
bwrap_extensions: codewhale_sandbox::BwrapMountExtensions {
read_only_roots: execution_config.bwrap_ro_roots.clone(),
device_roots: execution_config.bwrap_dev_roots.clone(),
+67 -3
View File
@@ -1323,6 +1323,7 @@ struct ThreadSummary {
preview: String,
model: String,
mode: String,
agent_preset: String,
workspace: PathBuf,
branch: Option<String>,
head: Option<String>,
@@ -1578,10 +1579,41 @@ struct RuntimeInfoResponse {
job_operation_store_id: Option<String>,
account: RuntimeAccountInfo,
experimental: RuntimeExperimentalCapabilities,
sandbox: RuntimeSandboxInfo,
// Backward-compatible alias kept for existing clients.
version: &'static str,
}
#[derive(Debug, Serialize)]
struct RuntimeSandboxInfo {
kind: String,
tool_launcher: bool,
}
async fn runtime_sandbox_info(state: &RuntimeApiState) -> RuntimeSandboxInfo {
let (prefer_bwrap, tool_launcher, denied_read_paths) = {
let cfg = state.config.read();
(
cfg.prefers_bwrap(),
cfg.tool_launcher.clone(),
codewhale_sandbox::read_guard::ReadDenylist::from_config(&cfg).subtree_paths(),
)
};
let launcher_configured = tool_launcher.is_some();
let kind = tokio::task::spawn_blocking(move || {
let mut manager = codewhale_sandbox::SandboxManager::with_bwrap_preference(prefer_bwrap);
manager.set_tool_launcher(tool_launcher);
manager.set_denied_read_subpaths(denied_read_paths);
manager.configured_sandbox().unwrap_or_default().to_string()
})
.await
.unwrap_or_else(|_| codewhale_sandbox::SandboxType::None.to_string());
RuntimeSandboxInfo {
kind,
tool_launcher: launcher_configured,
}
}
fn default_runtime_capabilities() -> RuntimeCapabilities {
RuntimeCapabilities {
client_token_intents: true,
@@ -2035,7 +2067,7 @@ async fn admit_runtime_host(
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let probe = match crate::daemon_client::probe_owner_for_host_startup(
config_path.clone(), selected_socket.clone(),
config_path.clone(), selected_socket.clone(), &selected_store,
).await {
Err(error) if error.is::<PublicationChangedBeforeAttach>() => {
tokio::time::sleep(Duration::from_millis(25)).await;
@@ -3283,6 +3315,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/tasks/{id}", get(get_task))
.route("/v1/tasks/{id}/cancel", post(cancel_task))
.route("/v1/skills", get(list_skills))
.route("/v1/modes", get(list_modes))
.route("/v1/commands", get(list_commands))
.route("/v1/threads/{id}/commands", post(run_thread_command))
.route(
@@ -3904,7 +3937,7 @@ async fn create_thread(
)
.await
.map_err(|e| {
if let Some(refusal) = external_agent_refusal(&e) {
if let Some(refusal) = external_agent_refusal(&e).or_else(|| mode_refusal(&e)) {
refusal
} else if e
.is::<codewhale_engine::runtime_threads::manager::RuntimeThreadCreateConflict>()
@@ -4153,6 +4186,10 @@ async fn list_threads_summary(
preview,
model: thread.model,
mode: thread.mode,
agent_preset: codewhale_engine::conversation_modes::effective_id(
thread.agent_preset.as_deref(),
)
.to_string(),
branch: workspace_git.and_then(|git| git.branch.clone()),
head: workspace_git.and_then(|git| git.head.clone()),
dirty: workspace_git.is_some_and(|git| git.dirty),
@@ -6217,6 +6254,17 @@ async fn list_hooks(
}))
}
async fn list_modes(
State(state): State<RuntimeApiState>,
) -> Json<codewhale_engine::conversation_modes::ConversationModeTable> {
Json(
codewhale_engine::conversation_modes::current_table(
state.runtime_threads.extension_host_options(),
)
.await,
)
}
async fn list_skills(
State(state): State<RuntimeApiState>,
) -> Result<Json<SkillsResponse>, ApiError> {
@@ -7083,6 +7131,7 @@ async fn runtime_info(
capabilities.fleet_run_create = !session_only;
capabilities.fleet_run_start = !session_only;
capabilities.fleet_local_target = !session_only;
let sandbox = runtime_sandbox_info(&state).await;
Json(RuntimeInfoResponse {
service: "codewhale-runtime-api",
runtime_api_version: RUNTIME_API_VERSION,
@@ -7097,6 +7146,7 @@ async fn runtime_info(
.then(|| state.runtime_threads.job_operation_store_id().to_string()),
account,
experimental: RuntimeExperimentalCapabilities::default(),
sandbox,
version,
})
}
@@ -14796,6 +14846,20 @@ fn map_automation_err(err: anyhow::Error) -> ApiError {
}
}
fn mode_refusal(err: &anyhow::Error) -> Option<ApiError> {
use codewhale_engine::conversation_modes::ModeRefusal;
let error = err.downcast_ref::<ModeRefusal>()?;
Some(ApiError {
status: match error.code {
ModeRefusal::MODE_UNAVAILABLE => StatusCode::FAILED_DEPENDENCY,
ModeRefusal::MODE_LOCKED => StatusCode::CONFLICT,
_ => StatusCode::BAD_REQUEST,
},
message: error.message.clone(),
code: Some(error.code),
})
}
fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
use codewhale_engine::fleet::agents::ExecutorUnavailable;
let error = err.downcast_ref::<ExecutorUnavailable>()?;
@@ -14811,7 +14875,7 @@ fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
}
fn map_thread_err(err: anyhow::Error) -> ApiError {
if let Some(refusal) = external_agent_refusal(&err) {
if let Some(refusal) = external_agent_refusal(&err).or_else(|| mode_refusal(&err)) {
return refusal;
}
if let Some(error) = err.downcast_ref::<codewhale_engine::runtime_threads::manager::RuntimeTurnOperationLookupError>() {
+11 -2
View File
@@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use codewhale_plugins::host_services::{PersonVia, PluginApprover, ReviewApproval};
use codewhale_plugins::marketplace::document::{
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
};
@@ -1432,8 +1433,16 @@ async fn run_registry_mutation(
let mut registry = (*registry).clone();
let result = match mutation {
RegistryMutation::Trust { .. } => registry.trust(selector),
RegistryMutation::Enable => registry.enable(selector),
RegistryMutation::Trust { .. } => registry.trust_reviewed(
selector,
ReviewApproval {
approved_by: PluginApprover::person(PersonVia::App),
host_services: None,
},
),
RegistryMutation::Enable => {
registry.enable_by(selector, PluginApprover::person(PersonVia::App))
}
RegistryMutation::Restage => registry.restage(selector),
RegistryMutation::Disable => registry.disable(selector),
RegistryMutation::Revoke => registry.revoke_trust(selector),
@@ -18,6 +18,7 @@ use uuid::Uuid;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
mod conversation_modes;
#[cfg(unix)]
mod external_agent_options;
#[cfg(unix)]
@@ -423,6 +424,7 @@ fn saved_session_with_blocks(blocks: Vec<codewhale_models::ContentBlock>) -> Sav
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
}
}
@@ -563,6 +565,7 @@ fn messages_from_thread_detail_batches_tool_results() {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
};
let turn = TurnRecord {
source_usage_origin_turn_id: None,
@@ -795,6 +798,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
},
turns: Vec::new(),
items: Vec::new(),
@@ -11083,6 +11087,8 @@ async fn runtime_info_reports_bind_state() -> Result<()> {
assert!(info["account"].get("refresh_token").is_none());
assert!(info["account"].get("email").is_none());
assert!(info["experimental"].is_object());
assert!(info["sandbox"]["kind"].is_string());
assert_eq!(info["sandbox"]["tool_launcher"], false);
handle.abort();
Ok(())
@@ -0,0 +1,82 @@
use super::*;
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
#[tokio::test]
async fn modes_route_lists_the_table_and_threads_carry_and_refuse_modes() -> Result<()> {
let _unavailable = CreatorAvailabilityGuard::unforced();
let Some((addr, _threads, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}");
let table: Value = client
.get(format!("{base}/v1/modes"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(table["default"], "standard");
assert_eq!(table["modes"][0]["id"], "standard");
assert_eq!(table["modes"][0]["source"], "builtin");
assert_eq!(table["modes"][0]["available"], true);
assert!(table["modes"][0].get("reason").is_none());
assert_eq!(table["modes"][1]["id"], "creator");
assert_eq!(table["modes"][1]["available"], false);
assert_eq!(
table["modes"][1]["reason"],
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON
);
let unavailable = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "creator"}))
.send()
.await?;
assert_eq!(unavailable.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
let body: Value = unavailable.json().await?;
assert_eq!(body["error"]["code"], "mode_unavailable");
let unknown = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "minimal"}))
.send()
.await?;
assert_eq!(unknown.status(), reqwest::StatusCode::BAD_REQUEST);
let body: Value = unknown.json().await?;
assert_eq!(body["error"]["code"], "unknown_mode");
let created: Value = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "standard"}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = created["id"].as_str().expect("thread id").to_string();
let summary: Value = client
.get(format!("{base}/v1/threads/summary"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let row = summary
.as_array()
.and_then(|rows| rows.iter().find(|row| row["id"] == id.as_str()))
.expect("summary row");
assert_eq!(row["agent_preset"], "standard");
let patched = client
.patch(format!("{base}/v1/threads/{id}"))
.json(&json!({"agent_preset": "creator"}))
.send()
.await?;
assert_eq!(patched.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
handle.abort();
let _ = handle.await;
Ok(())
}
@@ -135,6 +135,11 @@ async fn ui(
Ok((status, response.json().await?))
}
async fn mutate(client: &reqwest::Client, url: String) -> Result<(StatusCode, String)> {
let response = client.post(url).send().await?;
Ok((response.status(), response.text().await?))
}
fn refusal(status: u16, body: &serde_json::Value) -> (u16, String) {
(
status,
@@ -370,14 +375,14 @@ async fn plugin_ui_dsh_client_serves_admitted_seats_and_denies_and_revokes() ->
.is_client_error()
);
let disabled = client.post(format!("{base}/{name}/disable")).send().await?;
assert_eq!(disabled.status(), StatusCode::OK);
let (status, body) = mutate(&client, format!("{base}/{name}/disable")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
for url in [&asset_url, &route] {
let response = client.get(url).header("If-Match", &revision).send().await?;
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED, "{url}");
}
let enabled = client.post(format!("{base}/{name}/enable")).send().await?;
assert_eq!(enabled.status(), StatusCode::OK);
let (status, body) = mutate(&client, format!("{base}/{name}/enable")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
let stale = client
.get(&asset_url)
.header("If-Match", &revision)
@@ -459,13 +464,22 @@ async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() ->
fs::create_dir_all(&root)?;
let bundle = write_native_ui_bundle(tmp.path(), "nativeui")?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
let Some((addr, handle)) = spawn_plugin_api_server(root.clone(), workspace.clone()).await?
else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}/v1/apps/plugins");
let installed = install_active(&client, &base, format!("path:{}", bundle.display())).await?;
let name = installed["name"].as_str().unwrap().to_string();
let recorded =
isolated_plugin_discovery(&root, &workspace).inspect_registry_for_workspace(&workspace);
let by_app = codewhale_plugins::host_services::PluginApprover::person(
codewhale_plugins::host_services::PersonVia::App,
);
let plugin = recorded.get(&name).unwrap();
assert_eq!(plugin.approved_by.as_ref(), Some(&by_app));
assert_eq!(plugin.enabled_by.as_ref(), Some(&by_app));
let (status, minted) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
assert_eq!(status, 200, "{minted}");
@@ -509,8 +523,8 @@ async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() ->
let body: serde_json::Value = routed.json().await?;
assert_eq!(body["error"]["code"], "plugin_route_not_admitted");
let revoked = client.post(format!("{base}/{name}/revoke")).send().await?;
assert_eq!(revoked.status(), StatusCode::OK);
let (status, body) = mutate(&client, format!("{base}/{name}/revoke")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
let after = client
.get(&asset)
.header("If-Match", &revision)
@@ -559,6 +559,7 @@ impl RuntimeChatRelayHost {
parent_thread_id: None,
executor: None,
environment: None,
agent_preset: None,
operation_key: None,
model: Some(command.model.clone()),
model_provider: Some(command.model_provider.clone()),
@@ -721,6 +722,7 @@ impl RuntimeChatRelayHost {
parent_thread_id: None,
executor: None,
environment: None,
agent_preset: None,
dynamic_tools: Vec::new(),
environments: Vec::new(),
})
+4 -101
View File
@@ -18,111 +18,14 @@ use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::time::Duration;
use tokio::io::AsyncWriteExt as _;
use tokio::io::{AsyncBufRead, AsyncWrite};
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
use tokio_util::sync::CancellationToken;
#[derive(Debug, thiserror::Error)]
pub enum FrameRefusal {
#[error("control frame exceeds the 8 MiB transport limit")]
TooLarge,
#[error("control frame is not UTF-8")]
InvalidUtf8,
}
pub use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, is_frame_refusal};
pub fn is_frame_refusal(error: &std::io::Error) -> bool {
error
.get_ref()
.is_some_and(|source| source.is::<FrameRefusal>())
}
/// Cancellation-safe, allocation-bounded newline framing shared by every
/// local control transport. Consumed partial bytes remain owned by the reader.
pub struct BoundedLines<R> {
reader: R,
pub(crate) pending: Vec<u8>,
discarding: bool,
}
impl<R: AsyncBufRead + Unpin> BoundedLines<R> {
pub fn new(reader: R) -> Self {
Self {
reader,
pending: Vec::new(),
discarding: false,
}
}
pub fn into_inner(self) -> R {
self.reader
}
pub async fn next_line(&mut self) -> std::io::Result<Option<String>> {
loop {
let available = self.reader.fill_buf().await?;
if available.is_empty() {
self.discarding = false;
if self.pending.is_empty() {
return Ok(None);
}
if self.pending.len() > MAX_RUNTIME_IMAGE_BODY_BYTES {
self.pending.clear();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
let bytes = std::mem::take(&mut self.pending);
return String::from_utf8(bytes).map(Some).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
});
}
let available = &available[..available.len().min(8192)];
let newline = available.iter().position(|&byte| byte == b'\n');
let count = newline.unwrap_or(available.len());
if self.discarding {
self.reader.consume(count + usize::from(newline.is_some()));
if newline.is_some() {
self.discarding = false;
} else {
tokio::task::yield_now().await;
}
continue;
}
if count > (MAX_RUNTIME_IMAGE_BODY_BYTES + 1).saturating_sub(self.pending.len()) {
self.pending.clear();
self.reader.consume(count + usize::from(newline.is_some()));
self.discarding = newline.is_none();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
// Do not let Vec's geometric growth double an almost-full frame.
self.pending.reserve_exact(count);
self.pending.extend_from_slice(&available[..count]);
self.reader.consume(count + usize::from(newline.is_some()));
if self.pending.len() > MAX_RUNTIME_IMAGE_BODY_BYTES
&& self.pending.last() != Some(&b'\r')
{
self.pending.clear();
self.discarding = newline.is_none();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
if newline.is_some() {
if self.pending.last() == Some(&b'\r') {
self.pending.pop();
}
let bytes = std::mem::take(&mut self.pending);
return String::from_utf8(bytes).map(Some).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
});
}
}
}
}
const _: () =
assert!(codewhale_util::bounded_lines::MAX_FRAME_BYTES == MAX_RUNTIME_IMAGE_BODY_BYTES);
/// The stdio JSON-RPC loop, generic over its transport so it can be driven by
/// a duplex pipe in tests rather than the process's real stdin/stdout.
-31
View File
@@ -31,37 +31,6 @@ async fn control_framing_accepts_exact_limit_crlf_and_preserves_next_frame() {
assert!(reader.next_line().await.unwrap().is_none());
}
#[tokio::test]
async fn control_framing_refuses_oversize_before_retaining_extra_bytes() {
let input = vec![b'x'; MAX_RUNTIME_IMAGE_BODY_BYTES + 4096];
let mut reader = BoundedLines::new(BufReader::with_capacity(8192, input.as_slice()));
assert_eq!(
reader.next_line().await.unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
assert!(reader.pending.len() <= MAX_RUNTIME_IMAGE_BODY_BYTES + 1);
assert!(reader.pending.capacity() <= MAX_RUNTIME_IMAGE_BODY_BYTES + 1);
}
#[tokio::test]
async fn control_framing_keeps_consumed_prefix_when_read_future_is_cancelled() {
let (mut input, output) = tokio::io::duplex(64);
input.write_all(b"partial").await.unwrap();
let mut reader = BoundedLines::new(BufReader::new(output));
std::future::poll_fn(|cx| {
let mut read = std::pin::pin!(reader.next_line());
assert!(std::future::Future::poll(read.as_mut(), cx).is_pending());
std::task::Poll::Ready(())
})
.await;
assert_eq!(reader.pending, b"partial");
input.write_all(b"-continued\n").await.unwrap();
assert_eq!(
reader.next_line().await.unwrap().as_deref(),
Some("partial-continued")
);
}
#[tokio::test]
async fn control_framing_rejects_non_utf8_and_keeps_eof_line_contract() {
let mut invalid = BoundedLines::new(BufReader::new(&b"\xff\n"[..]));
+306
View File
@@ -0,0 +1,306 @@
mod acp_support;
#[path = "../../engine/tests/support/acp_conformance.rs"]
mod conformance;
use acp_support::{Client, fixture_config, text_of, updates};
use agent_client_protocol_schema::ProtocolVersion;
use agent_client_protocol_schema::v1 as acp;
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use conformance::{Allowed, Conformance, Side};
use serde::Serialize;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
const KNOWN: &[Allowed] = &[Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.sessionId` is not in the official type",
reason: "session/load keeps the session/new body with its root sessionId through the 0.10.3 tag; official LoadSessionResponse has no sessionId",
}];
fn wire<T: Serialize>(value: T) -> Value {
serde_json::to_value(value).expect("official type serializes")
}
async fn prompt_to_end(
client: &mut Client,
params: Value,
answer: Value,
) -> Result<(Value, Vec<Value>)> {
client.next_id += 1;
let id = client.next_id;
client
.send(json!({"jsonrpc":"2.0","id":id,"method":"session/prompt","params":params}))
.await?;
let mut frames = Vec::new();
loop {
let frame = client.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
if frame["method"] == "session/request_permission" {
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],"result":answer}))
.await?;
}
frames.push(frame);
}
}
#[test]
fn every_frame_the_acp_server_writes_is_an_official_v1_shape() -> 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(120), journey())
.await
.map_err(|_| anyhow!("conformance journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("conformance journey thread panicked"))?
}
async fn journey() -> Result<()> {
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace)?;
let runtime_dir = dir.path().join("runtime");
let config = fixture_config();
let plugins = Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace));
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: 4,
},
plugins,
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let provider = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn(
"plan-call",
"todo_write",
r#"{"todos":[{"content":"write the file","status":"in_progress"}]}"#,
),
canned::tool_call_turn(
"write-call",
"write",
r#"{"path":"allowed.txt","content":"approved"}"#,
),
canned::simple_text_turn("settled after approval"),
canned::tool_call_turn(
"cancel-call",
"write",
r#"{"path":"cancelled.txt","content":"never"}"#,
),
canned::simple_text_turn("unused"),
]));
manager.set_test_model_client(provider.clone());
let sessions_dir = codewhale_engine::session_manager::default_sessions_dir()?;
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
sessions_dir,
None,
None,
)?;
let mut observed = Conformance::checking(&[Side::Agent]);
let (mut client, agent_read, agent_write) = Client::connect(true);
let first_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let first_script = async {
let result = async {
let (initialized, _) = client
.request(
"initialize",
wire(
acp::InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false),
)
.client_info(acp::Implementation::new("conformance", "1.0.0")),
),
)
.await?;
assert_eq!(initialized["result"]["protocolVersion"], 1);
client
.send(json!({"jsonrpc":"2.0","method":"$/cancel_request","params":wire(acp::CancelRequestNotification::new(999_i64))}))
.await?;
let (unknown, _) = client.request("session/not_a_method", json!({})).await?;
assert_eq!(unknown["error"]["code"], -32601);
let (invalid, _) = client
.request("session/set_mode", json!({"modeId":"agent"}))
.await?;
assert_eq!(invalid["error"]["code"], -32602);
let (relative, _) = client
.request("session/new", wire(acp::NewSessionRequest::new("relative/dir")))
.await?;
assert_eq!(relative["error"]["code"], -32602);
let (created, _) = client
.request("session/new", wire(acp::NewSessionRequest::new(&workspace)))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
.to_string();
let (mode, _) = client
.request(
"session/set_mode",
wire(acp::SetSessionModeRequest::new(session.clone(), "agent")),
)
.await?;
assert!(mode.get("result").is_some(), "{mode}");
let (option, _) = client
.request(
"session/set_config_option",
wire(acp::SetSessionConfigOptionRequest::new(
session.clone(),
"model",
"fixture-model",
)),
)
.await?;
assert!(option.get("result").is_some(), "{option}");
let (image, _) = client
.request(
"session/prompt",
wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Image(acp::ImageContent::new(
"AAAA",
"image/png",
))],
)),
)
.await?;
assert_eq!(image["error"]["code"], -32602);
let allow = wire(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
"allow-once",
)),
));
let (done, frames) = prompt_to_end(
&mut client,
wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Text(acp::TextContent::new(
"plan, then write under Ask",
))],
)),
allow,
)
.await?;
assert_eq!(done["result"]["stopReason"], "end_turn");
assert!(!updates(&frames, "tool_call").is_empty());
assert_eq!(text_of(&frames, "agent_message_chunk"), "settled after approval");
client.next_id += 1;
let cancel_id = client.next_id;
client
.send(json!({"jsonrpc":"2.0","id":cancel_id,"method":"session/prompt","params":wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Text(acp::TextContent::new("write then cancel"))],
))}))
.await?;
let (permission, _) = client.until_permission().await?;
client
.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":wire(acp::CancelNotification::new(session.clone()))}))
.await?;
client
.send(json!({"jsonrpc":"2.0","id":permission["id"],"result":wire(acp::RequestPermissionResponse::new(acp::RequestPermissionOutcome::Cancelled))}))
.await?;
let (cancelled, _) = client.until_response(cancel_id).await?;
assert_eq!(cancelled["result"]["stopReason"], "cancelled");
let (listed, _) = client
.request(
"session/list",
wire(acp::ListSessionsRequest::new().cwd(workspace.clone())),
)
.await?;
assert!(listed["result"]["sessions"].is_array());
Ok::<_, anyhow::Error>(session)
}
.await;
let _ = client.write.shutdown().await;
result
};
let (served, session) = tokio::join!(first_connection, first_script);
served?;
let session = session?;
for (sent, frame) in client.log.take().unwrap_or_default() {
observed.observe(if sent { Side::Client } else { Side::Agent }, &frame);
}
let (mut client, agent_read, agent_write) = Client::connect(true);
let second_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let second_script = async {
let result = async {
client
.request(
"initialize",
wire(acp::InitializeRequest::new(ProtocolVersion::V1)),
)
.await?;
let (missing, _) = client
.request(
"session/load",
wire(acp::LoadSessionRequest::new("no-such-session", &workspace)),
)
.await?;
assert_eq!(missing["error"]["code"], -32602);
let (loaded, replay) = client
.request(
"session/load",
wire(acp::LoadSessionRequest::new(session.clone(), &workspace)),
)
.await?;
assert!(loaded.get("result").is_some(), "{loaded}");
assert!(text_of(&replay, "user_message_chunk").contains("plan, then write under Ask"));
assert!(!updates(&replay, "tool_call_update").is_empty());
Ok::<_, anyhow::Error>(())
}
.await;
let _ = client.write.shutdown().await;
result
};
let (served, script) = tokio::join!(second_connection, second_script);
served?;
script?;
for (sent, frame) in client.log.take().unwrap_or_default() {
observed.observe(if sent { Side::Client } else { Side::Agent }, &frame);
}
manager.shutdown_and_wait().await?;
assert!(
observed.frames() > 20,
"only {} frames observed",
observed.frames()
);
observed.assert_only(KNOWN);
Ok(())
}
+207 -118
View File
@@ -1,3 +1,6 @@
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};
@@ -5,110 +8,11 @@ use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use codewhale_runtime::config::{Config, ProviderConfig, ProvidersConfig, SnapshotsConfig};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf};
const STEP: Duration = Duration::from_secs(30);
fn fixture_config() -> Config {
let mut config = Config::default();
config.provider = Some("acp-fixture".into());
config.providers = Some(ProvidersConfig {
custom: HashMap::from([(
"acp-fixture".into(),
ProviderConfig {
kind: Some("openai-compatible".into()),
base_url: Some("http://127.0.0.1:18181/v1".into()),
model: Some("fixture-model".into()),
api_key: Some("local-test-key".into()),
..Default::default()
},
)]),
..Default::default()
});
config.snapshots = Some(SnapshotsConfig {
enabled: false,
..Default::default()
});
config.set_feature("mcp", false).unwrap();
config.set_feature("subagents", false).unwrap();
config
}
struct Client {
lines: tokio::io::Lines<BufReader<ReadHalf<DuplexStream>>>,
write: WriteHalf<DuplexStream>,
next_id: u64,
}
impl Client {
async fn send(&mut self, message: Value) -> Result<()> {
let mut line = serde_json::to_vec(&message)?;
line.push(b'\n');
self.write.write_all(&line).await?;
self.write.flush().await?;
Ok(())
}
async fn recv(&mut self) -> Result<Value> {
let line = tokio::time::timeout(STEP, self.lines.next_line())
.await
.map_err(|_| anyhow!("agent produced no frame within {STEP:?}"))??
.ok_or_else(|| anyhow!("agent closed the transport"))?;
Ok(serde_json::from_str(&line)?)
}
async fn request(&mut self, method: &str, params: Value) -> Result<(Value, Vec<Value>)> {
self.next_id += 1;
let id = self.next_id;
self.send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
.await?;
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
async fn until_permission(&mut self) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["method"] == "session/request_permission" {
return Ok((frame, frames));
}
frames.push(frame);
}
}
async fn until_response(&mut self, id: u64) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
}
fn updates<'a>(frames: &'a [Value], kind: &str) -> Vec<&'a Value> {
frames
.iter()
.filter(|frame| frame.pointer("/params/update/sessionUpdate") == Some(&json!(kind)))
.collect()
}
fn text_of(frames: &[Value], kind: &str) -> String {
updates(frames, kind)
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/text")?.as_str())
.collect()
}
use tokio::io::{AsyncWriteExt, BufReader};
#[test]
fn editor_drives_a_session_through_permission_cancel_and_load() -> Result<()> {
@@ -177,22 +81,7 @@ async fn journey() -> Result<()> {
None,
)?;
let connect = || {
let (client, agent) = tokio::io::duplex(1 << 20);
let (agent_read, agent_write) = tokio::io::split(agent);
let (client_read, client_write) = tokio::io::split(client);
(
Client {
lines: BufReader::new(client_read).lines(),
write: client_write,
next_id: 0,
},
agent_read,
agent_write,
)
};
let (mut client, agent_read, agent_write) = connect();
let (mut client, agent_read, agent_write) = Client::connect(false);
let first_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let first_script = async {
@@ -354,7 +243,7 @@ async fn journey() -> Result<()> {
assert_eq!(detail.turns[0].status, RuntimeTurnStatus::Completed);
assert_eq!(detail.turns[1].status, RuntimeTurnStatus::Interrupted);
let (mut client, agent_read, agent_write) = connect();
let (mut client, agent_read, agent_write) = Client::connect(false);
let second_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let second_script = async {
@@ -408,3 +297,203 @@ async fn journey() -> Result<()> {
manager.shutdown_and_wait().await?;
Ok(())
}
const 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:[{name:'echo',description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}]};
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-echo:' + r.params.arguments.text}]};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
});
"#;
#[test]
fn plugin_and_mcp_tools_run_as_acp_tool_calls_through_the_permission_dial() -> 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(120), extension_journey())
.await
.map_err(|_| anyhow!("extension journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("extension journey thread panicked"))?
}
async fn extension_journey() -> Result<()> {
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
let Some(node) = node_for_tests("plugin_and_mcp_tools_run_as_acp_tool_calls") else {
return Ok(());
};
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["plugin-context"]).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();
let server = dir.path().join("fixture-mcp.mjs");
std::fs::write(&server, 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]}}}))?,
)?;
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: 4,
},
fixture.registry(),
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let provider = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn("plugin-call", "ctx_probe", "{}"),
canned::tool_call_turn("mcp-call", "mcp_fixture_echo", r#"{"text":"from-acp"}"#),
canned::simple_text_turn("both extension tools ran"),
]));
manager.set_test_model_client(provider.clone());
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 script = async {
let result = async {
let (initialized, _) = client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let meta = &initialized["result"]["agentCapabilities"]["_meta"]["codewhale"];
assert_eq!(meta["plugins"], true);
assert_eq!(meta["configuredMcpServers"], true);
assert_eq!(meta["clientMcpServers"], false);
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"))?
.to_string();
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":"use both extension tools"}]}}))
.await?;
let mut asked = Vec::new();
let mut frames = Vec::new();
let done = loop {
let frame = client.recv().await?;
if frame["method"] == "session/request_permission" {
asked.push(
frame["params"]["toolCall"]["title"]
.as_str()
.unwrap_or_default()
.to_string(),
);
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}))
.await?;
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
break frame;
} else {
frames.push(frame);
}
};
assert_eq!(done["result"]["stopReason"], "end_turn");
assert!(
asked.iter().any(|title| title.starts_with("ctx_probe")),
"the plugin tool asks through the permission dial: {asked:?} {frames:#?}"
);
assert!(
asked
.iter()
.any(|title| title.starts_with("mcp_fixture_echo")),
"the MCP tool asks through the permission dial: {asked:?}"
);
let announced: Vec<_> = updates(&frames, "tool_call")
.iter()
.filter_map(|frame| frame.pointer("/params/update/title")?.as_str())
.map(str::to_string)
.collect();
assert!(announced.iter().any(|title| title == "ctx_probe"), "{announced:?}");
assert!(
announced.iter().any(|title| title == "mcp_fixture_echo"),
"{announced:?}"
);
let completed = updates(&frames, "tool_call_update")
.iter()
.filter(|frame| {
frame.pointer("/params/update/status") == Some(&json!("completed"))
})
.count();
assert_eq!(completed, 2, "both extension calls complete: {frames:#?}");
let rendered = serde_json::to_string(&frames)?;
assert!(rendered.contains("mcp-echo:from-acp"), "{rendered}");
let probed = updates(&frames, "tool_call_update")
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/0/content/text"))
.filter_map(Value::as_str)
.filter_map(|text| serde_json::from_str::<Value>(&text[text.find('{')?..]).ok())
.find_map(|result| Some(PathBuf::from(result.get("workspace")?.as_str()?)))
.ok_or_else(|| anyhow!("the plugin result did not reach the editor: {rendered}"))?;
assert_eq!(probed.canonicalize()?, workspace.canonicalize()?);
assert_eq!(
text_of(&frames, "agent_message_chunk"),
"both extension tools 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?;
let first = serde_json::to_string(
provider
.captured_requests()
.first()
.ok_or_else(|| anyhow!("no model request"))?,
)?;
assert!(
first.contains("ctx_probe") && first.contains("mcp_fixture_echo"),
"the first model request carries both extension tools"
);
manager.shutdown_and_wait().await?;
host.shutdown().await;
Ok(())
}
+120
View File
@@ -0,0 +1,120 @@
use anyhow::{Result, anyhow};
use codewhale_runtime::config::{Config, ProviderConfig, ProvidersConfig, SnapshotsConfig};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf};
pub const STEP: Duration = Duration::from_secs(30);
pub fn fixture_config() -> Config {
let mut config = Config::default();
config.provider = Some("acp-fixture".into());
config.providers = Some(ProvidersConfig {
custom: HashMap::from([(
"acp-fixture".into(),
ProviderConfig {
kind: Some("openai-compatible".into()),
base_url: Some("http://127.0.0.1:18181/v1".into()),
model: Some("fixture-model".into()),
api_key: Some("local-test-key".into()),
..Default::default()
},
)]),
..Default::default()
});
config.snapshots = Some(SnapshotsConfig {
enabled: false,
..Default::default()
});
config.set_feature("mcp", false).unwrap();
config.set_feature("subagents", false).unwrap();
config
}
pub struct Client {
pub lines: tokio::io::Lines<BufReader<ReadHalf<DuplexStream>>>,
pub write: WriteHalf<DuplexStream>,
pub next_id: u64,
pub log: Option<Vec<(bool, Value)>>,
}
impl Client {
pub fn connect(record: bool) -> (Self, ReadHalf<DuplexStream>, WriteHalf<DuplexStream>) {
let (client, agent) = tokio::io::duplex(1 << 20);
let (agent_read, agent_write) = tokio::io::split(agent);
let (client_read, client_write) = tokio::io::split(client);
(
Client {
lines: BufReader::new(client_read).lines(),
write: client_write,
next_id: 0,
log: record.then(Vec::new),
},
agent_read,
agent_write,
)
}
pub async fn send(&mut self, message: Value) -> Result<()> {
let mut line = serde_json::to_vec(&message)?;
line.push(b'\n');
self.write.write_all(&line).await?;
self.write.flush().await?;
if let Some(log) = &mut self.log {
log.push((true, message));
}
Ok(())
}
pub async fn recv(&mut self) -> Result<Value> {
let line = tokio::time::timeout(STEP, self.lines.next_line())
.await
.map_err(|_| anyhow!("agent produced no frame within {STEP:?}"))??
.ok_or_else(|| anyhow!("agent closed the transport"))?;
let frame: Value = serde_json::from_str(&line)?;
if let Some(log) = &mut self.log {
log.push((false, frame.clone()));
}
Ok(frame)
}
pub async fn request(&mut self, method: &str, params: Value) -> Result<(Value, Vec<Value>)> {
self.next_id += 1;
let id = self.next_id;
self.send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
.await?;
self.until_response(id).await
}
pub async fn until_permission(&mut self) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["method"] == "session/request_permission" {
return Ok((frame, frames));
}
frames.push(frame);
}
}
pub async fn until_response(&mut self, id: u64) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
}
pub fn updates<'a>(frames: &'a [Value], kind: &str) -> Vec<&'a Value> {
frames
.iter()
.filter(|frame| frame.pointer("/params/update/sessionUpdate") == Some(&json!(kind)))
.collect()
}
pub fn text_of(frames: &[Value], kind: &str) -> String {
updates(frames, kind)
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/text")?.as_str())
.collect()
}
+50 -2
View File
@@ -139,6 +139,7 @@ impl BootstrapPublicationFixture {
if reused {
process_start.push_str("-different-generation");
}
std::fs::create_dir_all(harness.root.join("runtime")).unwrap();
let owner = codewhale_protocol::RuntimeOwnerReceipt {
version: 1,
data_dir: harness.root.join("runtime"),
@@ -168,6 +169,7 @@ impl BootstrapPublicationFixture {
match codewhale_app_server::daemon_client::probe_owner_for_host_startup(
self.owner.config_path.clone(),
Some(self.owner.socket_path.clone()),
&self.owner.data_dir,
)
.await
.unwrap()
@@ -250,12 +252,58 @@ async fn owner_bootstrap_unavailable_is_host_only_and_read_only() {
fixture.parent.socket_identity("daemon.sock").unwrap(),
identity
);
assert!(
!fixture.owner.data_dir.exists(),
assert_eq!(
std::fs::read_dir(&fixture.owner.data_dir).unwrap().count(),
0,
"standalone control must not open or mint a store"
);
}
#[tokio::test]
async fn dead_owner_whose_store_was_deleted_is_discarded_and_startup_proceeds() {
let fixture = BootstrapPublicationFixture::new("boot-gone", true, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
assert!(matches!(
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&fixture.harness.root.join("another-store"),
)
.await
.unwrap(),
codewhale_app_server::daemon_client::HostOwnerProbe::Unavailable(_)
));
fixture.assert_publication(&fixture.bytes);
let probe = codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&fixture.owner.data_dir,
)
.await
.unwrap();
assert!(matches!(
probe,
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
));
assert!(
fixture
.parent
.read_private_receipt("daemon.sock.owner.json", 16384)
.unwrap()
.is_none()
);
assert!(!fixture.owner.data_dir.exists());
fixture.bind_replacement(None).await.unwrap();
}
#[tokio::test]
async fn live_owner_with_a_missing_store_is_never_discarded() {
let fixture = BootstrapPublicationFixture::new("boot-gone-live", false, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
fixture.capture().await;
fixture.assert_publication(&fixture.bytes);
}
#[tokio::test]
async fn owner_bootstrap_live_process_and_reused_pid_refuse_retirement() {
for reused in [false, true] {
+129 -2
View File
@@ -425,6 +425,74 @@ pub struct RuntimeRequestStats {
pub provider_reported_output_tokens: u64,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct SessionUsageStats {
pub sessions: u64,
pub usage_receipts: u64,
pub usage_receipts_incomplete: u64,
pub provider_reported_input_tokens: u64,
pub provider_reported_output_tokens: u64,
pub sessions_with_total_tokens_only: u64,
pub total_tokens_only: u64,
}
fn record_session_usage(v: &Value, rollup: &mut Rollup) {
let Some(sources) = v
.pointer("/metadata/cost/accounted_usage_sources")
.and_then(Value::as_object)
else {
let total = v
.pointer("/metadata/total_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
if total > 0 {
let stats = &mut rollup.session_usage;
stats.sessions_with_total_tokens_only =
stats.sessions_with_total_tokens_only.saturating_add(1);
stats.total_tokens_only = stats.total_tokens_only.saturating_add(total);
}
return;
};
let stats = &mut rollup.session_usage;
let mut counted = false;
for contribution in sources.values() {
let record = contribution.get("record");
let reported = match record
.and_then(|record| record.pointer("/settlement/coverage"))
.and_then(Value::as_str)
{
Some(coverage) => matches!(coverage, "reported" | "incomplete"),
None => record
.and_then(|record| record.pointer("/usage/usage"))
.is_some_and(|usage| {
usage.get("input_tokens").and_then(Value::as_u64) != Some(0)
|| usage.get("output_tokens").and_then(Value::as_u64) != Some(0)
}),
};
if !reported {
continue;
}
let tokens = |name: &str| {
record
.and_then(|record| record.pointer(&format!("/usage/usage/{name}")))
.and_then(Value::as_u64)
};
let (Some(input), Some(output)) = (tokens("input_tokens"), tokens("output_tokens")) else {
stats.usage_receipts_incomplete = stats.usage_receipts_incomplete.saturating_add(1);
continue;
};
stats.usage_receipts = stats.usage_receipts.saturating_add(1);
stats.provider_reported_input_tokens =
stats.provider_reported_input_tokens.saturating_add(input);
stats.provider_reported_output_tokens =
stats.provider_reported_output_tokens.saturating_add(output);
counted = true;
}
if counted {
stats.sessions = stats.sessions.saturating_add(1);
}
}
/// Top-level rollup.
#[derive(Debug, Default, serde::Serialize)]
pub struct Rollup {
@@ -445,6 +513,7 @@ pub struct Rollup {
pub capacity: CapacityStats,
pub credentials: CredentialStats,
pub runtime_requests: RuntimeRequestStats,
pub session_usage: SessionUsageStats,
/// Total lines read across all sources.
pub total_lines: u64,
/// Lines successfully parsed.
@@ -936,6 +1005,8 @@ fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rol
rollup.touch_ts(&ts);
}
record_session_usage(&v, rollup);
// Walk messages looking for tool_use calls with associated results.
let messages = match v.get("messages").and_then(|m| m.as_array()) {
Some(m) => m,
@@ -1640,15 +1711,44 @@ fn print_human(rollup: &Rollup, since: Option<DateTime<Utc>>) {
);
}
if runtime.provider_usage_receipts == 0 {
println!("Provider usage receipts: (none recorded; this is not zero usage)");
println!(
"Provider usage receipts (Runtime events): (none recorded; this is not zero usage)"
);
} else {
println!(
"Provider usage receipts: {} records, {} input tokens, {} output tokens",
"Provider usage receipts (Runtime events): {} records, {} input tokens, {} output tokens",
fmt_num(runtime.provider_usage_receipts),
fmt_num(runtime.provider_reported_input_tokens),
fmt_num(runtime.provider_reported_output_tokens),
);
}
let session_usage = &rollup.session_usage;
if session_usage.usage_receipts == 0 {
println!(
"Provider usage receipts (saved terminal sessions): (none recorded; this is not zero usage)"
);
} else {
println!(
"Provider usage receipts (saved terminal sessions): {} records in {} sessions, {} input tokens, {} output tokens",
fmt_num(session_usage.usage_receipts),
fmt_num(session_usage.sessions),
fmt_num(session_usage.provider_reported_input_tokens),
fmt_num(session_usage.provider_reported_output_tokens),
);
}
if session_usage.sessions_with_total_tokens_only > 0 {
println!(
"Saved sessions with a combined token total only: {} sessions, {} tokens (no per-call usage receipts saved)",
fmt_num(session_usage.sessions_with_total_tokens_only),
fmt_num(session_usage.total_tokens_only),
);
}
if session_usage.usage_receipts_incomplete > 0 {
println!(
"Saved-session usage coverage: {} usage receipts incomplete, not counted as zero",
fmt_num(session_usage.usage_receipts_incomplete),
);
}
if runtime.diagnostics_unavailable_turn_receipts > 0
|| runtime.diagnostics_incomplete_turn_receipts > 0
|| runtime.terminal_receipts_without_identity > 0
@@ -2241,6 +2341,33 @@ mod tests {
assert_eq!(runtime.model_requests_started, 2, "status is not a request");
}
#[test]
fn saved_terminal_session_usage_is_counted_apart_from_runtime_events() {
let session = serde_json::json!({
"metadata": { "cost": { "accounted_usage_sources": {
"a": { "record": { "settlement": { "coverage": "reported" }, "usage": { "usage": { "input_tokens": 40, "output_tokens": 6 } } } },
"b": { "record": { "settlement": { "coverage": "missing" }, "usage": { "usage": { "input_tokens": 0, "output_tokens": 0 } } } },
"c": { "record": { "settlement": { "coverage": "incomplete" }, "usage": { "usage": { "input_tokens": 3 } } } }
} } }
});
let mut rollup = Rollup::default();
record_session_usage(&session, &mut rollup);
let usage = &rollup.session_usage;
assert_eq!(usage.usage_receipts, 1);
assert_eq!(usage.usage_receipts_incomplete, 1);
assert_eq!(usage.sessions, 1);
assert_eq!(usage.provider_reported_input_tokens, 40);
assert_eq!(usage.provider_reported_output_tokens, 6);
record_session_usage(&serde_json::json!({ "metadata": {} }), &mut rollup);
assert_eq!(rollup.session_usage.sessions, 1);
record_session_usage(
&serde_json::json!({ "metadata": { "total_tokens": 900 } }),
&mut rollup,
);
assert_eq!(rollup.session_usage.sessions_with_total_tokens_only, 1);
assert_eq!(rollup.session_usage.total_tokens_only, 900);
}
#[test]
fn runtime_usage_receipts_deduplicate_by_runtime_event_identity() {
let usage = runtime_event(
@@ -39,6 +39,8 @@ pub struct Receipt {
/// own record; empty when no turn recorded one.
pub postures: Vec<&'static str>,
pub totals: ReceiptTotals,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<ReceiptUsage>,
pub actions: Vec<ReceiptAction>,
/// Actions left off the list because it hit [`MAX_RECEIPT_ACTIONS`].
pub omitted_actions: usize,
@@ -47,6 +49,50 @@ pub struct Receipt {
pub claim_ceiling: [&'static str; 3],
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ReceiptUsage {
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens: Option<ReceiptTokens>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<ReceiptCost>,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct ReceiptTokens {
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>,
pub total: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub calls: Option<usize>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ReceiptCost {
pub state: ReceiptCostState,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<f64>,
pub currency: &'static str,
pub priced_turns: u32,
pub unpriced_turns: u32,
pub report: String,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptCostState {
Priced,
Partial,
Unknown,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
@@ -263,6 +263,7 @@ fn empty_receipt() -> Receipt {
turn: None,
postures: vec![],
totals: ReceiptTotals::default(),
usage: None,
actions: vec![],
omitted_actions: 0,
not_recorded: vec![],
+17 -12
View File
@@ -217,7 +217,21 @@ pub fn cost(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
};
let projection = diagnostics.cost_projection();
let details = arg.is_some_and(|arg| arg.trim().eq_ignore_ascii_case("details"));
let plan_only = projection.total == 0.0
if cost_is_subscription_only(&projection) && !details {
return CommandResult::message(format!(
"Session Cost:\n ─────────────────────────────\n {SUBSCRIPTION_ONLY_COST_NOTE}\n\n Run /cost details for the route receipts."
));
}
match cost_report(&projection, presentation) {
Ok(report) => CommandResult::message(report),
Err(error) => CommandResult::error(error),
}
}
pub const SUBSCRIPTION_ONLY_COST_NOTE: &str = "These routes use subscription billing. Codewhale does not know the amount charged by your provider.";
pub fn cost_is_subscription_only(projection: &DebugCostProjection) -> bool {
projection.total == 0.0
&& projection.priced_turns == 0
&& projection.unpriced_turns == 0
&& !projection.legacy_coverage_unknown
@@ -227,19 +241,10 @@ pub fn cost(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
receipt
.split_whitespace()
.any(|field| field == "billing_mode=subscription")
});
if plan_only && !details {
return CommandResult::message(
"Session Cost:\n ─────────────────────────────\n These routes use subscription billing. Codewhale does not know the amount charged by your provider.\n\n Run /cost details for the route receipts.".to_string(),
);
}
match cost_report(&projection, presentation) {
Ok(report) => CommandResult::message(report),
Err(error) => CommandResult::error(error),
}
})
}
fn cost_report(
pub fn cost_report(
cost: &DebugCostProjection,
presentation: &dyn CommandPresentationContext,
) -> Result<String, String> {
@@ -414,6 +414,53 @@ pub fn action_line(action: &ReceiptAction) -> String {
one_line(&line)
}
fn tokens_line(tokens: &ReceiptTokens) -> String {
let mut line = match (tokens.input, tokens.output) {
(Some(input), Some(output)) => {
format!("Tokens: {input} in · {output} out · {} total", tokens.total)
}
_ => format!("Tokens: {} total", tokens.total),
};
if let Some(calls) = tokens.calls {
line.push_str(&format!(
" · {}",
plural(calls, "provider call", "provider calls")
));
}
let detail: Vec<String> = [
("cache read", tokens.cache_read),
("cache write", tokens.cache_write),
("reasoning", tokens.reasoning),
]
.into_iter()
.filter_map(|(label, value)| value.map(|value| format!("{value} {label}")))
.collect();
if !detail.is_empty() {
line.push_str(&format!(" ({})", detail.join(", ")));
}
line
}
fn usage_block(usage: &ReceiptUsage) -> String {
let mut out = String::new();
if let Some(tokens) = &usage.tokens {
out.push_str(&one_line(&tokens_line(tokens)));
out.push('\n');
}
if let Some(cost) = &usage.cost {
out.push_str("Cost, as /cost reports it:\n");
for line in cost
.report
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
out.push_str(&format!(" {}\n", one_line(line)));
}
}
out
}
/// The readable receipt: header, totals, one line per action, then what the
/// record does not hold. Valid Markdown and plain enough for a terminal.
#[must_use]
@@ -457,6 +504,10 @@ pub fn render_markdown(receipt: &Receipt) -> String {
out.push_str("\n\n");
out.push_str(&one_line(&totals_line(receipt)));
out.push_str("\n\n");
if let Some(usage) = &receipt.usage {
out.push_str(&usage_block(usage));
out.push('\n');
}
let width = receipt.actions.len().to_string().len();
for action in &receipt.actions {
out.push_str(&format!(
+36 -3
View File
@@ -1188,6 +1188,9 @@
"context_window": 1000000,
"max_output": 131072
},
"qwen4-max-preview": {
"source": "Token Plan Supported Models table, https://qwencloud.com/pricing/token-plan, verified 2026-11-11: id qwen4-max-preview (New), capabilities Text Generation, Reasoning Model, Visual Understanding; see source_receipts"
},
"step-3.5-flash": {
"context_window": 256000,
"modalities": [
@@ -4177,6 +4180,12 @@
"added_at": "2026-08-02",
"aliases": []
},
{
"id": "qwen4-max-preview",
"label": "Qwen",
"added_at": "2026-11-10",
"aliases": []
},
{
"id": "kimi-for-coding-highspeed",
"label": "Moonshot/Kimi",
@@ -4779,7 +4788,8 @@
"https://api.deepinfra.com/models/list": "verified 2026-10-20: deepseek-ai/DeepSeek-V4.1-Flash-1017, deepseek-ai/DeepSeek-V4.1-Flash, deepseek-ai/DeepSeek-V4-Pro-0813 and deepseek-ai/DeepSeek-V4-Flash-0731 are listed and not deprecated, each with max_tokens 1048576 and the tools tag; both V4.1 Flash ids are tagged multimodal and reasoning, V4-Flash-0731 reasoning; V4-Pro-0813 carries can-disable-reasoning beside a non-reasoning tag, while its model page documents low, high and max reasoning effort and the Models.dev deepseek/deepseek-v4-pro-0813 row marks reasoning, so reasoning is recorded; the V4-Pro and V4-Flash previews are still listed; no separate output limit is published, so none is recorded",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4-Pro-0813": "verified 2026-10-20: the ModelScope card describes DeepSeek-V4-Pro-0813 as the official release of DeepSeek-V4-Pro on the preview model structure, whose card lists a 1M context length; no separate ModelScope API-Inference context limit is published there",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4.1-Flash": "verified 2026-10-20: the ModelScope card states contexts of up to one million tokens and a recommended 1M context_window; no separate ModelScope API-Inference context limit is published there",
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred"
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred",
"https://qwencloud.com/pricing/token-plan": "verified 2026-11-11: the Supported Models table lists qwen4-max-preview marked New with Text Generation, Reasoning Model and Visual Understanding, so image input is recorded; the page gives no context window, maximum output, tool calling or thinking control, so none is recorded and Codewhale sends no thinking switch"
},
"transports": [
{
@@ -5052,6 +5062,17 @@
"default_for_provider": false,
"source": "preserved reviewed exact transport snapshot; see source_receipts"
},
{
"provider": "modelstudio-token-plan",
"id": "qwen4-max-preview",
"endpoint_key": "chat",
"default_for_provider": false,
"source": "Token Plan Supported Models table, verified 2026-11-11; see source_receipts",
"limits": {},
"capabilities": {
"image_input": "supported"
}
},
{
"provider": "opencode-go",
"id": "qwen3.8-flash",
@@ -6870,10 +6891,22 @@
"@MODELSTUDIO_TEXT_MODELS"
],
"modelstudio-coding-plan": [
"@MODELSTUDIO_TEXT_MODELS"
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
],
"modelstudio-coding-plan-anthropic": [
"@MODELSTUDIO_TEXT_MODELS"
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
],
"mistral": [
"$DEFAULT_MISTRAL_MODEL",
+36 -3
View File
@@ -8800,6 +8800,9 @@
"context_window": 1000000,
"max_output": 131072
},
"qwen4-max-preview": {
"source": "Token Plan Supported Models table, https://qwencloud.com/pricing/token-plan, verified 2026-11-11: id qwen4-max-preview (New), capabilities Text Generation, Reasoning Model, Visual Understanding; see source_receipts"
},
"step-3.5-flash": {
"context_window": 256000,
"modalities": [
@@ -11789,6 +11792,12 @@
"added_at": "2026-08-02",
"aliases": []
},
{
"id": "qwen4-max-preview",
"label": "Qwen",
"added_at": "2026-11-10",
"aliases": []
},
{
"id": "kimi-for-coding-highspeed",
"label": "Moonshot/Kimi",
@@ -12391,7 +12400,8 @@
"https://api.deepinfra.com/models/list": "verified 2026-10-20: deepseek-ai/DeepSeek-V4.1-Flash-1017, deepseek-ai/DeepSeek-V4.1-Flash, deepseek-ai/DeepSeek-V4-Pro-0813 and deepseek-ai/DeepSeek-V4-Flash-0731 are listed and not deprecated, each with max_tokens 1048576 and the tools tag; both V4.1 Flash ids are tagged multimodal and reasoning, V4-Flash-0731 reasoning; V4-Pro-0813 carries can-disable-reasoning beside a non-reasoning tag, while its model page documents low, high and max reasoning effort and the Models.dev deepseek/deepseek-v4-pro-0813 row marks reasoning, so reasoning is recorded; the V4-Pro and V4-Flash previews are still listed; no separate output limit is published, so none is recorded",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4-Pro-0813": "verified 2026-10-20: the ModelScope card describes DeepSeek-V4-Pro-0813 as the official release of DeepSeek-V4-Pro on the preview model structure, whose card lists a 1M context length; no separate ModelScope API-Inference context limit is published there",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4.1-Flash": "verified 2026-10-20: the ModelScope card states contexts of up to one million tokens and a recommended 1M context_window; no separate ModelScope API-Inference context limit is published there",
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred"
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred",
"https://qwencloud.com/pricing/token-plan": "verified 2026-11-11: the Supported Models table lists qwen4-max-preview marked New with Text Generation, Reasoning Model and Visual Understanding, so image input is recorded; the page gives no context window, maximum output, tool calling or thinking control, so none is recorded and Codewhale sends no thinking switch"
},
"transports": [
{
@@ -12664,6 +12674,17 @@
"default_for_provider": false,
"source": "preserved reviewed exact transport snapshot; see source_receipts"
},
{
"provider": "modelstudio-token-plan",
"id": "qwen4-max-preview",
"endpoint_key": "chat",
"default_for_provider": false,
"source": "Token Plan Supported Models table, verified 2026-11-11; see source_receipts",
"limits": {},
"capabilities": {
"image_input": "supported"
}
},
{
"provider": "opencode-go",
"id": "qwen3.8-flash",
@@ -14482,10 +14503,22 @@
"@MODELSTUDIO_TEXT_MODELS"
],
"modelstudio-coding-plan": [
"@MODELSTUDIO_TEXT_MODELS"
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
],
"modelstudio-coding-plan-anthropic": [
"@MODELSTUDIO_TEXT_MODELS"
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
],
"mistral": [
"$DEFAULT_MISTRAL_MODEL",
+1 -1
View File
@@ -614,7 +614,7 @@ mod tests {
let reviewed = bundled_reviewed();
reviewed.validate().expect("committed data");
assert_eq!(reviewed.selections.len(), 188);
assert_eq!(reviewed.transports.len(), 122);
assert_eq!(reviewed.transports.len(), 123);
let go = reviewed_transport("opencode-go", "gpt-5.6-luna").unwrap();
assert_eq!(go.endpoint_key, "responses");
assert_eq!(
+2
View File
@@ -73,6 +73,7 @@ path = "src/lib.rs"
doctest = false
[dependencies]
agent-client-protocol.workspace = true
http = "1.5"
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
@@ -179,6 +180,7 @@ wiremock = { version = "0.6", optional = true }
codewhale-build-support = { path = "../build-support", version = "0.10.3" }
[dev-dependencies]
agent-client-protocol-schema.workspace = true
tokio = { workspace = true, features = ["test-util"] }
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3", features = ["schema"] }
codewhale-skills = { path = "../skills", version = "0.10.3", features = ["test-support"] }
+78 -31
View File
@@ -727,7 +727,7 @@ var init_lib2 = __esm({
} else throw new TypeError("Invalid effect");
}, runner.getOuterStack);
}
effect(execute, label = "anonymous") {
effect(execute, label2 = "anonymous") {
this.assertActive();
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
const disposables = [];
@@ -745,7 +745,7 @@ var init_lib2 = __esm({
return disposalTask = task2;
};
const meta = {
label,
label: label2,
children: []
};
const runner = {
@@ -1167,7 +1167,7 @@ var init_lib2 = __esm({
};
return dispatch();
}
register(label, name, callback, options) {
register(label2, name, callback, options) {
const method = options.prepend ? "unshift" : "push";
return this.ctx.fiber.effect(() => {
(this._hooks[name] ??= [])[method]({
@@ -1176,7 +1176,7 @@ var init_lib2 = __esm({
...options
});
return () => this.unregister(name, callback);
}, label);
}, label2);
}
unregister(name, callback) {
const hooks = this._hooks[name];
@@ -1205,8 +1205,8 @@ var init_lib2 = __esm({
listener = this.ctx.reflect.bind(listener);
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
if (result) return result;
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label, name, listener, options);
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label2, name, listener, options);
}
/**
* Register an event listener that disposes itself after the first call.
@@ -1951,9 +1951,9 @@ var init_lib2 = __esm({
* @param label — scope label to join; defaults to a fresh unique symbol.
* @returns a child context whose `name` service resolves in the new scope.
*/
isolate(name, label) {
isolate(name, label2) {
const shadow = Object.create(this[symbols.isolate]);
shadow[name] = label ?? Symbol(name);
shadow[name] = label2 ?? Symbol(name);
return this.extend({ [symbols.isolate]: shadow });
}
intercept(name, config) {
@@ -2650,14 +2650,14 @@ function isolate(ctx) {
const delims = /* @__PURE__ */ Object.create(null);
function access2(entry, name, create = false) {
let realm;
const label = entry.options.isolate?.[name];
if (!label) return;
if (label === true) {
const label2 = entry.options.isolate?.[name];
if (!label2) return;
if (label2 === true) {
realm = entry.realm ??= new LocalRealm(entry);
} else if (create) {
realm = realms[label] ??= new GlobalRealm(label);
realm = realms[label2] ??= new GlobalRealm(label2);
} else {
realm = realms[label];
realm = realms[label2];
}
return realm?.access(name, create);
}
@@ -2714,10 +2714,10 @@ function isolate(ctx) {
}
});
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
if (label === true) continue;
if (active && entry.options.isolate?.[name] === label) continue;
const realm = realms[label];
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
if (label2 === true) continue;
if (active && entry.options.isolate?.[name] === label2) continue;
const realm = realms[label2];
if (!realm) continue;
for (const entry2 of ctx.loader.entries()) {
if (entry2.options.isolate?.[name] === realm.label) return;
@@ -2761,9 +2761,9 @@ var init_isolate = __esm({
}
};
GlobalRealm = class extends Realm {
constructor(label) {
constructor(label2) {
super();
this.label = label;
this.label = label2;
}
label;
get suffix() {
@@ -7370,9 +7370,9 @@ var init_acorn = __esm({
keywordRelationalOperator = /^in(stanceof)?$/;
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
TokenType = function TokenType2(label, conf) {
TokenType = function TokenType2(label2, conf) {
if (conf === void 0) conf = {};
this.label = label;
this.label = label2;
this.keyword = conf.keyword;
this.beforeExpr = !!conf.beforeExpr;
this.startsExpr = !!conf.startsExpr;
@@ -8421,8 +8421,8 @@ var init_acorn = __esm({
};
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
var label = list2[i$1];
if (label.name === maybeName) {
var label2 = list2[i$1];
if (label2.name === maybeName) {
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
}
}
@@ -12795,7 +12795,8 @@ var init_compatibility = __esm({
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
refused_services: [
@@ -14483,6 +14484,51 @@ var init_agents = __esm({
}
});
// src/dsh/remote.ts
function label(value) {
if (value === void 0) return void 0;
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
return value;
}
function grant(value) {
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
return value;
}
var remoteAdapter;
var init_remote = __esm({
"src/dsh/remote.ts"() {
"use strict";
init_lib2();
init_protocol();
init_rpc();
remoteAdapter = {
service: "remote",
define(host) {
class RemoteService extends Service {
constructor(ctx) {
super(ctx, "remote");
}
status() {
return host.broker(this.ctx, "remote", "status", {});
}
pair(options) {
const value = label(options?.label);
return host.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
}
clients() {
return host.broker(this.ctx, "remote", "clients", {});
}
revoke(id) {
return host.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
}
}
Object.freeze(RemoteService.prototype);
return RemoteService;
}
};
}
});
// src/dsh/adapters.ts
var DSH_ADAPTERS;
var init_adapters = __esm({
@@ -14490,7 +14536,8 @@ var init_adapters = __esm({
"use strict";
init_session_prompt();
init_agents();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
@@ -14719,16 +14766,16 @@ function entryListProblem(rows, at2 = "") {
return at2 === "" ? "the composition must be a top-level list of plugin rows" : `group ${at2} must hold a list of plugin rows`;
}
for (const [index, row] of rows.entries()) {
const label = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
const label2 = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
if (typeof row !== "object" || row === null || Array.isArray(row)) {
return `${label} is not a plugin row (expected a map with a "name")`;
return `${label2} is not a plugin row (expected a map with a "name")`;
}
const { name, group, config } = row;
if (typeof name !== "string" || name === "") {
return `${label} names no plugin (a "name" string is required)`;
return `${label2} names no plugin (a "name" string is required)`;
}
if (group === true) {
const nested = entryListProblem(config, label);
const nested = entryListProblem(config, label2);
if (nested !== void 0) return nested;
}
}
@@ -14751,8 +14798,8 @@ async function unresolvableRows(rows, presetBase, harnessBase, resolves, io, at2
continue;
}
if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves, io)) continue;
const label = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
found.push({ label, name: row.name });
const label2 = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
found.push({ label: label2, name: row.name });
}
return found;
}
@@ -921,7 +921,7 @@ var init_lib2 = __esm({
} else throw new TypeError("Invalid effect");
}, runner.getOuterStack);
}
effect(execute, label = "anonymous") {
effect(execute, label2 = "anonymous") {
this.assertActive();
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
const disposables = [];
@@ -939,7 +939,7 @@ var init_lib2 = __esm({
return disposalTask = task2;
};
const meta = {
label,
label: label2,
children: []
};
const runner = {
@@ -1361,7 +1361,7 @@ var init_lib2 = __esm({
};
return dispatch();
}
register(label, name, callback, options) {
register(label2, name, callback, options) {
const method = options.prepend ? "unshift" : "push";
return this.ctx.fiber.effect(() => {
(this._hooks[name] ??= [])[method]({
@@ -1370,7 +1370,7 @@ var init_lib2 = __esm({
...options
});
return () => this.unregister(name, callback);
}, label);
}, label2);
}
unregister(name, callback) {
const hooks = this._hooks[name];
@@ -1399,8 +1399,8 @@ var init_lib2 = __esm({
listener = this.ctx.reflect.bind(listener);
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
if (result) return result;
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label, name, listener, options);
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label2, name, listener, options);
}
/**
* Register an event listener that disposes itself after the first call.
@@ -2145,9 +2145,9 @@ var init_lib2 = __esm({
* @param label — scope label to join; defaults to a fresh unique symbol.
* @returns a child context whose `name` service resolves in the new scope.
*/
isolate(name, label) {
isolate(name, label2) {
const shadow = Object.create(this[symbols.isolate]);
shadow[name] = label ?? Symbol(name);
shadow[name] = label2 ?? Symbol(name);
return this.extend({ [symbols.isolate]: shadow });
}
intercept(name, config) {
@@ -4543,14 +4543,14 @@ function isolate(ctx) {
const delims = /* @__PURE__ */ Object.create(null);
function access2(entry, name, create = false) {
let realm;
const label = entry.options.isolate?.[name];
if (!label) return;
if (label === true) {
const label2 = entry.options.isolate?.[name];
if (!label2) return;
if (label2 === true) {
realm = entry.realm ??= new LocalRealm(entry);
} else if (create) {
realm = realms[label] ??= new GlobalRealm(label);
realm = realms[label2] ??= new GlobalRealm(label2);
} else {
realm = realms[label];
realm = realms[label2];
}
return realm?.access(name, create);
}
@@ -4607,10 +4607,10 @@ function isolate(ctx) {
}
});
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
if (label === true) continue;
if (active && entry.options.isolate?.[name] === label) continue;
const realm = realms[label];
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
if (label2 === true) continue;
if (active && entry.options.isolate?.[name] === label2) continue;
const realm = realms[label2];
if (!realm) continue;
for (const entry2 of ctx.loader.entries()) {
if (entry2.options.isolate?.[name] === realm.label) return;
@@ -4655,9 +4655,9 @@ var init_isolate = __esm({
}
};
GlobalRealm = class extends Realm {
constructor(label) {
constructor(label2) {
super();
this.label = label;
this.label = label2;
}
label;
get suffix() {
@@ -9683,9 +9683,9 @@ var init_acorn = __esm({
keywordRelationalOperator = /^in(stanceof)?$/;
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
TokenType = function TokenType2(label, conf) {
TokenType = function TokenType2(label2, conf) {
if (conf === void 0) conf = {};
this.label = label;
this.label = label2;
this.keyword = conf.keyword;
this.beforeExpr = !!conf.beforeExpr;
this.startsExpr = !!conf.startsExpr;
@@ -10734,8 +10734,8 @@ var init_acorn = __esm({
};
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
var label = list2[i$1];
if (label.name === maybeName) {
var label2 = list2[i$1];
if (label2.name === maybeName) {
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
}
}
@@ -15147,7 +15147,8 @@ var init_compatibility = __esm({
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
refused_services: [
@@ -16662,6 +16663,52 @@ var init_agents = __esm({
}
});
// src/dsh/remote.ts
function label(value) {
if (value === void 0) return void 0;
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
return value;
}
function grant(value) {
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
return value;
}
var remoteAdapter;
var init_remote = __esm({
"src/dsh/remote.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_lib2();
init_protocol();
init_rpc();
remoteAdapter = {
service: "remote",
define(host2) {
class RemoteService extends Service {
constructor(ctx) {
super(ctx, "remote");
}
status() {
return host2.broker(this.ctx, "remote", "status", {});
}
pair(options) {
const value = label(options?.label);
return host2.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
}
clients() {
return host2.broker(this.ctx, "remote", "clients", {});
}
revoke(id) {
return host2.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
}
}
Object.freeze(RemoteService.prototype);
return RemoteService;
}
};
}
});
// src/dsh/adapters.ts
var DSH_ADAPTERS;
var init_adapters = __esm({
@@ -16670,7 +16717,8 @@ var init_adapters = __esm({
init_define_BUILTIN_MODULE_DIGESTS();
init_session_prompt();
init_agents();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
@@ -17449,10 +17497,10 @@ function describeError2(error) {
if (error instanceof Error) return `${error.name}: ${error.message}`;
return String(error);
}
function withDeadline(promise, ms, label) {
function withDeadline(promise, ms, label2) {
let timer;
const deadline = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} exceeded ${ms} ms`)), ms);
timer = setTimeout(() => reject(new Error(`${label2} exceeded ${ms} ms`)), ms);
timer.unref();
});
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer));
+78 -31
View File
@@ -755,7 +755,7 @@ var init_lib2 = __esm({
} else throw new TypeError("Invalid effect");
}, runner.getOuterStack);
}
effect(execute, label = "anonymous") {
effect(execute, label2 = "anonymous") {
this.assertActive();
if (this.state === 5) throw new CordisError("INACTIVE_EFFECT");
const disposables = [];
@@ -773,7 +773,7 @@ var init_lib2 = __esm({
return disposalTask = task2;
};
const meta = {
label,
label: label2,
children: []
};
const runner = {
@@ -1195,7 +1195,7 @@ var init_lib2 = __esm({
};
return dispatch();
}
register(label, name, callback, options) {
register(label2, name, callback, options) {
const method = options.prepend ? "unshift" : "push";
return this.ctx.fiber.effect(() => {
(this._hooks[name] ??= [])[method]({
@@ -1204,7 +1204,7 @@ var init_lib2 = __esm({
...options
});
return () => this.unregister(name, callback);
}, label);
}, label2);
}
unregister(name, callback) {
const hooks = this._hooks[name];
@@ -1233,8 +1233,8 @@ var init_lib2 = __esm({
listener = this.ctx.reflect.bind(listener);
const result = this.bail(this.ctx, "internal/listener", name, listener, options);
if (result) return result;
const label = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label, name, listener, options);
const label2 = `ctx.on(${typeof name === "string" ? JSON.stringify(name) : name.toString()})`;
return this.register(label2, name, listener, options);
}
/**
* Register an event listener that disposes itself after the first call.
@@ -1979,9 +1979,9 @@ var init_lib2 = __esm({
* @param label — scope label to join; defaults to a fresh unique symbol.
* @returns a child context whose `name` service resolves in the new scope.
*/
isolate(name, label) {
isolate(name, label2) {
const shadow = Object.create(this[symbols.isolate]);
shadow[name] = label ?? Symbol(name);
shadow[name] = label2 ?? Symbol(name);
return this.extend({ [symbols.isolate]: shadow });
}
intercept(name, config) {
@@ -2621,14 +2621,14 @@ function isolate(ctx) {
const delims = /* @__PURE__ */ Object.create(null);
function access2(entry, name, create = false) {
let realm;
const label = entry.options.isolate?.[name];
if (!label) return;
if (label === true) {
const label2 = entry.options.isolate?.[name];
if (!label2) return;
if (label2 === true) {
realm = entry.realm ??= new LocalRealm(entry);
} else if (create) {
realm = realms[label] ??= new GlobalRealm(label);
realm = realms[label2] ??= new GlobalRealm(label2);
} else {
realm = realms[label];
realm = realms[label2];
}
return realm?.access(name, create);
}
@@ -2685,10 +2685,10 @@ function isolate(ctx) {
}
});
ctx.on("loader/partial-dispose", (entry, legacy, active) => {
for (const [name, label] of Object.entries(legacy.isolate ?? {})) {
if (label === true) continue;
if (active && entry.options.isolate?.[name] === label) continue;
const realm = realms[label];
for (const [name, label2] of Object.entries(legacy.isolate ?? {})) {
if (label2 === true) continue;
if (active && entry.options.isolate?.[name] === label2) continue;
const realm = realms[label2];
if (!realm) continue;
for (const entry2 of ctx.loader.entries()) {
if (entry2.options.isolate?.[name] === realm.label) return;
@@ -2732,9 +2732,9 @@ var init_isolate = __esm({
}
};
GlobalRealm = class extends Realm {
constructor(label) {
constructor(label2) {
super();
this.label = label;
this.label = label2;
}
label;
get suffix() {
@@ -7348,9 +7348,9 @@ var init_acorn = __esm({
keywordRelationalOperator = /^in(stanceof)?$/;
nonASCIIidentifierStart = new RegExp("[" + nonASCIIidentifierStartChars + "]");
nonASCIIidentifier = new RegExp("[" + nonASCIIidentifierStartChars + nonASCIIidentifierChars + "]");
TokenType = function TokenType2(label, conf) {
TokenType = function TokenType2(label2, conf) {
if (conf === void 0) conf = {};
this.label = label;
this.label = label2;
this.keyword = conf.keyword;
this.beforeExpr = !!conf.beforeExpr;
this.startsExpr = !!conf.startsExpr;
@@ -8399,8 +8399,8 @@ var init_acorn = __esm({
};
pp$8.parseLabeledStatement = function(node, maybeName, expr, context) {
for (var i$1 = 0, list2 = this.labels; i$1 < list2.length; i$1 += 1) {
var label = list2[i$1];
if (label.name === maybeName) {
var label2 = list2[i$1];
if (label2.name === maybeName) {
this.raise(expr.start, "Label '" + maybeName + "' is already declared");
}
}
@@ -12773,7 +12773,8 @@ var init_compatibility = __esm({
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
refused_services: [
@@ -14159,6 +14160,51 @@ var init_agents = __esm({
}
});
// src/dsh/remote.ts
function label(value) {
if (value === void 0) return void 0;
if (typeof value !== "string") throw new RpcError(ErrorCode.InvalidParams, "remote.pair label must be a string");
return value;
}
function grant(value) {
if (typeof value !== "string" || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, "remote.revoke needs the grant id remote.pair returned");
return value;
}
var remoteAdapter;
var init_remote = __esm({
"src/dsh/remote.ts"() {
"use strict";
init_lib2();
init_protocol();
init_rpc();
remoteAdapter = {
service: "remote",
define(host) {
class RemoteService extends Service {
constructor(ctx) {
super(ctx, "remote");
}
status() {
return host.broker(this.ctx, "remote", "status", {});
}
pair(options) {
const value = label(options?.label);
return host.broker(this.ctx, "remote", "pair", value === void 0 ? {} : { label: value });
}
clients() {
return host.broker(this.ctx, "remote", "clients", {});
}
revoke(id) {
return host.broker(this.ctx, "remote", "revoke", { grant: grant(id) });
}
}
Object.freeze(RemoteService.prototype);
return RemoteService;
}
};
}
});
// src/dsh/adapters.ts
var DSH_ADAPTERS;
var init_adapters = __esm({
@@ -14166,7 +14212,8 @@ var init_adapters = __esm({
"use strict";
init_session_prompt();
init_agents();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS];
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
@@ -14395,16 +14442,16 @@ function entryListProblem(rows, at2 = "") {
return at2 === "" ? "the composition must be a top-level list of plugin rows" : `group ${at2} must hold a list of plugin rows`;
}
for (const [index, row] of rows.entries()) {
const label = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
const label2 = at2 === "" ? `row ${String(index + 1)}` : `${at2} row ${String(index + 1)}`;
if (typeof row !== "object" || row === null || Array.isArray(row)) {
return `${label} is not a plugin row (expected a map with a "name")`;
return `${label2} is not a plugin row (expected a map with a "name")`;
}
const { name, group, config } = row;
if (typeof name !== "string" || name === "") {
return `${label} names no plugin (a "name" string is required)`;
return `${label2} names no plugin (a "name" string is required)`;
}
if (group === true) {
const nested = entryListProblem(config, label);
const nested = entryListProblem(config, label2);
if (nested !== void 0) return nested;
}
}
@@ -14427,8 +14474,8 @@ async function unresolvableRows(rows, presetBase, harnessBase, resolves, io, at2
continue;
}
if (await rowResolves(classifyRowSpecifier(row.name), presetBase, harnessBase, resolves, io)) continue;
const label = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
found.push({ label, name: row.name });
const label2 = typeof row.id === "string" && row.id !== "" ? `row "${row.id}"` : positional;
found.push({ label: label2, name: row.name });
}
return found;
}
@@ -5,6 +5,7 @@ import type { RpcPeer } from '../rpc.ts'
import type { HostTier } from '../tier.ts'
import { sessionsAdapter, systemPromptAdapter } from './session-prompt.ts'
import { DSH_AGENT_ADAPTERS } from './agents.ts'
import { remoteAdapter } from './remote.ts'
export interface AdapterHost {
readonly tier: HostTier
@@ -20,4 +21,4 @@ export interface DshAdapter {
define(host: AdapterHost): new (ctx: any) => Service
}
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS]
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter]
@@ -23,7 +23,8 @@
"prompt",
"avatars",
"mcp",
"shellHooks"
"shellHooks",
"remote"
]
},
"refused_services": [
@@ -0,0 +1,92 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { AdapterHost, DshAdapter } from './adapters.ts'
export const CREDENTIAL_HANDLE_PREFIX = 'codewhale-credential:'
interface BrokeredResponse {
status: number
url: string
redirected: boolean
headers: [string, string][]
body_base64: string
}
function credentialRef(ref: unknown): string {
if (typeof ref !== 'string' || ref.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a credential ref is a non-empty string')
return ref
}
function brokered(value: unknown): BrokeredResponse {
const response = value as Partial<BrokeredResponse> | null
if (response === null || typeof response !== 'object' || typeof response.status !== 'number' || typeof response.url !== 'string'
|| typeof response.body_base64 !== 'string' || !Array.isArray(response.headers)) {
throw new RpcError(ErrorCode.Internal, 'invalid network broker response')
}
return response as BrokeredResponse
}
async function requestBody(body: unknown): Promise<string | undefined> {
if (body === undefined || body === null) return undefined
if (typeof body === 'string') return Buffer.from(body, 'utf8').toString('base64')
if (body instanceof ArrayBuffer) return Buffer.from(body).toString('base64')
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('base64')
if (body instanceof URLSearchParams) return Buffer.from(body.toString(), 'utf8').toString('base64')
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer()).toString('base64')
throw new RpcError(ErrorCode.InvalidParams, 'the network broker sends string, binary, URLSearchParams or Blob bodies')
}
export async function brokeredFetch(host: AdapterHost, ctx: unknown, input: string | URL | Request, init: RequestInit = {}): Promise<Response> {
const request = input instanceof Request ? input : undefined
const url = request?.url ?? String(input)
const headers = new Headers(request?.headers)
new Headers(init.headers).forEach((value, name) => headers.set(name, value))
const body = init.body !== undefined ? init.body : request !== undefined && request.body !== null ? await request.arrayBuffer() : undefined
const result = brokered(await host.broker(ctx, 'network', 'fetch', {
url,
method: init.method ?? request?.method ?? 'GET',
headers: [...headers.entries()],
body_base64: await requestBody(body),
}))
const bytes = Buffer.from(result.body_base64, 'base64')
const nullBody = [101, 204, 205, 304].includes(result.status)
const response = new Response(nullBody ? null : bytes, { status: result.status, headers: result.headers })
Object.defineProperty(response, 'url', { value: result.url })
Object.defineProperty(response, 'redirected', { value: result.redirected === true })
return response
}
export const credentialsAdapter: DshAdapter = {
service: 'credentials',
define(host) {
class CredentialsService extends Service {
constructor(ctx: any) { super(ctx, 'credentials') }
async resolve(ref: unknown): Promise<{ value: string } | undefined> {
const result = await host.broker(this.ctx, 'network', 'credentials.resolve', { ref: credentialRef(ref) }) as { value?: unknown } | null
const value = result?.value
if (value === null || value === undefined) return undefined
if (typeof value !== 'string' || !value.startsWith(CREDENTIAL_HANDLE_PREFIX)) throw new RpcError(ErrorCode.Internal, 'invalid credential handle')
return { value }
}
async describe(ref: unknown): Promise<{ ref: string, set: boolean }> {
const result = await host.broker(this.ctx, 'network', 'credentials.describe', { ref: credentialRef(ref) }) as { ref?: unknown, set?: unknown } | null
if (result === null || typeof result !== 'object' || typeof result.set !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid credential description')
return { ref: credentialRef(ref), set: result.set }
}
async set(ref: unknown, value: unknown): Promise<void> {
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a credential value is a non-empty string')
await host.broker(this.ctx, 'network', 'credentials.set', { ref: credentialRef(ref), value })
}
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
return brokeredFetch(host, this.ctx, input, init)
}
}
Object.freeze(CredentialsService.prototype)
return CredentialsService
},
}
@@ -0,0 +1,33 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { DshAdapter } from './adapters.ts'
function label(value: unknown): string | undefined {
if (value === undefined) return undefined
if (typeof value !== 'string') throw new RpcError(ErrorCode.InvalidParams, 'remote.pair label must be a string')
return value
}
function grant(value: unknown): string {
if (typeof value !== 'string' || value.length === 0 || value.length > 128) throw new RpcError(ErrorCode.InvalidParams, 'remote.revoke needs the grant id remote.pair returned')
return value
}
export const remoteAdapter: DshAdapter = {
service: 'remote',
define(host) {
class RemoteService extends Service {
constructor(ctx: any) { super(ctx, 'remote') }
status() { return host.broker(this.ctx, 'remote', 'status', {}) }
pair(options?: { label?: unknown }) {
const value = label(options?.label)
return host.broker(this.ctx, 'remote', 'pair', value === undefined ? {} : { label: value })
}
clients() { return host.broker(this.ctx, 'remote', 'clients', {}) }
revoke(id: unknown) { return host.broker(this.ctx, 'remote', 'revoke', { grant: grant(id) }) }
}
Object.freeze(RemoteService.prototype)
return RemoteService
},
}
@@ -0,0 +1,64 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { DshAdapter } from './adapters.ts'
const SESSION = /^[A-Za-z0-9._-]{1,64}$/
function invalid(message: string): never {
throw new RpcError(ErrorCode.InvalidParams, message)
}
function session(value: unknown): string {
if (typeof value !== 'string' || !SESSION.test(value)) invalid('a terminal name is 1 to 64 letters, digits, `_`, `-` or `.`')
return value as string
}
function text(value: unknown, what: string): string {
if (typeof value !== 'string' || value.length === 0) invalid(`${what} is a non-empty string`)
return value as string
}
function bounded(value: unknown, what: string, min: number, max: number): number | undefined {
if (value === undefined) return undefined
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) invalid(`${what} is an integer between ${min} and ${max}`)
return value as number
}
function compact(input: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined))
}
export const terminalAdapter: DshAdapter = {
service: 'terminal',
define(host) {
class TerminalService extends Service {
constructor(ctx: any) { super(ctx, 'terminal') }
list() { return host.broker(this.ctx, 'terminal', 'list', {}) }
run(command: unknown, options?: { session?: unknown, timeoutSecs?: unknown }) {
return host.broker(this.ctx, 'terminal', 'run', compact({
command: text(command, 'a command'),
session: options?.session === undefined ? undefined : session(options.session),
timeout_secs: bounded(options?.timeoutSecs, 'timeoutSecs', 1, 600),
}))
}
send(name: unknown, input: unknown, options?: { waitMs?: unknown }) {
return host.broker(this.ctx, 'terminal', 'send', compact({
session: session(name),
text: text(input, 'terminal input'),
wait_ms: bounded(options?.waitMs, 'waitMs', 0, 60_000),
}))
}
wait(name: unknown, options?: { timeoutSecs?: unknown }) {
return host.broker(this.ctx, 'terminal', 'wait', compact({
session: session(name),
timeout_secs: bounded(options?.timeoutSecs, 'timeoutSecs', 1, 600),
}))
}
cancel(name: unknown) { return host.broker(this.ctx, 'terminal', 'cancel', { session: session(name) }) }
reset(name: unknown) { return host.broker(this.ctx, 'terminal', 'reset', { session: session(name) }) }
}
Object.freeze(TerminalService.prototype)
return TerminalService
},
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,17 @@
# DeepSeek Harness creator files, pinned
Byte-for-byte copies of the files DeepSeek Harness 0.2.2-rc.1 uses for its
Creator mode, kept so Codewhale hosts the same tool and skill text rather than
a rewrite. `UPSTREAM.json` records the package, version, upstream path and
SHA-256 of every file; `test/creator-pin.test.mjs` fails when a file drifts.
- `plugin-manager/tools.js`: `@deepseek-ai/dsh-plugin-manager` `lib/types/tools.js`
- `tool-cordis/index.js`: `@deepseek-ai/dsh-tool-cordis` `lib/index.js`
- `sandbox/escalation.js`: not yet pinned (see `UPSTREAM.json`, `pending`)
The four authoring skills of `@deepseek-ai/dsh-agent-preset` are pinned beside
the skills crate at `crates/skills/assets/dsh-creator/`.
The files are MIT licensed by DeepSeek; `LICENSE` is the licence text shipped
in each of the four packages. Nothing here is edited: change the pin by
replacing the file and its recorded hash together, at a new DSH version.
@@ -0,0 +1,39 @@
{
"repository": "https://github.com/deepseek-ai/deepseek-harness",
"version": "0.2.2-rc.1",
"license": "MIT",
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"source": "npm packages as shipped in DeepSeek Harness desktop 0.2.2-rc.1",
"packages": [
"@deepseek-ai/dsh-agent-preset",
"@deepseek-ai/dsh-plugin-manager",
"@deepseek-ai/dsh-tool-cordis",
"@deepseek-ai/dsh-sandbox"
],
"files": [
{
"path": "plugin-manager/tools.js",
"package": "@deepseek-ai/dsh-plugin-manager",
"upstream_path": "lib/types/tools.js",
"sha256": "18d9f06f20a821bd205b6d88f87f440659730ef9431fb92351a50d86779da3ca",
"bytes": 12583,
"verbatim": true
},
{
"path": "tool-cordis/index.js",
"package": "@deepseek-ai/dsh-tool-cordis",
"upstream_path": "lib/index.js",
"sha256": "ce0cd4d8983532c920cab7713dc51874f6d710c2c46c4a095167bfa980becdb0",
"bytes": 3687,
"verbatim": true
}
],
"pending": [
{
"path": "sandbox/escalation.js",
"package": "@deepseek-ai/dsh-sandbox",
"reason": "excerpt of approveEscalation and its mode table; needs the published tarball, not yet fetched"
}
],
"skills_pin": "crates/skills/assets/dsh-creator/UPSTREAM.json"
}
@@ -0,0 +1,154 @@
/** Agent-facing current-profile management using the same service as Web controls. */
import { assertNever } from '@deepseek-ai/dsh-util-values';
import { approveEscalation } from '@deepseek-ai/dsh-sandbox';
import { defineTool } from '@deepseek-ai/dsh-tools';
import { getDshRuntimeVersion } from '@deepseek-ai/dsh-app-boot';
/** What the model does next for each refusal the manager reports. */
const ERROR_STEPS = {
'management-required': 'This entry belongs to plugin management itself and cannot be changed.',
'unaddressable': 'This entry has no unique profile patch target; tell the user to edit the profile patch file directly.',
'unknown-plugin': 'Call list_plugins and retry with an exact entryId.',
'invalid-spec': 'Retry with an npm package name, a git URL, a tarball URL, or an absolute local directory.',
'already-installed': 'The package is already installed; to install another version, call remove_bundle first, then install_bundle again.',
'ambiguous-install': 'The dependency change named no single package; call list_bundles to see what was installed.',
'not-bundle': 'The package declares no dsh.bundle and cannot be managed as a plugin; install a DSH plugin bundle instead.',
'not-removable': 'Call list_bundles; only bundles marked removable can be removed.',
'stop-profile': 'This profile runs without HMR; tell the user to stop DSH and run `dsh plugin --profile <profile> remove <bundle>`.',
'bundle-in-use': 'Call list_plugins, disable the entries from other layers that still use this bundle, then retry.',
'stale-approval': 'Call install_bundle again without approvedBuilds to read the current pendingBuilds.',
'incompatible-version': 'Install a version compatible with the running DSH. A version exemption needs the user\'s explicit acceptance of crash and data-loss risk.',
'unsupported-platform': 'This bundle cannot run on the current Host platform; ask the user to use a supported Host.',
'operation-error': 'Report the diagnostic to the user; retry only after its cause is addressed.',
};
/** What the model does next for each package-manager failure. */
const FAILURE_STEPS = {
'pnpm-missing': 'pnpm is not available to DSH; tell the user to install pnpm.',
'timeout': 'Retry, or retry with another registry.',
'not-found': 'Check the package name and version with the user, then retry.',
'no-matching-version': 'Retry with a published version.',
'network': 'Retry later or pass another registry.',
'disk-full': 'Tell the user to free disk space, then retry.',
'permission': 'Tell the user DSH cannot write the profile directory; retry after they fix its permissions.',
'build-blocked': 'Ask the user whether to allow the install scripts of the packages in pendingBuilds; if they approve, retry with approvedBuilds.',
};
/** Why a change waits for a restart, as the model tells the user. */
const RESTART_STEPS = {
'no-hmr': 'this profile applies changes only at startup',
'package-moved': 'an installed package now resolves to another directory, or the change replaces a package the DSH installation provides; remove a package before installing it from another location',
'host-module': 'the change reaches modules DSH itself loaded',
'unmanaged-plugin': 'the change reaches a plugin running outside the plugin list',
'module-comparison': 'DSH could not compare loaded plugins with their files',
};
/**
* The model-facing result of a change: the manager's result plus the next step its outcome calls for.
* @param result The manager's result.
* @returns The result with `nextStep` when it is not plainly applied.
*/
function withNextStep(result) {
switch (result.application) {
case 'applied':
return result;
case 'cancelled':
return { ...result, nextStep: 'The installation was cancelled and its files restored; nothing changed.' };
case 'overridden':
return { ...result, nextStep: 'Saved, but a higher-priority layer such as the agent preset overrides it; tell the user it is not in effect.' };
case 'restart-required': {
const reason = result.restartReason === undefined ? '' : `: ${RESTART_STEPS[result.restartReason]}`;
return { ...result, nextStep: `Tell the user to restart DSH to apply this change${reason}.` };
}
case 'failed': {
const code = result.error?.code ?? 'operation-error';
// A refusal code is more specific than the package manager's failure kind.
const kind = code === 'operation-error' ? result.packageResult?.kind : undefined;
return { ...result, nextStep: (kind === undefined ? undefined : FAILURE_STEPS[kind]) ?? ERROR_STEPS[code] };
}
/* v8 ignore next -- ChangeResult.application is a closed union */
default: return assertNever(result.application);
}
}
/** Required services for the management tool. */
export const inject = ['tools', 'pluginManager', 'sandboxPolicy'];
/** Register one management tool for discovery and the four persistent actions.
* @param ctx Agent-scoped tool registration context.
*/
export function apply(ctx) {
ctx.tools.register(defineTool({
name: 'plugin_manager',
description: 'List plugins or bundles in the current profile, enable or disable them, install a bundle, or remove an installed bundle. Every action requires danger-full-access permission or approval for this call. Approval does not change the session permission mode. Changes affect every session in this profile. List first to obtain exact identifiers. Package installation can execute allowed build scripts. Live profiles apply changes immediately, including upgrades of installed packages; a change result that is not applied carries nextStep, which says what to do or tell the user. Unmet DSH version requirements (engines.dsh, else DSH peer dependencies) block installation and activation. Version exemptions risk crashes and data loss: warn the user and obtain explicit permission for the exact plugin and runtime versions before granting one.',
parameters: {
action: { type: 'string', required: true, enum: ['list_plugins', 'list_bundles', 'set_plugin', 'set_bundle', 'install_bundle', 'remove_bundle', 'list_version_exemptions', 'set_version_exemption'], description: 'Management operation.' },
target: { type: 'string', description: 'Plugin entry id, bundle package name, or installation spec, according to action. For an Official catalog entry, pass list_bundles installTarget.spec to install_bundle.' },
enabled: { type: 'boolean', description: 'Required for set operations; defaults to true for installation. For set_version_exemption, true grants and false revokes.' },
runtimeVersion: { type: 'string', description: 'For set_version_exemption: exact DSH version from list_version_exemptions. Target must be the manifest package-name@version, not an alias or version range.' },
acceptRisk: { type: 'boolean', description: 'For granting an exemption: true only after warning the user about possible crashes and data loss and receiving explicit permission for this exact plugin/runtime pair. General installation permission is not enough.' },
approvedBuilds: { type: 'array', items: { type: 'string' }, description: 'For install_bundle: pass names from pendingBuilds only after the user explicitly approves running their install scripts in the conversation. This grants persistent permission for this profile.' },
registry: { type: 'string', description: 'For install_bundle: the npm registry URL asked first, when the user names one; otherwise the configured registry is asked, and its configured fallbacks while a registry is unreachable.' },
offset: { type: 'number', description: 'Zero-based list offset; defaults to 0.' },
limit: { type: 'number', description: 'List page size, from 1 to 100; defaults to 25.' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args, exec) {
const policy = ctx.sandboxPolicy.resolve(exec.agent === undefined ? {} : { session: exec.agent.session });
await approveEscalation({
requestedMode: 'danger-full-access', effectiveMode: policy.mode, subject: 'plugin management operation',
justification: `plugin_manager ${JSON.stringify(args)}. Profile changes persist across sessions; installed Host code runs outside the workspace sandbox.`,
}, { approver: ctx.get('approval'), agent: exec.agent, callId: exec.callId,
toolName: 'plugin_manager', signal: exec.signal });
exec.signal.throwIfAborted();
const manager = ctx.pluginManager;
switch (args.action) {
case 'list_version_exemptions':
return JSON.stringify({ runtimeVersion: getDshRuntimeVersion(), ...manager.listVersionExemptions() });
case 'set_version_exemption':
if (args.target === undefined || args.runtimeVersion === undefined || args.enabled === undefined) {
throw new Error('target package-name@version, runtimeVersion, and enabled are required');
}
return JSON.stringify(withNextStep(await manager.setVersionExemption(args.target, args.runtimeVersion, args.enabled, args.acceptRisk)));
case 'list_plugins':
case 'list_bundles': {
const offset = args.offset ?? 0;
const limit = args.limit ?? 25;
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 100) {
throw new Error('offset must be a non-negative integer and limit must be an integer from 1 to 100');
}
const rows = args.action === 'list_plugins' ? await manager.listPlugins() : await manager.listBundles();
const entries = rows.slice(offset, offset + limit).map(({ meta: _meta, ...row }) => 'rows' in row
// GUI listing and update eligibility do not change the tool's management operations.
? (({ updatable: _updatable, listedByDefault: _listedByDefault, ...bundle }) => ({
...bundle, rows: bundle.rows.map(({ meta: _rowMeta, ...declared }) => declared),
}))(row)
: row);
return JSON.stringify({ entries, total: rows.length,
nextOffset: offset + entries.length < rows.length ? offset + entries.length : null });
}
case 'set_plugin':
case 'set_bundle': {
if (args.target === undefined || args.enabled === undefined)
throw new Error('target and enabled are required');
return JSON.stringify(withNextStep(await (args.action === 'set_plugin'
? manager.setPluginEnabled(args.target, args.enabled)
: manager.setBundleEnabled(args.target, args.enabled))));
}
case 'install_bundle':
if (args.target === undefined)
throw new Error('target package spec is required');
return JSON.stringify(withNextStep(await manager.installBundle(args.target, {
...args.enabled === undefined ? {} : { enabled: args.enabled },
...args.approvedBuilds === undefined ? {} : { approvedBuilds: args.approvedBuilds },
...args.registry === undefined ? {} : { registry: args.registry },
})));
case 'remove_bundle':
if (args.target === undefined)
throw new Error('target bundle name is required');
return JSON.stringify(withNextStep(await manager.removeBundle(args.target)));
/* v8 ignore next -- tool JSON validation rejects actions outside the declared enum */
default: return assertNever(args.action);
}
},
presentCall: args => ({ card: 'generic', title: 'Manage profile plugins', kind: args.action.startsWith('list_') ? 'read' : 'other', rawInput: args }),
}));
}
//# sourceMappingURL=tools.js.map
@@ -0,0 +1,100 @@
import { defineTool } from "@deepseek-ai/dsh-tools";
//#region lib/types/present.js
/**
* Render provider-directory inspection.
* @returns replay-safe generic call presentation.
*/
function presentInspectListCall() {
return {
card: "generic",
kind: "read",
title: "List Cordis Inspect Providers"
};
}
/**
* Render one provider query.
* @param args - target platform, provider, and method.
* @returns replay-safe generic call presentation.
*/
function presentInspectQueryCall(args) {
return {
card: "generic",
kind: "read",
title: `Query Cordis ${args.platform} ${args.provider}.${args.method}`
};
}
//#endregion
//#region lib/types/index.js
const name = "tool-cordis";
const inject = ["tools", "cordisInspect"];
function requireAgent(exec) {
if (exec.agent === void 0) throw new Error("Cordis inspection requires an Agent-backed session");
return exec.agent;
}
/** Register read-only runtime inspection tools over the Host providers that
* `@deepseek-ai/dsh-tool-cordis/host` registers once per process.
* @param ctx Agent-scoped registration context.
*/
function apply(ctx) {
ctx.tools.register(defineTool({
name: "cordis_inspect_list",
description: "List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before writing or configuring a plugin, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.",
parameters: {},
output: {
schema: { type: "json" },
render: (_args, value) => [{
type: "text",
text: JSON.stringify(value, null, 2)
}]
},
execute(_args, _exec) {
return Promise.resolve({ providers: ctx.cordisInspect.list() });
},
presentCall: presentInspectListCall
}));
ctx.tools.register(defineTool({
name: "cordis_inspect_query",
description: "Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, plugin Config schemas, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response within the configured timeout; otherwise it reports a Client failure or asks you to reconnect and retry. This Tool cannot invoke business Service methods or modify the runtime.",
parameters: {
platform: {
type: "string",
required: true,
enum: ["host", "client"],
description: "Runtime platform that owns the Provider."
},
provider: {
type: "string",
required: true,
description: "Exact Provider ID returned by cordis_inspect_list."
},
method: {
type: "string",
required: true,
description: "Exact method name declared by the Provider manifest."
},
input: {
type: "json",
description: "Optional query input; it must satisfy the method input schema."
}
},
output: {
schema: { type: "json" },
render: (_args, value) => [{
type: "text",
text: JSON.stringify(value, null, 2)
}]
},
async execute(args, exec) {
const data = await ctx.cordisInspect.query(args.platform, args.provider, args.method, args.input, requireAgent(exec), exec.signal);
return {
platform: args.platform,
provider: args.provider,
method: args.method,
data
};
},
presentCall: presentInspectQueryCall
}));
}
//#endregion
export { apply, inject, name };
@@ -0,0 +1,70 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { DshAdapter } from './adapters.ts'
const MAX_PATH = 512
const MAX_WRITE = 1024 * 1024
const MAX_ARGV = 9
const MAX_ARG = 1024
function invalid(message: string): never {
throw new RpcError(ErrorCode.InvalidParams, message)
}
function path(value: unknown): string {
if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PATH || value.trim() !== value || /[\x00-\x1f\x7f]/.test(value)) {
invalid(`a path is 1 to ${MAX_PATH} characters on one line with no surrounding spaces`)
}
return value as string
}
function bounded(value: unknown, what: string, min: number, max: number): number | undefined {
if (value === undefined) return undefined
if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) invalid(`${what} is an integer between ${min} and ${max}`)
return value as number
}
function argv(value: unknown): string[] {
if (!Array.isArray(value) || value.length === 0 || value.length > MAX_ARGV || value.some(arg => typeof arg !== 'string' || arg.length > MAX_ARG || /[\x00-\x1f\x7f]/.test(arg))) {
invalid(`argv is 1 to ${MAX_ARGV} strings of at most ${MAX_ARG} characters`)
}
return [...value as string[]]
}
function compact(input: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined))
}
export const workspaceAdapter: DshAdapter = {
service: 'workspace',
define(host) {
class WorkspaceService extends Service {
constructor(ctx: any) { super(ctx, 'workspace') }
read(file: unknown, options?: { startLine?: unknown, maxLines?: unknown }) {
return host.broker(this.ctx, 'workspace', 'read', compact({
path: path(file),
start_line: bounded(options?.startLine, 'startLine', 1, Number.MAX_SAFE_INTEGER),
max_lines: bounded(options?.maxLines, 'maxLines', 1, 10_000),
}))
}
list(dir?: unknown) {
return host.broker(this.ctx, 'workspace', 'list', compact({ path: dir === undefined ? undefined : path(dir) }))
}
write(file: unknown, content: unknown, options?: { expectedHash?: unknown }) {
if (typeof content !== 'string' || Buffer.byteLength(content) > MAX_WRITE) invalid(`content is a string of at most ${MAX_WRITE} bytes`)
const hash = options?.expectedHash
if (hash !== undefined && (typeof hash !== 'string' || hash.length === 0 || hash.length > 128)) invalid('expectedHash is 1 to 128 characters')
return host.broker(this.ctx, 'workspace', 'write', compact({ path: path(file), content, expected_hash: hash }))
}
exec(command: unknown, options?: { cwd?: unknown }) {
return host.broker(this.ctx, 'workspace', 'exec', compact({
argv: argv(command),
cwd: options?.cwd === undefined ? undefined : path(options.cwd),
}))
}
}
Object.freeze(WorkspaceService.prototype)
return WorkspaceService
},
}
@@ -44,7 +44,7 @@ before(async () => {
const adapterFor = (service) => ({ service, define: () => class extends Service { constructor(ctx) { super(ctx, service) } } })
test('the shipped adapter table holds the reviewed adapters, and the root still builds', () => {
assert.deepEqual(DSH_ADAPTERS.map((adapter) => adapter.service), ['sessions', 'systemPrompt', 'subagents', 'agents'])
assert.deepEqual(DSH_ADAPTERS.map((adapter) => adapter.service), ['sessions', 'systemPrompt', 'subagents', 'agents', 'remote'])
assert.ok(new HostRoot(rpc, 'plugin'))
assert.ok(new HostRoot(rpc, 'builtin'))
})
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFileSync, readdirSync, statSync } from 'node:fs'
import { dirname, join, relative, sep } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const pin = join(here, '..', 'src', 'dsh', 'upstream', 'creator')
const manifest = JSON.parse(readFileSync(join(pin, 'UPSTREAM.json'), 'utf8'))
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
function walk(dir) {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name)
return statSync(path).isDirectory() ? walk(path) : [path]
})
}
test('every pinned creator file equals its recorded hash and is verbatim', () => {
assert.equal(manifest.version, '0.2.2-rc.1')
assert.ok(manifest.files.length >= 2)
for (const entry of manifest.files) {
assert.equal(entry.verbatim, true, entry.path)
const bytes = readFileSync(join(pin, entry.path))
assert.equal(bytes.length, entry.bytes, `${entry.path} size`)
assert.equal(sha256(bytes), entry.sha256, `${entry.path} hash`)
assert.ok(!bytes.includes(13), `${entry.path} must keep LF line endings`)
}
})
test('the pin directory holds exactly the files the manifest lists', () => {
const listed = new Set([...manifest.files.map((entry) => entry.path), 'UPSTREAM.json', 'LICENSE', 'README.md'])
const present = walk(pin).map((path) => relative(pin, path).split(sep).join('/'))
assert.deepEqual(present.sort(), [...listed].sort())
})
test('the licence is the MIT text the four packages ship', () => {
const license = readFileSync(join(pin, 'LICENSE'))
assert.equal(sha256(license), manifest.license_sha256)
assert.match(license.toString('utf8'), /^MIT License\n\nCopyright \(c\) 2026 DeepSeek\n/)
assert.deepEqual(manifest.packages.sort(), [
'@deepseek-ai/dsh-agent-preset',
'@deepseek-ai/dsh-plugin-manager',
'@deepseek-ai/dsh-sandbox',
'@deepseek-ai/dsh-tool-cordis',
])
})
@@ -0,0 +1,88 @@
import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const here = dirname(fileURLToPath(import.meta.url))
let credentialsAdapter
let brokeredFetch
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-network-'))
const outfile = join(dir, 'network.mjs')
await build({ entryPoints: [join(here, '../src/dsh/network.ts')], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
;({ credentialsAdapter, brokeredFetch } = await import(pathToFileURL(outfile).href))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
function brokerHost(answers) {
const calls = []
return {
calls,
host: {
tier: 'plugin',
broker: async (ctx, service, method, input) => {
calls.push({ service, method, input })
const answer = answers[method]
if (answer instanceof Error) throw answer
return typeof answer === 'function' ? answer(input) : answer
},
},
}
}
test('credentials resolve to an opaque handle and go only to the network broker', async () => {
const { host, calls } = brokerHost({
'credentials.resolve': { ref: 'KEY', value: 'codewhale-credential:KEY' },
'credentials.describe': { ref: 'KEY', set: true },
'credentials.set': { ref: 'KEY', set: true },
})
const Credentials = credentialsAdapter.define(host)
const service = Object.create(Credentials.prototype)
service.ctx = {}
assert.deepEqual(await service.resolve('KEY'), { value: 'codewhale-credential:KEY' })
assert.deepEqual(await service.describe('KEY'), { ref: 'KEY', set: true })
await service.set('KEY', 'sk-secret')
assert.deepEqual(calls.map((c) => [c.service, c.method]), [
['network', 'credentials.resolve'], ['network', 'credentials.describe'], ['network', 'credentials.set'],
])
await assert.rejects(service.resolve(''), /non-empty/)
})
test('a resolve answer that is not a handle is refused rather than passed on', async () => {
const { host } = brokerHost({ 'credentials.resolve': { value: 'sk-raw' } })
const service = Object.create(credentialsAdapter.define(host).prototype)
service.ctx = {}
await assert.rejects(service.resolve('KEY'), /invalid credential handle/)
const unset = Object.create(credentialsAdapter.define(brokerHost({ 'credentials.resolve': { value: null } }).host).prototype)
unset.ctx = {}
assert.equal(await unset.resolve('KEY'), undefined)
})
test('brokeredFetch sends a typed fetch and rebuilds a Response from the broker answer', async () => {
const { host, calls } = brokerHost({
fetch: {
status: 201, url: 'https://api.example.com/v1/x', redirected: true,
headers: [['content-type', 'application/json']],
body_base64: Buffer.from('{"ok":true}').toString('base64'),
},
})
const response = await brokeredFetch(host, {}, 'https://api.example.com/v1/x', {
method: 'POST', headers: { authorization: 'Bearer codewhale-credential:KEY' }, body: 'hi',
})
assert.equal(response.status, 201)
assert.equal(response.url, 'https://api.example.com/v1/x')
assert.equal(response.redirected, true)
assert.deepEqual(await response.json(), { ok: true })
assert.equal(calls[0].service, 'network')
assert.equal(calls[0].method, 'fetch')
assert.deepEqual(calls[0].input.headers, [['authorization', 'Bearer codewhale-credential:KEY']])
assert.equal(Buffer.from(calls[0].input.body_base64, 'base64').toString(), 'hi')
})
test('a broker refusal reaches the plugin as the refusal', async () => {
const { host } = brokerHost({ fetch: new Error('egress refused: `evil.example` is not one of this plugin\'s reviewed network hosts') })
await assert.rejects(brokeredFetch(host, {}, 'https://evil.example/'), /egress refused/)
})
@@ -0,0 +1,68 @@
import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const here = dirname(fileURLToPath(import.meta.url))
let remoteAdapter
let Context
let ErrorCode
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-remote-'))
const bundle = async (entry, name) => {
const outfile = join(dir, name)
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
return import(pathToFileURL(outfile).href)
}
;({ remoteAdapter } = await bundle('../src/dsh/remote.ts', 'remote.mjs'))
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
;({ Context } = await import('@deepseek-ai/cordis'))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
async function harness() {
const calls = []
const host = {
tier: 'plugin',
rpc: { request: async () => { throw new Error('remote never calls the core directly') }, notify() {} },
ownerOf: () => undefined,
liveInvocation: () => undefined,
broker: async (ctx, service, method, input) => { calls.push([service, method, input]); return { ok: method } },
log() {},
}
const ctx = new Context()
ctx.plugin(remoteAdapter.define(host))
await settle()
return { ctx, calls }
}
test('remote sends every method through the remote broker with a typed payload and nothing else', async () => {
const { ctx, calls } = await harness()
assert.deepEqual(await ctx.remote.status(), { ok: 'status' })
await ctx.remote.pair()
await ctx.remote.pair({ label: 'phone' })
await ctx.remote.clients()
await ctx.remote.revoke('cwrg_1')
assert.deepEqual(calls, [
['remote', 'status', {}],
['remote', 'pair', {}],
['remote', 'pair', { label: 'phone' }],
['remote', 'clients', {}],
['remote', 'revoke', { grant: 'cwrg_1' }],
])
})
test('remote refuses malformed arguments locally, before any broker call', async () => {
const { ctx, calls } = await harness()
assert.throws(() => ctx.remote.pair({ label: 3 }), (error) => error.code === ErrorCode.InvalidParams)
for (const id of [undefined, '', 7, 'x'.repeat(129)]) {
assert.throws(() => ctx.remote.revoke(id), (error) => error.code === ErrorCode.InvalidParams)
}
assert.equal(typeof ctx.remote.listen, 'undefined')
assert.deepEqual(calls, [])
})
@@ -0,0 +1,85 @@
import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const here = dirname(fileURLToPath(import.meta.url))
let terminalAdapter
let Context
let ErrorCode
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-terminal-'))
const bundle = async (entry, name) => {
const outfile = join(dir, name)
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
return import(pathToFileURL(outfile).href)
}
;({ terminalAdapter } = await bundle('../src/dsh/terminal.ts', 'terminal.mjs'))
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
;({ Context } = await import('@deepseek-ai/cordis'))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
async function harness() {
const calls = []
const host = {
tier: 'plugin',
rpc: { request: async () => { throw new Error('terminal never calls the core directly') }, notify() {} },
ownerOf: () => undefined,
liveInvocation: () => undefined,
broker: async (ctx, service, method, input) => { calls.push([service, method, input]); return { ok: method } },
log() {},
}
const ctx = new Context()
ctx.plugin(terminalAdapter.define(host))
await settle()
return { ctx, calls }
}
test('terminal sends every method through the terminal broker with a typed payload and nothing else', async () => {
const { ctx, calls } = await harness()
assert.deepEqual(await ctx.terminal.list(), { ok: 'list' })
await ctx.terminal.run('make')
await ctx.terminal.run('make', { session: 'build', timeoutSecs: 30, cwd: '/', sandbox_permissions: 'danger' })
await ctx.terminal.send('build', 'y\n', { waitMs: 0 })
await ctx.terminal.wait('build')
await ctx.terminal.wait('build', { timeoutSecs: 600 })
await ctx.terminal.cancel('build')
await ctx.terminal.reset('build')
assert.deepEqual(calls, [
['terminal', 'list', {}],
['terminal', 'run', { command: 'make' }],
['terminal', 'run', { command: 'make', session: 'build', timeout_secs: 30 }],
['terminal', 'send', { session: 'build', text: 'y\n', wait_ms: 0 }],
['terminal', 'wait', { session: 'build' }],
['terminal', 'wait', { session: 'build', timeout_secs: 600 }],
['terminal', 'cancel', { session: 'build' }],
['terminal', 'reset', { session: 'build' }],
])
})
test('terminal refuses malformed arguments locally, before any broker call', async () => {
const { ctx, calls } = await harness()
const refused = (act) => assert.throws(act, (error) => error.code === ErrorCode.InvalidParams)
for (const command of [undefined, '', 7, ['ls']]) refused(() => ctx.terminal.run(command))
for (const name of [undefined, '', 7, '../term-1', 'dsh:b:x', 'a b', 'n'.repeat(65)]) {
refused(() => ctx.terminal.run('true', { session: name === undefined ? null : name }))
refused(() => ctx.terminal.send(name, 'x'))
refused(() => ctx.terminal.wait(name))
refused(() => ctx.terminal.cancel(name))
refused(() => ctx.terminal.reset(name))
}
refused(() => ctx.terminal.send('build', ''))
for (const secs of [0, 601, 1.5, '30']) {
refused(() => ctx.terminal.run('true', { timeoutSecs: secs }))
refused(() => ctx.terminal.wait('build', { timeoutSecs: secs }))
}
refused(() => ctx.terminal.send('build', 'x', { waitMs: 60_001 }))
for (const absent of ['spawn', 'kill', 'resize', 'read', 'exec']) assert.equal(typeof ctx.terminal[absent], 'undefined')
assert.deepEqual(calls, [])
})
@@ -0,0 +1,80 @@
import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const here = dirname(fileURLToPath(import.meta.url))
let workspaceAdapter
let Context
let ErrorCode
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-workspace-'))
const bundle = async (entry, name) => {
const outfile = join(dir, name)
await build({ entryPoints: [join(here, entry)], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
return import(pathToFileURL(outfile).href)
}
;({ workspaceAdapter } = await bundle('../src/dsh/workspace.ts', 'workspace.mjs'))
;({ ErrorCode } = await bundle('../src/protocol.ts', 'protocol.mjs'))
;({ Context } = await import('@deepseek-ai/cordis'))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
const settle = () => new Promise((resolve) => setTimeout(resolve, 0))
async function harness() {
const calls = []
const host = {
tier: 'plugin',
rpc: { request: async () => { throw new Error('workspace never calls the core directly') }, notify() {} },
ownerOf: () => undefined,
liveInvocation: () => undefined,
broker: async (ctx, service, method, input) => { calls.push([service, method, input]); return { ok: method } },
log() {},
}
const ctx = new Context()
ctx.plugin(workspaceAdapter.define(host))
await settle()
return { ctx, calls }
}
test('workspace sends every method through the workspace broker with a typed payload and nothing else', async () => {
const { ctx, calls } = await harness()
assert.deepEqual(await ctx.workspace.read('src/a.ts'), { ok: 'read' })
await ctx.workspace.read('src/a.ts', { startLine: 3, maxLines: 10, follow: true })
await ctx.workspace.list()
await ctx.workspace.list('src')
await ctx.workspace.write('src/b.ts', 'x', { expectedHash: 'h', mode: 0o777 })
await ctx.workspace.write('src/c.ts', '')
await ctx.workspace.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: '/w', env: { GIT_DIR: '/' }, shell: true })
assert.deepEqual(calls, [
['workspace', 'read', { path: 'src/a.ts' }],
['workspace', 'read', { path: 'src/a.ts', start_line: 3, max_lines: 10 }],
['workspace', 'list', {}],
['workspace', 'list', { path: 'src' }],
['workspace', 'write', { path: 'src/b.ts', content: 'x', expected_hash: 'h' }],
['workspace', 'write', { path: 'src/c.ts', content: '' }],
['workspace', 'exec', { argv: ['git', 'rev-parse', '--show-toplevel'], cwd: '/w' }],
])
})
test('workspace refuses malformed arguments locally, before any broker call', async () => {
const { ctx, calls } = await harness()
const refused = (act) => assert.throws(act, (error) => error.code === ErrorCode.InvalidParams)
for (const bad of [undefined, '', 7, ' a', 'a\nb', 'p'.repeat(513), ['a']]) {
refused(() => ctx.workspace.read(bad))
refused(() => ctx.workspace.write(bad, 'x'))
if (bad !== undefined) refused(() => ctx.workspace.list(bad))
}
for (const lines of [0, 10_001, 1.5, '3']) refused(() => ctx.workspace.read('a', { maxLines: lines }))
refused(() => ctx.workspace.read('a', { startLine: 0 }))
for (const content of [undefined, 7, 'c'.repeat(1024 * 1024 + 1)]) refused(() => ctx.workspace.write('a', content))
refused(() => ctx.workspace.write('a', 'x', { expectedHash: '' }))
for (const command of [undefined, 'git status', [], [7], ['git', 'a\u0000b'], new Array(10).fill('git')]) refused(() => ctx.workspace.exec(command))
refused(() => ctx.workspace.exec(['git'], { cwd: '' }))
for (const absent of ['delete', 'rename', 'spawn', 'shell', 'watch', 'readSync']) assert.equal(typeof ctx.workspace[absent], 'undefined')
assert.deepEqual(calls, [])
})
+12
View File
@@ -1942,6 +1942,18 @@ mod tests {
Some("disabled"),
"{body}"
);
let mut preview_client = modelstudio_test_client(
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
);
preview_client.route_facts = preview_client.catalog_facts_for_model("qwen4-max-preview");
for effort in [None, Some("high"), Some("max"), Some("off")] {
let mut request = request_with("qwen4-max-preview", effort, None, None);
request.max_tokens = 64_000;
let body = preview_client.build_anthropic_body(&request, true);
assert!(body.get("thinking").is_none(), "{effort:?}: {body}");
assert!(body.get("output_config").is_none(), "{effort:?}: {body}");
}
}
#[test]
+1
View File
@@ -5443,6 +5443,7 @@ mod alias_thinking_detection_tests {
"foo-reasoner",
"new-model-reasoning",
"acme-reasoner-v2",
"qwen4-max-preview",
] {
assert!(
!should_replay_reasoning_content_for_provider_on_route(
+234
View File
@@ -0,0 +1,234 @@
use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::Serialize;
use crate::extension_host::ExtensionHostOptions;
pub const STANDARD: &str = "standard";
pub const CREATOR: &str = "creator";
pub const SOURCE_BUILTIN: &str = "builtin";
pub const CREATOR_LOADS_ITS_OWN_TOOLS: bool = false;
pub const CREATOR_NOT_READY_REASON: &str = "Creator mode is not ready in this build.";
const AVAILABILITY_TTL: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("{message}")]
pub struct ModeRefusal {
pub code: &'static str,
pub message: String,
}
impl ModeRefusal {
pub const UNKNOWN_MODE: &'static str = "unknown_mode";
pub const MODE_UNAVAILABLE: &'static str = "mode_unavailable";
pub const MODE_LOCKED: &'static str = "mode_locked";
pub const MODE_NOT_SUPPORTED_WITH_EXECUTOR: &'static str = "mode_not_supported_with_executor";
pub fn new(code: &'static str, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
}
}
pub fn locked() -> Self {
Self::new(
Self::MODE_LOCKED,
"The conversation mode is fixed once the conversation has its first turn; start a new conversation to use another mode.",
)
}
pub fn with_executor() -> Self {
Self::new(
Self::MODE_NOT_SUPPORTED_WITH_EXECUTOR,
"A session with another agent uses that agent's own tools and instructions; conversation modes are unavailable.",
)
}
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConversationMode {
pub id: String,
pub name: String,
pub description: String,
pub source: &'static str,
pub available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct ConversationModeTable {
pub default: &'static str,
pub modes: Vec<ConversationMode>,
}
#[must_use]
pub fn effective_id(stored: Option<&str>) -> &str {
stored.unwrap_or(STANDARD)
}
#[must_use]
pub fn stored_form(id: &str) -> Option<String> {
(id != STANDARD).then(|| id.to_string())
}
#[must_use]
pub fn table(creator_unavailable: Option<String>) -> ConversationModeTable {
ConversationModeTable {
default: STANDARD,
modes: vec![
ConversationMode {
id: STANDARD.to_string(),
name: "Standard".to_string(),
description: "The everyday Codewhale agent with its normal tools and skills."
.to_string(),
source: SOURCE_BUILTIN,
available: true,
reason: None,
},
ConversationMode {
id: CREATOR.to_string(),
name: "Creator".to_string(),
description:
"Build, inspect and test Codewhale plugins in this conversation, with the plugin authoring tools and guides loaded."
.to_string(),
source: SOURCE_BUILTIN,
available: creator_unavailable.is_none(),
reason: creator_unavailable,
},
],
}
}
type Cached = Option<(Instant, Option<String>)>;
static CREATOR_PROBE: Mutex<Cached> = Mutex::new(None);
#[cfg(any(test, feature = "test-support"))]
static CREATOR_PROBE_OVERRIDE: Mutex<Option<Option<String>>> = Mutex::new(None);
#[cfg(any(test, feature = "test-support"))]
static CREATOR_PROBE_LOCK: Mutex<()> = Mutex::new(());
#[cfg(any(test, feature = "test-support"))]
pub struct CreatorAvailabilityGuard {
_serial: std::sync::MutexGuard<'static, ()>,
}
#[cfg(any(test, feature = "test-support"))]
impl CreatorAvailabilityGuard {
pub fn set(unavailable_reason: Option<&str>) -> Self {
Self::hold(Some(unavailable_reason.map(str::to_string)))
}
pub fn unforced() -> Self {
Self::hold(None)
}
fn hold(forced: Option<Option<String>>) -> Self {
let serial = CREATOR_PROBE_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*CREATOR_PROBE_OVERRIDE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = forced;
Self { _serial: serial }
}
}
#[cfg(any(test, feature = "test-support"))]
impl Drop for CreatorAvailabilityGuard {
fn drop(&mut self) {
*CREATOR_PROBE_OVERRIDE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
}
fn probe_host_runtime(options: &ExtensionHostOptions) -> Option<String> {
let resolution = codewhale_dependencies::resolve_extension_host_runtime(
options.runtime,
options.node_override.as_deref(),
options.bun_override.as_deref(),
crate::extension_host::bundle_sha256(),
);
if resolution.selected.is_some() {
None
} else {
Some(format!(
"Creator mode needs a plugin runtime (Node.js or Bun), and none was found. {}",
resolution.failure()
))
}
}
async fn creator_unavailable_reason(options: ExtensionHostOptions) -> Option<String> {
#[cfg(any(test, feature = "test-support"))]
if let Some(forced) = CREATOR_PROBE_OVERRIDE
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
{
return forced;
}
if !CREATOR_LOADS_ITS_OWN_TOOLS {
return Some(CREATOR_NOT_READY_REASON.to_string());
}
if let Some((at, reason)) = CREATOR_PROBE.lock().expect("probe cache").clone()
&& at.elapsed() < AVAILABILITY_TTL
{
return reason;
}
let reason = tokio::task::spawn_blocking(move || probe_host_runtime(&options))
.await
.unwrap_or_else(|_| Some("Creator mode could not check for a plugin runtime.".to_string()));
*CREATOR_PROBE.lock().expect("probe cache") = Some((Instant::now(), reason.clone()));
reason
}
pub async fn current_table(options: ExtensionHostOptions) -> ConversationModeTable {
table(creator_unavailable_reason(options).await)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModeSelection {
pub id: String,
pub unavailable: Option<String>,
}
impl ModeSelection {
pub fn require_available(&self) -> Result<(), ModeRefusal> {
match &self.unavailable {
Some(reason) => Err(ModeRefusal::new(
ModeRefusal::MODE_UNAVAILABLE,
reason.clone(),
)),
None => Ok(()),
}
}
}
pub async fn select(id: &str, options: ExtensionHostOptions) -> Result<ModeSelection, ModeRefusal> {
let table = current_table(options).await;
match table.modes.into_iter().find(|mode| mode.id == id) {
Some(mode) => Ok(ModeSelection {
id: mode.id,
unavailable: if mode.available {
None
} else {
Some(
mode.reason
.unwrap_or_else(|| "This mode is unavailable.".to_string()),
)
},
}),
None => Err(ModeRefusal::new(
ModeRefusal::UNKNOWN_MODE,
format!("`{id}` is not a conversation mode; GET /v1/modes lists them."),
)),
}
}
+32
View File
@@ -863,6 +863,38 @@ mod tests {
TurnAuthority::from_effective_fields(mode, true, false, auto_approve, approval_mode)
}
#[test]
fn a_managed_network_grant_survives_the_ask_posture_and_shares_the_network() {
let workspace = Path::new("/srv/workspace");
for approval_mode in [ApprovalMode::Suggest, ApprovalMode::Bypass] {
let policy = sandbox_policy_for_turn(
AppMode::Agent,
approval_mode,
Some("workspace-write"),
workspace,
SandboxNetworkAccess::from_config(Some(true)),
);
assert!(
matches!(
policy,
SandboxPolicy::WorkspaceWrite {
network_access: true,
..
}
),
"{approval_mode:?}: {policy:?}"
);
}
let unset = sandbox_policy_for_turn(
AppMode::Agent,
ApprovalMode::Suggest,
Some("workspace-write"),
workspace,
SandboxNetworkAccess::from_config(None),
);
assert!(!unset.has_network_access(), "{unset:?}");
}
#[test]
fn write_carve_out_posture_is_exactly_accept_edits() {
assert!(write_carve_out_posture(
+7 -1
View File
@@ -566,6 +566,7 @@ pub struct EngineConfig {
/// When true and `/usr/bin/bwrap` is executable on Linux, route exec_shell
/// through bubblewrap (#2184).
pub prefer_bwrap: bool,
pub tool_launcher: Option<Vec<String>>,
/// User-configured bwrap mount extensions (#5410): extra read-only roots
/// and writable device nodes such as `/dev/null`.
pub bwrap_extensions: codewhale_sandbox::BwrapMountExtensions,
@@ -700,6 +701,7 @@ impl Default for EngineConfig {
goal_max_steps: None,
// Mirrors `Config::prefers_bwrap`: on unless explicitly opted out.
prefer_bwrap: true,
tool_launcher: None,
bwrap_extensions: codewhale_sandbox::BwrapMountExtensions::default(),
// Fail-closed (F7): `Engine::new` unconditionally installs this
// list process-wide via `read_guard::set_active`, so a default
@@ -2541,12 +2543,14 @@ impl Engine {
match shell_manager.lock() {
Ok(mut manager) => {
manager.set_prefer_bwrap(config.prefer_bwrap);
manager.set_tool_launcher(config.tool_launcher.clone());
manager.set_bwrap_extensions(config.bwrap_extensions.clone());
manager.set_denied_read_subpaths(config.read_denylist.subtree_paths());
}
Err(poisoned) => {
let mut manager = poisoned.into_inner();
manager.set_prefer_bwrap(config.prefer_bwrap);
manager.set_tool_launcher(config.tool_launcher.clone());
manager.set_bwrap_extensions(config.bwrap_extensions.clone());
manager.set_denied_read_subpaths(config.read_denylist.subtree_paths());
}
@@ -6242,7 +6246,9 @@ impl Engine {
};
}
if self.is_acp_turn() {
return self.acp_tool_build(input_policy, &route, allowed_tools);
return self
.acp_turn_tool_build(input_policy, &route, allowed_tools, mcp_access)
.await;
}
if self.rlm_host.is_some() {
return self.rlm_tool_build(input_policy, &route);
+84 -5
View File
@@ -40,11 +40,61 @@ impl Engine {
self.host_profile.is_acp() || self.turn_narrowing.is_acp()
}
pub(super) async fn acp_turn_tool_build(
&mut self,
authority: &TurnAuthority,
route: &TurnRouteContext,
allowed: Option<Vec<String>>,
mcp_access: McpAccess,
) -> TurnToolBuild {
if let Some(attachment) = self
.extension_host
.as_ref()
.filter(|_| self.config.features.enabled(Feature::ExtensionHost))
{
if attachment.selection_settled() {
attachment.sync_in_background();
} else {
attachment.sync_within(EXTENSION_FIRST_SYNC_WAIT).await;
}
}
let mcp = if self.config.features.enabled(Feature::Mcp) {
if mcp_access.may_connect() {
self.wait_for_mcp_boot().await;
let tools = self.mcp_tools().await;
let server_count = match self.mcp_pool.as_ref() {
Some(pool) => pool.lock().await.connected_servers().len(),
None => 0,
};
McpToolState::Live {
tools,
server_count,
}
} else {
self.passive_mcp_snapshot().await
}
} else {
McpToolState::Disabled
};
self.acp_tool_build_with_mcp(authority, route, allowed, mcp)
}
#[cfg(test)]
pub(super) fn acp_tool_build(
&self,
authority: &TurnAuthority,
route: &TurnRouteContext,
allowed: Option<Vec<String>>,
) -> TurnToolBuild {
self.acp_tool_build_with_mcp(authority, route, allowed, McpToolState::Disabled)
}
pub(super) fn acp_tool_build_with_mcp(
&self,
authority: &TurnAuthority,
route: &TurnRouteContext,
allowed: Option<Vec<String>>,
mcp: McpToolState,
) -> TurnToolBuild {
let mut context = self.build_tool_context_for_turn(authority, route);
let requested = self
@@ -85,6 +135,13 @@ impl Engine {
builder = builder.with_foreground_shell_tools();
}
let mut registry = builder.build(context);
let extension_host = self
.extension_host
.as_ref()
.filter(|_| self.config.features.enabled(Feature::ExtensionHost));
if let Some(attachment) = extension_host {
attachment.manager().note_native_names(registry.names());
}
if let Some(overrides) = self
.api_config
.tools
@@ -95,15 +152,28 @@ impl Engine {
remove_acp_overridden_builtin(&mut registry, name);
}
}
let plugin_tool_names: HashSet<String> = extension_host
.map(|attachment| attachment.install_tools(&mut registry))
.unwrap_or_default()
.into_iter()
.collect();
let mut mcp_tools = if self.child_host.is_none() {
mcp.tools().to_vec()
} else {
Vec::new()
};
mcp_tools.sort_by(|a, b| a.name.cmp(&b.name));
let mcp_tool_names: Vec<String> = mcp_tools.iter().map(|tool| tool.name.clone()).collect();
let allowed = Some(
registry
.names()
.into_iter()
.filter(|name| tool_catalog::tool_allowed(allowed.as_deref(), name))
.map(str::to_string)
.chain(mcp_tool_names.iter().cloned())
.filter(|name| tool_catalog::tool_allowed(allowed.as_deref(), name))
.collect(),
);
let catalog = self.child_host.as_ref().map_or_else(
let mut catalog = self.child_host.as_ref().map_or_else(
|| registry.to_api_tools_with_cache(true),
|child| {
child
@@ -111,6 +181,15 @@ impl Engine {
.tools_for_model(&registry, &child.authority.agent_type)
},
);
for tool in &mut catalog {
if plugin_tool_names.contains(&tool.name) {
tool.defer_loading = Some(false);
}
}
catalog.extend(mcp_tools.into_iter().map(|mut tool| {
tool.defer_loading = Some(false);
tool
}));
let always_load = catalog.iter().map(|tool| tool.name.clone()).collect();
TurnToolBuild {
surface: ToolSurfacePolicy::new(
@@ -125,11 +204,11 @@ impl Engine {
self.config.max_tool_calls,
tool_catalog::ToolMode::Direct,
),
mcp_tool_names: Vec::new(),
mcp: McpToolState::Disabled,
mcp_tool_names,
mcp,
subagent_runtime_model: None,
mailbox: None,
plugin_tool_names: HashSet::new(),
plugin_tool_names,
}
}
}
@@ -179,6 +179,49 @@ async fn child_ceiling_filters_acp_foreground_catalog_without_optional_allow_lis
assert!(!dir.path().join("blocked.txt").exists());
}
#[tokio::test(flavor = "current_thread")]
async fn child_engine_gets_no_mcp_tools_from_the_acp_tool_build() {
let dir = tempdir().unwrap();
let _home = crate::test_support::SealedHome::at(dir.path());
let (child, _handle, _authority, route) = fixture(dir.path(), None);
let (mut root, _root_handle) = Engine::new(
EngineConfig {
workspace: dir.path().into(),
snapshots_enabled: false,
memory_enabled: false,
..Default::default()
},
&route.api_config,
);
root.host_profile = EngineHostProfile::Acp;
let authority =
TurnAuthority::from_effective_fields(AppMode::Agent, true, false, true, ApprovalMode::Auto);
let live = || McpToolState::Live {
tools: vec![api_tool("mcp_fixture_echo")],
server_count: 1,
};
let offered = root.acp_tool_build_with_mcp(&authority, &route, None, live());
assert_eq!(offered.mcp_tool_names, ["mcp_fixture_echo"]);
assert!(
offered
.surface
.catalog
.iter()
.any(|tool| tool.name == "mcp_fixture_echo")
);
assert!(offered.surface.passes_allow_list("mcp_fixture_echo"));
let build = child.acp_tool_build_with_mcp(&authority, &route, None, live());
assert!(build.mcp_tool_names.is_empty());
assert!(
!build
.surface
.catalog
.iter()
.any(|tool| tool.name == "mcp_fixture_echo")
);
assert!(!build.surface.passes_allow_list("mcp_fixture_echo"));
}
#[tokio::test(flavor = "current_thread")]
async fn child_registry_rejects_context_override_identity_before_tool_effect() {
let dir = tempdir().unwrap();
@@ -742,7 +742,10 @@ impl Engine {
if let Some(context) = context
&& (context.acp_host.is_some() || context.child_host.is_some())
{
if context.acp_host.is_some() || tool_name != EXECUTE_TOOLS_TOOL_NAME {
let acp_mcp_call = context.acp_host.is_some() && McpPool::is_mcp_tool(&tool_name);
if !acp_mcp_call
&& (context.acp_host.is_some() || tool_name != EXECUTE_TOOLS_TOOL_NAME)
{
let spec = registry
.and_then(|registry| registry.get(&tool_name))
.ok_or_else(|| {
+5 -1
View File
@@ -2044,7 +2044,11 @@ impl Engine {
)));
}
if blocked_error.is_none() && self.is_acp_turn() && !registry_has_spec {
if blocked_error.is_none()
&& self.is_acp_turn()
&& !registry_has_spec
&& !McpPool::is_mcp_tool(&tool_name)
{
blocked_error = Some(ToolError::not_available(format!(
"{tool_name} is outside the ACP foreground tool profile"
)));
@@ -1,11 +1,12 @@
//! Stable diagnostics translation keys mapped to the existing catalog.
//! This host-only bridge does not introduce new copy or infer provider prices.
//! This bridge does not introduce new copy or infer provider prices.
use codewhale_localization::MessageId;
use codewhale_command_contract::facets::CommandPresentationContext;
use codewhale_localization::{Locale, MessageId, tr};
pub(super) fn resolve(key: &str) -> Option<MessageId> {
pub fn resolve(key: &str) -> Option<MessageId> {
if let Some(reason) = key.strip_prefix("cost_reason_") {
return Some(codewhale_engine::pricing::UnpricedReason::from_label(reason).message_id());
return Some(crate::pricing::UnpricedReason::from_label(reason).message_id());
}
Some(match key {
"cmd_diff_git_unavailable" => MessageId::CmdDiffGitUnavailable,
@@ -83,3 +84,50 @@ pub(super) fn resolve(key: &str) -> Option<MessageId> {
_ => return None,
})
}
pub fn apply_named_replacements(template: &str, replacements: &[(&str, &str)]) -> Option<String> {
let supplied: std::collections::BTreeMap<&str, &str> = replacements.iter().copied().collect();
if supplied.len() != replacements.len() {
return None;
}
let mut placeholders = std::collections::BTreeSet::new();
let mut cursor = 0usize;
while let Some(start) = template[cursor..].find('{') {
let start = cursor + start;
let Some(end) = template[start + 1..].find('}') else {
break;
};
let end = start + 1 + end;
let name = &template[start + 1..end];
if !name.is_empty() {
placeholders.insert(name);
}
cursor = end + 1;
}
if placeholders != supplied.keys().copied().collect() {
return None;
}
let placeholders: Vec<_> = replacements
.iter()
.map(|(name, _)| format!("{{{name}}}"))
.collect();
let replacements: Vec<_> = placeholders
.iter()
.zip(replacements)
.map(|(placeholder, (_, value))| (placeholder.as_str(), *value))
.collect();
Some(codewhale_protocol::display::interpolate(
template,
&replacements,
))
}
pub struct EnglishDiagnostics;
impl CommandPresentationContext for EnglishDiagnostics {
fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
let message_id = resolve(key).ok_or_else(|| "unknown translation key".to_string())?;
apply_named_replacements(&tr(Locale::En, message_id), replacements)
.ok_or_else(|| "invalid translation replacement contract".to_string())
}
}
+199 -31
View File
@@ -43,7 +43,8 @@
//! Full Access still opens their card. Auto-Review and Never refuse them
//! (`resolve_approval_request_disposition`); explicit denials always win.
//!
//! **Caps.** Per invocation: 50 `core/call`s in all (the ticket's uses), 4 at
//! **Caps.** Per invocation: 50 core tool calls in all, counted together
//! across `core/call` and the nested calls of broker arms, 4 at
//! once (code mode's own cap), and one approval card at a time (the turn
//! loop serves one request at a time). Per host: the channel's 256 in-flight
//! host requests. A burst of invalid tickets ends the host (`ticket`).
@@ -67,6 +68,7 @@
//! tool's `core/call`s run beside it, not through the lock.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -101,6 +103,7 @@ pub const MAX_CALLS_PER_INVOCATION: u32 = 50;
/// A backstop only: an invocation ends (and revokes its ticket) long before
/// this, and its own deadline pauses while a person decides.
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;
@@ -264,7 +267,8 @@ pub struct Invocation {
calls: CodemodeInvoker,
gate: NestedCallGate,
root_caller: Option<SessionCheckpointCaller>,
broker_session: Option<String>,
broker: Option<BrokerScope>,
nested_calls: AtomicU32,
tickets: Vec<Ticket>,
caller_tools: Vec<String>,
activations: Mutex<
@@ -282,6 +286,98 @@ pub struct Invocation {
cancel: CancellationToken,
}
struct BrokerScope {
session_id: String,
context: Arc<ToolContext>,
}
pub struct BrokerContext {
invocation: Arc<Invocation>,
tool: Arc<ToolContext>,
service: DshService,
cancel: CancellationToken,
}
impl BrokerContext {
#[must_use]
pub fn tool(&self) -> &ToolContext {
&self.tool
}
#[must_use]
pub fn cancel(&self) -> &CancellationToken {
&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()),
));
}
if self.invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
return Err(BrokerRefusal::nested(
self.service,
NestedFailure::Rejected {
decision: NestedDecision::Refused,
message: format!(
"a tool call may make at most {MAX_CALLS_PER_INVOCATION} core tool calls"
),
},
));
}
self.invocation
.calls
.call(name.to_string(), input, Some(&self.cancel))
.await
.map_err(|failure| BrokerRefusal::nested(self.service, failure))
}
}
#[cfg(test)]
impl BrokerContext {
pub fn detached_for_test(owner: &OwnerRef, service: DshService) -> Self {
let tool = ToolContext::new(std::path::PathBuf::from("."));
let gate = NestedCallGate::admitting_for_test();
let calls = CodemodeInvoker::for_extension(
Vec::new(),
tool.clone(),
gate.clone(),
String::new(),
|_, _, _| None,
);
let invocation = Arc::new(Invocation {
tier: HostTier::Plugin,
host_generation: 0,
owner: owner.clone(),
scope: None,
plugins: None,
content_hash: String::new(),
calls,
gate,
root_caller: None,
broker: None,
nested_calls: AtomicU32::new(0),
tickets: Vec::new(),
caller_tools: Vec::new(),
activations: Mutex::new(HashMap::new()),
session_call_lock: tokio::sync::Mutex::new(()),
cancel: CancellationToken::new(),
});
Self {
invocation,
tool: Arc::new(tool),
service,
cancel: CancellationToken::new(),
}
}
}
/// The tickets and invocations of one manager.
#[derive(Default)]
pub struct CoreCalls {
@@ -315,6 +411,7 @@ impl CoreCalls {
gate,
None,
String::new(),
&dsh_broker::test_grants().unwrap_or_default(),
)
}
#[allow(clippy::too_many_arguments)]
@@ -329,6 +426,7 @@ impl CoreCalls {
gate: &NestedCallGate,
scope: Option<codewhale_config::EntryRef>,
content_hash: String,
broker_services: &[DshService],
) -> Option<InvocationGuard> {
let (caller, specs) = gate.extension()?;
if caller != expected {
@@ -416,23 +514,36 @@ impl CoreCalls {
let broker_tickets: Vec<(DshService, Ticket)> = broker_session
.iter()
.flat_map(|session| {
DshService::ALL.into_iter().map(move |service| {
(
service,
self.tickets.mint(Grant {
kind: TicketKind::Invocation,
tier,
host_generation,
owner: owner.clone(),
method: dsh_broker::ticket_method(service),
target: json!({ "call_id": call_id, "session_id": session }),
ttl: TICKET_TTL,
uses: MAX_CALLS_PER_INVOCATION,
}),
)
})
DshService::ALL
.into_iter()
.filter(|service| tier == HostTier::Plugin && broker_services.contains(service))
.map(move |service| {
(
service,
self.tickets.mint(Grant {
kind: TicketKind::Invocation,
tier,
host_generation,
owner: owner.clone(),
method: dsh_broker::ticket_method(service),
target: json!({ "call_id": call_id, "session_id": session }),
ttl: TICKET_TTL,
uses: MAX_CALLS_PER_INVOCATION,
}),
)
})
})
.collect();
let broker = broker_session
.filter(|_| !broker_tickets.is_empty())
.map(|session_id| {
let mut context = context.clone();
context.execution.nested_call_gate = None;
BrokerScope {
session_id,
context: Arc::new(context),
}
});
let caller_tools = specs
.iter()
.map(|spec| spec.name().to_string())
@@ -453,7 +564,8 @@ impl CoreCalls {
calls,
gate: gate.clone(),
root_caller,
broker_session,
broker,
nested_calls: AtomicU32::new(0),
tickets: std::iter::once(ticket.clone())
.chain(checkpoint_ticket.iter().cloned())
.chain(question_ticket.iter().cloned())
@@ -590,10 +702,30 @@ impl CoreCalls {
method: &str,
cx: &HostRequestContext,
) -> Result<Arc<Invocation>, RpcErrorWire> {
let refuse = |code, message| RpcErrorWire {
code,
message,
data: None,
self.redeem_invocation_or_limit(shared, tier, host_generation, owner, ticket, method, cx)
.map_err(|(_, error)| error)
}
#[allow(clippy::too_many_arguments)]
fn redeem_invocation_or_limit(
&self,
shared: &ManagerShared,
tier: HostTier,
host_generation: u64,
owner: &OwnerRef,
ticket: &str,
method: &str,
cx: &HostRequestContext,
) -> Result<Arc<Invocation>, (bool, RpcErrorWire)> {
let refuse = |code, message| {
(
false,
RpcErrorWire {
code,
message,
data: None,
},
)
};
let redeemed = self.tickets.redeem(&Presented {
ticket,
@@ -610,10 +742,8 @@ impl CoreCalls {
"too many invalid {method} tickets from one host process"
));
}
return Err(refuse(
error_code::REFUSED,
refused.reason.describe().to_string(),
));
let (_, error) = refuse(error_code::REFUSED, refused.reason.describe().to_string());
return Err((refused.reason == super::ticket::Refusal::Exhausted, error));
}
// The redemption proved the ticket is live and ours; the owner must
// still be the current one of this tier.
@@ -1144,7 +1274,7 @@ impl CoreCalls {
let service = DshService::parse(&params.service)
.ok_or_else(|| BrokerRefusal::UnknownService(params.service.clone()).wire())?;
let invocation = self
.redeem_invocation(
.redeem_invocation_or_limit(
shared,
tier,
host_generation,
@@ -1153,11 +1283,25 @@ impl CoreCalls {
dsh_broker::ticket_method(service),
&cx,
)
.map_err(|error| dsh_broker::coded(error, dsh_broker::TICKET_REFUSED))?;
let session_id = invocation
.broker_session
.clone()
.map_err(|(limit, mut error)| {
if limit {
error.message = format!(
"this tool call's limit for the {} service is used up",
service.name()
);
dsh_broker::coded(error, dsh_broker::LIMIT)
} else {
dsh_broker::coded(error, dsh_broker::TICKET_REFUSED)
}
})?;
let (session_id, tool) = invocation
.broker
.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 !shared.broker_granted(&params.owner, service).await {
return Err(BrokerRefusal::NotGranted(service).wire());
}
let arm = dsh_broker::arm_in(table, service)
.ok_or_else(|| BrokerRefusal::NotAvailable(service).wire())?;
let withdraw = invocation.cancel.child_token();
@@ -1168,11 +1312,26 @@ impl CoreCalls {
service,
method: params.method,
input: params.input,
context: BrokerContext {
invocation: Arc::clone(&invocation),
tool,
service,
cancel: withdraw.clone(),
},
};
let withdrawn = async {
loop {
tokio::time::sleep(GRANT_PROBE).await;
if !shared.broker_granted(&params.owner, service).await {
break;
}
}
};
let outcome = tokio::select! {
biased;
() = cx.cancel.cancelled() => return Err(cancelled("dsh/broker was cancelled by its host")),
() = withdraw.cancelled() => return Err(cancelled("dsh/broker invocation ended")),
() = withdrawn => return Err(BrokerRefusal::NotGranted(service).wire()),
outcome = arm(call) => outcome,
};
if shared
@@ -1196,6 +1355,9 @@ impl CoreCalls {
invocation.scope.as_ref(),
)
.map_err(refused)?;
if !shared.broker_granted(&params.owner, service).await {
return Err(BrokerRefusal::NotGranted(service).wire());
}
outcome.map_err(BrokerRefusal::wire)
}
@@ -1224,6 +1386,12 @@ impl CoreCalls {
METHOD,
&cx,
)?;
if invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
return Err(refuse(
error_code::REFUSED,
super::ticket::Refusal::Exhausted.describe().to_string(),
));
}
// Withdrawn when the host cancels this request, its owner is revoked,
// the host exits (`cx.cancel`), or the invocation ends.
let withdraw = invocation.cancel.child_token();
File diff suppressed because it is too large. Load diff
@@ -6,9 +6,13 @@ use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use super::protocol::{DshService, RpcErrorWire, error_code};
use super::registry::OwnerRegistry;
use super::{
dsh_catalog, dsh_llm, dsh_network, dsh_remote, dsh_settings, dsh_terminal, dsh_workspace,
};
use crate::tools::codemode::{NestedDecision, NestedFailure};
pub use super::core_call::BrokerContext;
pub struct BrokerCall {
pub owner: OwnerRef,
@@ -16,6 +20,7 @@ pub struct BrokerCall {
pub service: DshService,
pub method: String,
pub input: Value,
pub context: BrokerContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -30,6 +35,15 @@ pub enum BrokerRefusal {
message: String,
},
NotAvailable(DshService),
NotGranted(DshService),
Denied {
service: DshService,
message: String,
},
Refused {
service: DshService,
message: String,
},
}
impl BrokerRefusal {
@@ -40,6 +54,9 @@ impl BrokerRefusal {
Self::UnknownMethod { .. } => "unknown_method",
Self::InvalidInput { .. } => "invalid_input",
Self::NotAvailable(_) => "not_available",
Self::NotGranted(_) => "not_granted",
Self::Denied { .. } => "denied",
Self::Refused { .. } => "refused",
}
}
@@ -71,6 +88,24 @@ impl BrokerRefusal {
format!("the {} broker is not available yet", service.name()),
Value::String(service.name().into()),
),
Self::NotGranted(service) => (
error_code::REFUSED,
format!(
"the {} service is not granted to this plugin",
service.name()
),
Value::String(service.name().into()),
),
Self::Denied { service, message } => (
error_code::DENIED,
message,
Value::String(service.name().into()),
),
Self::Refused { service, message } => (
error_code::REFUSED,
message,
Value::String(service.name().into()),
),
};
RpcErrorWire {
code: rpc,
@@ -78,9 +113,23 @@ impl BrokerRefusal {
data: Some(json!({ "dsh_refusal": code, "service": service })),
}
}
#[must_use]
pub fn nested(service: DshService, failure: NestedFailure) -> Self {
match failure {
NestedFailure::Rejected {
decision: NestedDecision::Denied,
message,
} => Self::Denied { service, message },
NestedFailure::Rejected { message, .. } | NestedFailure::Unavailable(message) => {
Self::Refused { service, message }
}
}
}
}
pub const TICKET_REFUSED: &str = "ticket_refused";
pub const LIMIT: &str = "limit";
pub const CANCELLED: &str = "cancelled";
#[must_use]
@@ -102,6 +151,62 @@ pub const TABLE: &[(DshService, Arm)] = &[
(DshService::Remote, dsh_remote::serve as Arm),
];
#[cfg(test)]
thread_local! {
static TEST_GRANTS: std::cell::RefCell<Option<Vec<DshService>>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub struct TestGrants(Option<Vec<DshService>>);
#[cfg(test)]
impl TestGrants {
pub fn only(services: &[DshService]) -> Self {
Self(TEST_GRANTS.with(|grants| grants.replace(Some(services.to_vec()))))
}
}
#[cfg(test)]
impl Drop for TestGrants {
fn drop(&mut self) {
let previous = self.0.take();
TEST_GRANTS.with(|grants| *grants.borrow_mut() = previous);
}
}
#[cfg(test)]
pub(super) fn test_grants() -> Option<Vec<DshService>> {
TEST_GRANTS.with(|grants| grants.borrow().clone())
}
#[cfg(not(test))]
pub(super) fn test_grants() -> Option<Vec<DshService>> {
None
}
#[must_use]
pub fn offered(service: DshService) -> bool {
use codewhale_plugins::host_services::HostService;
HostService::parse(service.name()).is_some_and(codewhale_plugins::host_services::offered)
}
#[must_use]
pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService) -> bool {
match test_grants() {
Some(granted) => granted.contains(&service),
None => offered(service) && registry.host_service_granted(owner, service),
}
}
#[must_use]
pub fn granted_services(registry: &OwnerRegistry, owner: &OwnerRef) -> Vec<DshService> {
DshService::ALL
.into_iter()
.filter(|service| granted(registry, owner, *service))
.collect()
}
#[must_use]
pub fn arm_in(table: &[(DshService, Arm)], service: DshService) -> Option<Arm> {
table
@@ -0,0 +1,904 @@
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use serde_json::{Value, json};
use tokio::sync::mpsc;
use super::core_call::InvocationGuard;
use super::dsh_broker::{Arm, BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::{DshBrokerParams, DshService, HostRequest, RpcErrorWire, error_code};
use super::supervisor::HostRequestContext;
use super::tier::HostTier;
use super::{ExtensionHostManager, ExtensionHostOptions};
use crate::tools::codemode::{ExtensionCaller, NestedCallGate};
use crate::tools::spec::ToolContext;
use codewhale_config::OwnerRef;
use codewhale_plugins::PluginRegistry;
use codewhale_plugins::activation::TestPolicyGuard;
use codewhale_plugins::discovery::{DiscoveryConfig, discover_with_config};
use codewhale_plugins::host_services::{
AgentApproval, AgentPosture, HostService, PersonVia, PluginApprover, ReviewApproval, TestOffer,
};
const HOST: u64 = 7;
struct Plugins {
_temp: tempfile::TempDir,
config: DiscoveryConfig,
}
impl Plugins {
fn new(plugins: &[(&str, &[&str])]) -> Self {
let temp = tempfile::tempdir().unwrap();
let root = temp.path();
let config = DiscoveryConfig {
workspace: root.join("project"),
user_plugins_dir: root.join("user"),
workspace_plugins_dir: root.join("workspace"),
builtin_plugin_dirs: Vec::new(),
state_path: root.join("state/plugin-state.json"),
};
std::fs::create_dir_all(&config.workspace).unwrap();
for (name, asks) in plugins {
let plugin = config.user_plugins_dir.join(name);
std::fs::create_dir_all(plugin.join("native")).unwrap();
std::fs::write(
plugin.join("native/index.mjs"),
"export function apply() {}\n",
)
.unwrap();
let asked = if asks.is_empty() {
String::new()
} else {
format!(
"[capabilities]\nhost_services = [{}]\n",
asks.iter()
.map(|service| format!("{service:?}"))
.collect::<Vec<_>>()
.join(", ")
)
};
std::fs::write(
plugin.join("plugin.toml"),
format!(
"schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n[native]\npath = \"native/index.mjs\"\n{asked}"
),
)
.unwrap();
}
let plugins_fixture = Self {
_temp: temp,
config,
};
for (name, _) in plugins {
plugins_fixture.review(name, true);
}
plugins_fixture
}
fn registry(&self) -> PluginRegistry {
discover_with_config(&self.config)
}
fn review(&self, name: &str, showing_services: bool) {
let mut registry = self.registry();
let shown = registry
.host_service_review(name)
.filter(|_| showing_services)
.map(|review| review.token);
registry
.trust_reviewed(
name,
ReviewApproval {
approved_by: PluginApprover::person(PersonVia::Terminal),
host_services: shown,
},
)
.unwrap();
registry.enable(name).unwrap();
}
fn review_by_agent(&self, name: &str) {
self.registry().revoke_trust(name).unwrap();
let mut registry = self.registry();
let approver = PluginApprover::Agent(AgentApproval {
posture: AgentPosture::FullAccess,
workspace: self.config.workspace.to_string_lossy().into_owned(),
thread_id: Some("thread".into()),
turn_id: Some("turn".into()),
tool_call_id: Some("call".into()),
});
let shown = registry
.host_service_review_for(name, &approver)
.map(|review| review.token);
registry
.trust_reviewed(
name,
ReviewApproval {
approved_by: approver.clone(),
host_services: shown,
},
)
.unwrap();
registry.enable_by(name, approver).unwrap();
}
}
struct Host {
manager: ExtensionHostManager,
context: ToolContext,
gate: NestedCallGate,
}
fn host(workspace: &Path) -> Host {
let manager = ExtensionHostManager::new(ExtensionHostOptions::default());
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));
let context = ToolContext::new(workspace)
.with_session_objects(crate::rlm::session::SessionObjectSnapshot::new(
"grant-session".into(),
"fixture-model".into(),
workspace.to_path_buf(),
None,
Arc::new(Vec::new()),
))
.with_origin_turn_id("grant-turn");
Host {
manager,
context,
gate: gate.for_extension(
ExtensionCaller {
origin: "extension:grant".to_string(),
tool: "grant_tool".to_string(),
scope: "ext:grant@hash".to_string(),
},
Vec::new(),
),
}
}
impl Host {
fn load(&self, plugins: &Plugins, name: &str) -> OwnerRef {
let authority = plugins.registry().authority_for(name).unwrap();
let services = codewhale_plugins::registry::granted_host_services(&authority)
.unwrap()
.into_iter()
.filter_map(|service| DshService::parse(service.name()))
.collect();
let mut registry = self.manager.shared.registry.lock().unwrap();
let owner = registry
.begin_owner(
HostTier::Plugin,
authority.plugin_id.as_str(),
name,
Some(authority.clone()),
&authority.content_hash,
)
.unwrap();
registry.set_host_services(&owner, services);
registry.mark_active(&owner);
owner
}
fn mint(&self, tier: HostTier, owner: &OwnerRef) -> InvocationGuard {
self.manager
.shared
.core_calls
.begin_scoped(
tier,
HOST,
owner,
"call-1",
self.gate.extension().unwrap().0,
&self.context,
&self.gate,
None,
String::new(),
&self.manager.shared.broker_services(owner),
)
.expect("an invocation")
}
fn services(&self, owner: &OwnerRef) -> Vec<&'static str> {
self.mint(HostTier::Plugin, owner)
.broker_tickets()
.unwrap_or_default()
.into_iter()
.filter_map(|wire| DshService::parse(&wire.service).map(DshService::name))
.collect()
}
async fn call(
&self,
table: &[(DshService, Arm)],
owner: &OwnerRef,
ticket: &str,
service: &str,
) -> (Result<Value, RpcErrorWire>, Vec<String>) {
let (cx, mut violations, _cancel) = HostRequestContext::for_test(1);
let result = self
.manager
.shared
.core_calls
.serve_broker_in(
table,
&self.manager.shared,
HostTier::Plugin,
HOST,
DshBrokerParams {
owner: owner.clone(),
ticket: ticket.to_string(),
service: service.to_string(),
method: "echo".to_string(),
input: json!({ "text": "ok", "granted": ["llm"], "host_services": ["llm"] }),
},
cx,
)
.await;
let mut seen = Vec::new();
while let Ok(violation) = violations.try_recv() {
seen.push(violation);
}
(result, seen)
}
}
fn ticket(guard: &InvocationGuard, service: &str) -> String {
guard
.broker_tickets()
.unwrap_or_default()
.into_iter()
.find(|wire| wire.service == service)
.map(|wire| wire.ticket)
.unwrap_or_else(|| panic!("no {service} ticket"))
}
fn refusal(result: &Result<Value, RpcErrorWire>) -> (i64, String, String) {
let error = result.as_ref().expect_err("a refusal");
(
error.code,
error.data.as_ref().unwrap()["dsh_refusal"]
.as_str()
.unwrap()
.to_string(),
error.message.clone(),
)
}
fn not_granted(service: &str) -> (i64, String, String) {
(
error_code::REFUSED,
"not_granted".to_string(),
format!("the {service} service is not granted to this plugin"),
)
}
#[derive(serde::Deserialize)]
#[serde(tag = "method", content = "input")]
enum Echo {
#[serde(rename = "echo")]
Echo { text: String },
}
impl BrokerMethods for Echo {
const NAMES: &'static [&'static str] = &["echo"];
}
fn echo_arm(call: BrokerCall) -> BrokerFuture {
Box::pin(async move {
let Echo::Echo { text } = decode::<Echo>(&call)?;
Ok(json!({ "plugin": call.owner.plugin_id, "service": call.service.name(), "text": text }))
})
}
fn echo() -> Vec<(DshService, Arm)> {
DshService::ALL
.into_iter()
.map(|service| (service, echo_arm as Arm))
.collect()
}
static WAITING: AtomicBool = AtomicBool::new(false);
static WITHDRAWN: AtomicBool = AtomicBool::new(false);
fn waiting_arm(call: BrokerCall) -> BrokerFuture {
Box::pin(async move {
let cancel = call.context.cancel().clone();
tokio::spawn(async move {
cancel.cancelled().await;
WITHDRAWN.store(true, Ordering::SeqCst);
});
WAITING.store(true, Ordering::SeqCst);
std::future::pending::<()>().await;
Err(BrokerRefusal::NotAvailable(call.service))
})
}
async fn until(what: &str, done: impl Fn() -> bool) {
for _ in 0..400 {
if done() {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("timed out waiting for {what}");
}
#[test]
fn the_engine_and_the_review_record_name_the_same_services() {
assert_eq!(
DshService::ALL.map(DshService::name),
HostService::ALL.map(HostService::name)
);
for service in DshService::ALL {
assert!(!super::dsh_broker::offered(service));
}
let _offer = TestOffer::only(&[HostService::Remote]);
assert!(super::dsh_broker::offered(DshService::Remote));
assert!(!super::dsh_broker::offered(DshService::Network));
}
#[test]
fn an_agent_approved_owner_never_loads_into_the_reviewed_host() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network]);
let plugins = Plugins::new(&[("reviewed", &["network"]), ("made", &["network"])]);
let (desired, errors) = super::desired_owners(&plugins.registry());
assert_eq!(desired.len(), 2, "{errors:?}");
plugins.review_by_agent("made");
let registry = plugins.registry();
assert!(registry.get("made").unwrap().active());
let (desired, errors) = super::desired_owners(&registry);
let names: Vec<&str> = desired
.values()
.map(|owner| owner.plugin_name.as_str())
.collect();
assert_eq!(names, vec!["reviewed"]);
assert_eq!(
desired.values().next().unwrap().host_services,
vec![DshService::Network]
);
assert_eq!(
errors
.iter()
.filter(|error| error.contains("`made` was approved by the agent"))
.count(),
1,
"{errors:?}"
);
}
#[test]
fn a_record_that_cannot_be_read_at_the_scan_loads_no_owner() {
let _policy = TestPolicyGuard::extension_host(true);
let plugins = Plugins::new(&[("alpha", &[])]);
let registry = plugins.registry();
let (sources, errors) = codewhale_plugins::runtime::active_component_sources(
&registry,
codewhale_plugins::activation::PluginActivationCapability::Native,
);
assert_eq!(sources.len(), 1, "{errors:?}");
plugins.registry().disable("alpha").unwrap();
let (desired, errors) = super::desired_owners_from(&registry, sources, errors);
assert!(desired.is_empty(), "{:?}", desired.keys());
assert_eq!(
errors
.iter()
.filter(|error| error
.contains("`alpha` was not loaded because its review record could not be read"))
.count(),
1,
"{errors:?}"
);
}
#[tokio::test]
async fn no_broker_ticket_is_minted_for_a_plugin_whose_reviewed_record_does_not_name_the_service() {
let _policy = TestPolicyGuard::extension_host(true);
let offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
let granted = Plugins::new(&[("alpha", &["network"])]);
let host_a = host(&granted.config.workspace);
let alpha = host_a.load(&granted, "alpha");
assert_eq!(host_a.services(&alpha), vec!["network"]);
let guard = host_a.mint(HostTier::Plugin, &alpha);
let network = ticket(&guard, "network");
let (served, _) = host_a.call(&echo(), &alpha, &network, "network").await;
assert_eq!(served.unwrap()["service"], "network");
let (wrong, _) = host_a.call(&echo(), &alpha, &network, "llm").await;
assert_eq!(refusal(&wrong).1, "ticket_refused");
let ungranted = Plugins::new(&[("beta", &[])]);
let host_b = host(&ungranted.config.workspace);
let beta = host_b.load(&ungranted, "beta");
assert!(host_b.services(&beta).is_empty());
let forged = OwnerRef {
plugin_id: beta.plugin_id.clone(),
..alpha.clone()
};
let (borrowed, _) = host_b.call(&echo(), &forged, &network, "network").await;
assert_eq!(refusal(&borrowed).1, "ticket_refused");
let unshown = Plugins::new(&[("gamma", &["network"])]);
unshown.review("gamma", false);
let host_c = host(&unshown.config.workspace);
let gamma = host_c.load(&unshown, "gamma");
assert!(
host_c.services(&gamma).is_empty(),
"a review that did not show the service grants none"
);
drop(offer);
assert!(
host_a.services(&alpha).is_empty(),
"a build that offers no service mints no broker ticket, whatever is recorded"
);
let (unreachable, _) = host_a.call(&echo(), &alpha, &network, "network").await;
assert_eq!(refusal(&unreachable), not_granted("network"));
for service in DshService::ALL {
let (stub, _) = host_a
.call(super::dsh_broker::TABLE, &alpha, &network, service.name())
.await;
assert!(stub.is_err(), "{}", service.name());
}
}
#[tokio::test]
async fn no_broker_ticket_while_a_plugin_without_the_grant_shares_the_host() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
let plugins = Plugins::new(&[
("alpha", &["network", "llm"]),
("beta", &[]),
("gamma", &["network"]),
]);
plugins.review("alpha", true);
let host = host(&plugins.config.workspace);
let alpha = host.load(&plugins, "alpha");
assert_eq!(host.services(&alpha), vec!["llm", "network"]);
let alone = host.mint(HostTier::Plugin, &alpha);
let network = ticket(&alone, "network");
let llm = ticket(&alone, "llm");
let gamma = host.load(&plugins, "gamma");
assert_eq!(host.services(&alpha), vec!["network"]);
assert_eq!(host.services(&gamma), vec!["network"]);
let (shared, _) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(shared.unwrap()["plugin"], alpha.plugin_id);
let (unshared, _) = host.call(&echo(), &alpha, &llm, "llm").await;
assert_eq!(refusal(&unshared), not_granted("llm"));
let report = host
.manager
.shared
.registry
.lock()
.unwrap()
.take_withheld_report()
.unwrap();
assert_eq!(
report,
"host service `llm` is off: the review of `gamma` does not include it"
);
assert!(
host.manager
.shared
.registry
.lock()
.unwrap()
.take_withheld_report()
.is_none(),
"reported once"
);
let beta = {
let authority = plugins.registry().authority_for("beta").unwrap();
host.manager
.shared
.registry
.lock()
.unwrap()
.begin_owner(
HostTier::Plugin,
authority.plugin_id.as_str(),
"beta",
Some(authority.clone()),
&authority.content_hash,
)
.unwrap()
};
assert!(
host.services(&alpha).is_empty(),
"an activating plugin without the grant already withholds it"
);
assert!(host.services(&gamma).is_empty());
assert!(host.services(&beta).is_empty());
let (withheld, _) = host.call(&echo(), &alpha, &network, "network").await;
let own = Plugins::new(&[("delta", &[])]);
let other_host = self::host(&own.config.workspace);
let delta = other_host.load(&own, "delta");
let lacking = other_host.mint(HostTier::Plugin, &delta);
assert!(lacking.broker_tickets().is_none());
assert_eq!(
refusal(&withheld),
not_granted("network"),
"the refusal is the same whoever lacks the grant, and names nobody"
);
assert!(!refusal(&withheld).2.contains("beta"));
let report = host
.manager
.shared
.registry
.lock()
.unwrap()
.take_withheld_report()
.unwrap();
assert_eq!(
report,
"host service `llm` is off: the reviews of `beta`, `gamma` do not include it; host service `network` is off: the review of `beta` does not include it"
);
}
#[tokio::test]
async fn a_plugin_that_left_the_host_withholds_every_service_until_the_host_exits() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network]);
let plugins = Plugins::new(&[("alpha", &["network"]), ("gamma", &["network"])]);
plugins.review("alpha", true);
let host = host(&plugins.config.workspace);
let alpha = host.load(&plugins, "alpha");
let gamma = host.load(&plugins, "gamma");
assert_eq!(host.services(&alpha), vec!["network"]);
let guard = host.mint(HostTier::Plugin, &alpha);
let network = ticket(&guard, "network");
assert!(
!host
.manager
.shared
.registry
.lock()
.unwrap()
.host_services_await_restart()
);
{
let mut registry = host.manager.shared.registry.lock().unwrap();
assert_eq!(registry.revoke_owner(&gamma.plugin_id), Some(gamma.clone()));
registry.forget_owner(&gamma.plugin_id);
assert!(registry.host_services_await_restart());
assert_eq!(
registry.take_withheld_report().unwrap(),
"host services are off until the plugin host restarts: a plugin that was turned off, changed or failed is still loaded in it"
);
}
assert!(
host.services(&alpha).is_empty(),
"code that was loaded cannot be unloaded: nothing is given until the process is gone"
);
let (lingering, _) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(refusal(&lingering), not_granted("network"));
let again = host.load(&plugins, "gamma");
assert!(host.services(&alpha).is_empty());
assert!(host.services(&again).is_empty());
{
let mut registry = host.manager.shared.registry.lock().unwrap();
registry.host_exited(HostTier::Plugin, "planned restart");
assert!(!registry.host_services_await_restart());
}
let alpha = host.load(&plugins, "alpha");
assert_eq!(host.services(&alpha), vec!["network"]);
let failing = host.load(&plugins, "gamma");
host.manager
.shared
.registry
.lock()
.unwrap()
.mark_failed(&failing, super::registry::OwnerState::Failed("boom".into()));
assert!(host.services(&alpha).is_empty());
host.manager
.shared
.registry
.lock()
.unwrap()
.host_process_started(HostTier::Plugin);
assert_eq!(host.services(&alpha), vec!["network"]);
}
#[tokio::test]
async fn a_grant_is_read_from_the_owner_record_at_mint_and_at_redeem_never_from_the_wire() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network, HostService::Llm]);
let plugins = Plugins::new(&[("alpha", &["network"])]);
let host = host(&plugins.config.workspace);
let alpha = host.load(&plugins, "alpha");
let guard = host.mint(HostTier::Plugin, &alpha);
let network = ticket(&guard, "network");
let (served, _) = host.call(&echo(), &alpha, &network, "network").await;
assert!(served.is_ok(), "{served:?}");
let (claimed, _) = host.call(&echo(), &alpha, &network, "llm").await;
assert_eq!(
refusal(&claimed).1,
"ticket_refused",
"input that claims a grant grants nothing"
);
plugins.review("alpha", false);
let (reviewed_again, _) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(
refusal(&reviewed_again),
not_granted("network"),
"a new review that drops the service refuses the next call of the running tool"
);
assert_eq!(
host.services(&alpha),
vec!["network"],
"the owner record is replaced by reconcile; until then redeem reads the review record"
);
plugins.review("alpha", true);
let (stale, _) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(
refusal(&stale),
not_granted("network"),
"an owner from before the review holds nothing, whatever the new review grants"
);
host.manager
.shared
.registry
.lock()
.unwrap()
.host_exited(HostTier::Plugin, "restart");
let current = host.load(&plugins, "alpha");
let guard = host.mint(HostTier::Plugin, &current);
let (fresh, _) = host
.call(&echo(), &current, &ticket(&guard, "network"), "network")
.await;
assert!(fresh.is_ok(), "{fresh:?}");
std::fs::write(&plugins.config.state_path, b"{ not json").unwrap();
let (unreadable, _) = host
.call(&echo(), &current, &ticket(&guard, "network"), "network")
.await;
assert_eq!(refusal(&unreadable), not_granted("network"));
}
#[tokio::test]
async fn a_disable_from_another_process_stops_broker_calls_of_a_running_tool() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network]);
let plugins = Plugins::new(&[("alpha", &["network"]), ("gamma", &["network"])]);
plugins.review("alpha", true);
let host = host(&plugins.config.workspace);
let alpha = host.load(&plugins, "alpha");
let _gamma = host.load(&plugins, "gamma");
let guard = host.mint(HostTier::Plugin, &alpha);
let network = ticket(&guard, "network");
let waiting: Vec<(DshService, Arm)> = vec![(DshService::Network, waiting_arm as Arm)];
let in_flight = host.call(&waiting, &alpha, &network, "network");
let elsewhere = async {
until("the arm to start", || WAITING.load(Ordering::SeqCst)).await;
plugins.registry().disable("gamma").unwrap();
};
let ((stopped, violations), ()) = tokio::join!(in_flight, elsewhere);
assert_eq!(
refusal(&stopped),
not_granted("network"),
"a co-hosted plugin turned off elsewhere withdraws the call in flight"
);
assert!(violations.is_empty());
until("the arm's cancellation", || {
WITHDRAWN.load(Ordering::SeqCst)
})
.await;
let (next, _) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(refusal(&next), not_granted("network"));
assert_eq!(
host.services(&alpha),
vec!["network"],
"this process has not reconciled yet; the review record already refuses"
);
plugins.registry().enable("gamma").unwrap();
let (spent, _) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(
refusal(&spent),
not_granted("network"),
"the loaded owner is from before the plugin was turned off"
);
plugins.registry().disable("alpha").unwrap();
let (own, _) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(refusal(&own), not_granted("network"));
}
#[tokio::test]
async fn a_builtin_tier_owner_never_gets_a_broker_ticket() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&HostService::ALL);
let workspace = tempfile::tempdir().unwrap();
let host = host(workspace.path());
let builtin = {
let mut registry = host.manager.shared.registry.lock().unwrap();
let owner = registry
.begin_owner(HostTier::Builtin, "host:mcp", "mcp", None, "digest")
.unwrap();
registry.set_host_services(&owner, DshService::ALL.to_vec());
registry.mark_active(&owner);
assert!(
DshService::ALL
.into_iter()
.all(|service| !registry.host_service_granted(&owner, service))
);
owner
};
assert!(host.manager.shared.broker_services(&builtin).is_empty());
assert!(
host.mint(HostTier::Builtin, &builtin)
.broker_tickets()
.is_none()
);
let _everything = super::dsh_broker::TestGrants::only(&DshService::ALL);
let forced = host
.manager
.shared
.core_calls
.begin(
HostTier::Builtin,
HOST,
&builtin,
"call-2",
host.gate.extension().unwrap().0,
&host.context,
&host.gate,
)
.unwrap();
assert!(
forced.broker_tickets().is_none(),
"no override mints a broker ticket for the built-in tier"
);
}
#[tokio::test]
async fn a_broker_ticket_is_refused_by_core_call_checkpoint_question_and_subagent() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network]);
let plugins = Plugins::new(&[("alpha", &["network"])]);
let host = host(&plugins.config.workspace);
let alpha = host.load(&plugins, "alpha");
let guard = host.mint(HostTier::Plugin, &alpha);
let network = ticket(&guard, "network");
let shared = &host.manager.shared;
let cx = |id| HostRequestContext::for_test(id).0;
let refused = |result: Result<Value, RpcErrorWire>, what: &str| {
let error = result.expect_err(what);
assert_eq!(error.code, error_code::REFUSED, "{what}: {error:?}");
assert_eq!(
error.message, "the ticket is not valid for this request",
"{what}"
);
};
refused(
shared
.core_calls
.serve(
shared,
HostTier::Plugin,
HOST,
super::protocol::CoreCallParams {
owner: alpha.clone(),
ticket: network.clone(),
name: "read_file".into(),
input: json!({ "path": "x" }),
},
cx(1),
)
.await,
"core/call",
);
refused(
shared
.core_calls
.serve_checkpoint(
shared,
HostTier::Plugin,
HOST,
super::protocol::SessionCheckpointParams {
owner: alpha.clone(),
ticket: network.clone(),
},
cx(2),
)
.await,
"session/checkpoint",
);
refused(
shared
.core_calls
.serve_question(
shared,
HostTier::Plugin,
HOST,
super::protocol::UserQuestionParams {
owner: alpha.clone(),
ticket: network.clone(),
questions: Vec::new(),
},
cx(3),
)
.await,
"user-questions/ask",
);
refused(
shared
.core_calls
.serve_subagent(
shared,
HostTier::Plugin,
HOST,
HostRequest::SubagentStart(super::protocol::SubagentStartParams {
owner: alpha.clone(),
ticket: network.clone(),
child_id: "child".into(),
prompt: "p".into(),
model: None,
persona: None,
denied_tools: Vec::new(),
delivery: "caller".into(),
}),
cx(4),
)
.await,
"subagent/start",
);
let (still_good, _) = host.call(&echo(), &alpha, &network, "network").await;
assert!(still_good.is_ok(), "{still_good:?}");
}
#[tokio::test]
async fn a_spent_or_just_ended_broker_ticket_is_a_limit_not_a_forgery() {
let _policy = TestPolicyGuard::extension_host(true);
let _offer = TestOffer::only(&[HostService::Network]);
let plugins = Plugins::new(&[("alpha", &["network"])]);
let host = host(&plugins.config.workspace);
let alpha = host.load(&plugins, "alpha");
let guard = host.mint(HostTier::Plugin, &alpha);
let network = ticket(&guard, "network");
for _ in 0..super::core_call::MAX_CALLS_PER_INVOCATION {
let (served, _) = host.call(&echo(), &alpha, &network, "network").await;
assert!(served.is_ok(), "{served:?}");
}
let (spent, violations) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(
refusal(&spent),
(
error_code::REFUSED,
"limit".to_string(),
"this tool call's limit for the network service is used up".to_string()
)
);
assert!(violations.is_empty());
drop(guard);
let mut ended = Vec::new();
for _ in 0..(super::ticket::INVALID_BURST * 3) {
let (late, violations) = host.call(&echo(), &alpha, &network, "network").await;
assert_eq!(refusal(&late).1, "ticket_refused");
ended.extend(violations);
}
assert!(
ended.is_empty(),
"frames already in the pipe when a tool call ends do not end the host: {ended:?}"
);
for _ in 0..super::ticket::INVALID_BURST {
let (_, violations) = host.call(&echo(), &alpha, "cwt.guess", "network").await;
ended.extend(violations);
}
assert_eq!(ended.len(), 1, "a guessed ticket still does: {ended:?}");
}
+841 -4
View File
@@ -1,6 +1,843 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Network)
use base64::{Engine as _, engine::general_purpose::STANDARD};
use codewhale_secrets::Secrets;
use serde::Deserialize;
use serde_json::{Value, json};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::DshService;
use crate::tools::spec::ToolContext;
use crate::tools::web::guard::validate_fetch_target;
pub const CREDENTIAL_HANDLE_PREFIX: &str = "codewhale-credential:";
const MAX_REF_LEN: usize = 128;
const MAX_REDIRECTS: usize = 5;
const MAX_HEADERS: usize = 64;
const MAX_HEADER_BYTES: usize = 16 * 1024;
const MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024;
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_TIMEOUT: Duration = Duration::from_secs(120);
const AUDIT_LABEL: &str = "dsh_network";
const FORBIDDEN_HEADERS: &[&str] = &[
"host",
"connection",
"content-length",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"keep-alive",
"proxy-authorization",
"proxy-connection",
];
const METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
#[derive(Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum NetworkMethod {
#[serde(rename = "fetch")]
Fetch(FetchInput),
#[serde(rename = "credentials.describe")]
Describe(CredentialRef),
#[serde(rename = "credentials.resolve")]
Resolve(CredentialRef),
#[serde(rename = "credentials.set")]
Set(CredentialWrite),
}
impl BrokerMethods for NetworkMethod {
const NAMES: &'static [&'static str] = &[
"fetch",
"credentials.describe",
"credentials.resolve",
"credentials.set",
];
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FetchInput {
url: String,
#[serde(default)]
method: Option<String>,
#[serde(default)]
headers: Vec<(String, String)>,
#[serde(default)]
body_base64: Option<String>,
#[serde(default)]
timeout_ms: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CredentialRef {
#[serde(rename = "ref")]
name: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CredentialWrite {
#[serde(rename = "ref")]
name: String,
value: String,
}
pub(super) struct NetworkAuthority {
pub hosts: BTreeSet<String>,
pub tool: Option<ToolContext>,
pub vault: Option<Secrets>,
}
impl NetworkAuthority {
fn withheld() -> Self {
Self {
hosts: BTreeSet::new(),
tool: None,
vault: None,
}
}
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
serve_with(call, NetworkAuthority::withheld())
}
pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> BrokerFuture {
Box::pin(async move {
let method = decode::<NetworkMethod>(&call)?;
let plugin = call.owner.plugin_id.clone();
match method {
NetworkMethod::Fetch(input) => {
let Some(tool) = authority.tool.as_ref() else {
return Err(BrokerRefusal::NotAvailable(DshService::Network));
};
fetch(
&plugin,
&authority.hosts,
tool,
authority.vault.as_ref(),
input,
)
.await
}
NetworkMethod::Describe(CredentialRef { name }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
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) }))
}
NetworkMethod::Resolve(CredentialRef { name }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
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)) }))
}
NetworkMethod::Set(CredentialWrite { name, value }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
if value.is_empty() || value.contains(CREDENTIAL_HANDLE_PREFIX) {
return Err(invalid(
"a credential value must be a non-empty secret, not a handle",
));
}
vault
.set(&vault_slot(&plugin, name), &value)
.map_err(|_| invalid("the credential vault refused the write"))?;
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, "credential stored");
Ok(json!({ "ref": name, "set": true, "handle": handle(name) }))
}
}
})
}
fn invalid(message: impl Into<String>) -> BrokerRefusal {
BrokerRefusal::InvalidInput {
service: DshService::Network,
message: message.into(),
}
}
fn vault(authority: &NetworkAuthority) -> Result<&Secrets, BrokerRefusal> {
authority
.vault
.as_ref()
.ok_or(BrokerRefusal::NotAvailable(DshService::Network))
}
fn checked_ref(name: &str) -> Result<&str, BrokerRefusal> {
let ok = !name.is_empty()
&& name.len() <= MAX_REF_LEN
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
if ok {
Ok(name)
} else {
Err(invalid(format!(
"a credential ref is 1 to {MAX_REF_LEN} letters, digits, `_`, `-` or `.`"
)))
}
}
fn handle(name: &str) -> String {
format!("{CREDENTIAL_HANDLE_PREFIX}{name}")
}
fn vault_slot(plugin: &str, name: &str) -> String {
format!("dsh-plugin:{plugin}:{name}")
}
fn read_secret(vault: &Secrets, plugin: &str, name: &str) -> Result<Option<String>, BrokerRefusal> {
vault
.get(&vault_slot(plugin, name))
.map(|value| value.filter(|value| !value.is_empty()))
.map_err(|_| invalid("the credential vault could not be read"))
}
fn normalized_host(host: &str) -> String {
host.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.trim_end_matches('.')
.to_ascii_lowercase()
}
fn same_origin(a: &reqwest::Url, b: &reqwest::Url) -> bool {
a.scheme() == b.scheme()
&& a.host_str().map(normalized_host) == b.host_str().map(normalized_host)
&& a.port_or_known_default() == b.port_or_known_default()
}
fn egress_host(url: &reqwest::Url, hosts: &BTreeSet<String>) -> Result<String, BrokerRefusal> {
if url.scheme() != "http" && url.scheme() != "https" {
return Err(invalid(
"egress refused: only http and https URLs are brokered",
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(invalid("egress refused: URLs may not carry userinfo"));
}
let host = url
.host_str()
.map(normalized_host)
.ok_or_else(|| invalid("egress refused: the URL has no host"))?;
if hosts.iter().any(|allowed| normalized_host(allowed) == host) {
Ok(host)
} else {
Err(invalid(format!(
"egress refused: `{}` is not one of this plugin's reviewed network hosts",
host.chars().take(253).collect::<String>()
)))
}
}
struct Substituted {
headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
secrets: Vec<(String, String)>,
}
fn substitute_headers(
plugin: &str,
vault: Option<&Secrets>,
headers: &[(String, String)],
) -> Result<Substituted, BrokerRefusal> {
let mut out = Vec::with_capacity(headers.len());
let mut secrets: Vec<(String, String)> = Vec::new();
for (name, value) in headers {
let mut resolved = String::with_capacity(value.len());
let mut rest = value.as_str();
while let Some(at) = rest.find(CREDENTIAL_HANDLE_PREFIX) {
resolved.push_str(&rest[..at]);
let tail = &rest[at + CREDENTIAL_HANDLE_PREFIX.len()..];
let end = tail
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')))
.unwrap_or(tail.len());
let reference = checked_ref(&tail[..end])?;
let vault = vault.ok_or(BrokerRefusal::NotAvailable(DshService::Network))?;
let secret = read_secret(vault, plugin, reference)?.ok_or_else(|| {
invalid(format!(
"credential `{reference}` is not set for this plugin"
))
})?;
resolved.push_str(&secret);
if !secrets.iter().any(|(_, known)| *known == secret) {
secrets.push((handle(reference), secret));
}
rest = &tail[end..];
}
resolved.push_str(rest);
let header_name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|_| invalid("a request header name is not valid"))?;
let header_value = reqwest::header::HeaderValue::from_str(&resolved).map_err(|_| {
invalid(format!(
"request header `{header_name}` has an invalid value"
))
})?;
out.push((header_name, header_value));
}
Ok(Substituted {
headers: out,
secrets,
})
}
fn scrub(bytes: &[u8], secrets: &[(String, String)]) -> Vec<u8> {
let mut out = bytes.to_vec();
for (handle, secret) in secrets {
let needle = secret.as_bytes();
if needle.is_empty() {
continue;
}
let mut next = Vec::with_capacity(out.len());
let mut i = 0;
while i < out.len() {
if out[i..].starts_with(needle) {
next.extend_from_slice(handle.as_bytes());
i += needle.len();
} else {
next.push(out[i]);
i += 1;
}
}
out = next;
}
out
}
async fn fetch(
plugin: &str,
hosts: &BTreeSet<String>,
tool: &ToolContext,
vault: Option<&Secrets>,
input: FetchInput,
) -> Result<Value, BrokerRefusal> {
let mut url = reqwest::Url::parse(&input.url).map_err(|_| invalid("the URL is not valid"))?;
if input.url.contains(CREDENTIAL_HANDLE_PREFIX) {
return Err(invalid(
"credential handles are substituted only in request header values",
));
}
let mut method = input
.method
.as_deref()
.unwrap_or("GET")
.to_ascii_uppercase();
if !METHODS.contains(&method.as_str()) {
return Err(invalid(
"the request method is not one the network broker sends",
));
}
if input.headers.len() > MAX_HEADERS
|| input
.headers
.iter()
.map(|(name, value)| name.len() + value.len())
.sum::<usize>()
> MAX_HEADER_BYTES
{
return Err(invalid("too many or too large request headers"));
}
if let Some((name, _)) = input.headers.iter().find(|(name, _)| {
FORBIDDEN_HEADERS
.iter()
.any(|forbidden| name.eq_ignore_ascii_case(forbidden))
}) {
return Err(invalid(format!(
"request header `{}` is set by the broker, not the plugin",
name.chars().take(64).collect::<String>()
)));
}
let mut body = match input.body_base64.as_deref() {
Some(encoded) => {
let bytes = STANDARD
.decode(encoded)
.map_err(|_| invalid("body_base64 is not valid base64"))?;
if bytes.len() > MAX_REQUEST_BYTES {
return Err(invalid("the request body is too large"));
}
Some(bytes)
}
None => None,
};
let timeout = input
.timeout_ms
.map_or(DEFAULT_TIMEOUT, Duration::from_millis)
.min(MAX_TIMEOUT);
let deadline = Instant::now() + timeout;
let origin = url.clone();
let host = egress_host(&url, hosts)?;
let mut redirects = 0usize;
let mut substituted: Option<Substituted> = None;
let response = loop {
egress_host(&url, hosts)?;
let pin = validate_fetch_target(&url, tool, AUDIT_LABEL)
.await
.map_err(|error| invalid(format!("egress refused: {error}")))?;
let substitution = match substituted.take() {
Some(done) => done,
None => substitute_headers(plugin, vault, &input.headers)?,
};
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(invalid("the request timed out"));
}
let mut builder = codewhale_net::guard::guarded_reqwest_client_builder()
.timeout(remaining)
.redirect(reqwest::redirect::Policy::none());
if let Some((name, ip)) = pin {
builder = builder.resolve(&name, std::net::SocketAddr::new(ip, 0));
}
let client = builder
.build()
.map_err(|_| invalid("the network broker could not build its client"))?;
let verb = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|_| invalid("the request method is not valid"))?;
let mut request = client.request(verb, url.clone());
for (name, value) in &substitution.headers {
request = request.header(name, value);
}
if let Some(bytes) = body.clone() {
request = request.body(bytes);
}
let response = request.send().await.map_err(|error| {
invalid(format!(
"the request failed: {}",
if error.is_timeout() {
"timed out"
} else if error.is_connect() {
"could not connect"
} else {
"transport error"
}
))
})?;
substituted = Some(substitution);
let status = response.status();
if !status.is_redirection() {
break response;
}
let Some(location) = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
else {
break response;
};
if redirects >= MAX_REDIRECTS {
return Err(invalid("the request exceeded the five-redirect limit"));
}
let next = url
.join(location)
.map_err(|_| invalid("the redirect location is not valid"))?;
if !same_origin(&origin, &next) {
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, host = %host, "cross-origin redirect refused");
return Err(invalid(
"egress refused: a redirect may not leave the requested origin",
));
}
if status == reqwest::StatusCode::SEE_OTHER
|| (matches!(status.as_u16(), 301 | 302) && method == "POST")
{
method = "GET".into();
body = None;
}
url = next;
redirects += 1;
};
let secrets = substituted.map(|done| done.secrets).unwrap_or_default();
let status = response.status().as_u16();
let final_url = response.url().to_string();
let headers: Vec<(String, String)> = response
.headers()
.iter()
.filter_map(|(name, value)| {
let value = String::from_utf8(scrub(value.as_bytes(), &secrets)).ok()?;
Some((name.as_str().to_string(), value))
})
.collect();
let mut response = response;
let mut bytes = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let chunk = tokio::time::timeout(remaining, response.chunk())
.await
.map_err(|_| invalid("the response timed out"))?
.map_err(|_| invalid("the response body could not be read"))?;
let Some(chunk) = chunk else { break };
if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
return Err(invalid(
"the response body is larger than the broker returns",
));
}
bytes.extend_from_slice(&chunk);
}
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, host = %host, status, redirects, "brokered fetch");
Ok(json!({
"status": status,
"url": final_url,
"redirected": redirects > 0,
"headers": headers,
"body_base64": STANDARD.encode(scrub(&bytes, &secrets)),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_config::OwnerRef;
use codewhale_net::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
use codewhale_secrets::InMemoryKeyringStore;
use std::sync::Arc;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
const SECRET: &str = "sk-live-0123456789abcdef";
fn owner(plugin: &str) -> OwnerRef {
OwnerRef {
plugin_id: plugin.into(),
generation: 1,
owner_token: "t".into(),
}
}
fn call(plugin: &str, method: &str, input: Value) -> BrokerCall {
BrokerCall {
owner: owner(plugin),
session_id: "s".into(),
service: DshService::Network,
method: method.into(),
input,
context: super::super::dsh_broker::BrokerContext::detached_for_test(
&owner(plugin),
DshService::Network,
),
}
}
fn policy(allow: &[&str], deny: &[&str]) -> ToolContext {
let policy = NetworkPolicy {
default: Decision::Deny.into(),
allow: allow.iter().map(|h| (*h).to_string()).collect(),
deny: deny.iter().map(|h| (*h).to_string()).collect(),
proxy: Vec::new(),
proxy_fake_ip_cidrs: Vec::new(),
audit: false,
};
ToolContext::new(std::path::PathBuf::from("."))
.with_network_policy(NetworkPolicyDecider::new(policy, None))
}
fn authority(hosts: &[&str], tool: ToolContext, vault: &Secrets) -> NetworkAuthority {
NetworkAuthority {
hosts: hosts.iter().map(|h| (*h).to_string()).collect(),
tool: Some(tool),
vault: Some(vault.clone()),
}
}
fn vault() -> Secrets {
Secrets::new(Arc::new(InMemoryKeyringStore::new()))
}
fn code(result: &Result<Value, BrokerRefusal>) -> &'static str {
result.as_ref().expect_err("a refusal").code()
}
fn message(result: &Result<Value, BrokerRefusal>) -> String {
format!("{:?}", result.as_ref().expect_err("a refusal"))
}
struct Echo;
impl Respond for Echo {
fn respond(&self, request: &Request) -> ResponseTemplate {
let auth = request
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
ResponseTemplate::new(200)
.insert_header("x-echo", auth.as_str())
.set_body_string(format!("you sent {auth}"))
}
}
#[tokio::test]
async fn withheld_authority_refuses_typed_and_unknown_methods_are_named() {
let unknown = serve(call("a", "connect", json!({}))).await;
assert_eq!(code(&unknown), "unknown_method");
let bad = serve(call("a", "fetch", json!({"url": 1}))).await;
assert_eq!(code(&bad), "invalid_input");
for (method, input) in [
("fetch", json!({"url": "https://example.com/"})),
("credentials.resolve", json!({"ref": "K"})),
("credentials.describe", json!({"ref": "K"})),
("credentials.set", json!({"ref": "K", "value": "v"})),
] {
assert_eq!(
code(&serve(call("a", method, input)).await),
"not_available",
"{method}"
);
}
}
#[tokio::test]
async fn credentials_never_leave_rust_and_are_scoped_to_their_plugin() {
let vault = vault();
let tool = policy(&[], &[]);
let set = serve_with(
call(
"a",
"credentials.set",
json!({"ref": "OPENAI_API_KEY", "value": SECRET}),
),
authority(&[], tool.clone(), &vault),
)
.await
.unwrap();
assert!(!set.to_string().contains(SECRET));
let resolved = serve_with(
call("a", "credentials.resolve", json!({"ref": "OPENAI_API_KEY"})),
authority(&[], tool.clone(), &vault),
)
.await
.unwrap();
assert_eq!(resolved["value"], "codewhale-credential:OPENAI_API_KEY");
assert!(!resolved.to_string().contains(SECRET));
let foreign = serve_with(
call("b", "credentials.resolve", json!({"ref": "OPENAI_API_KEY"})),
authority(&[], tool.clone(), &vault),
)
.await
.unwrap();
assert_eq!(foreign["value"], Value::Null);
let traversal = serve_with(
call(
"b",
"credentials.describe",
json!({"ref": "../a:OPENAI_API_KEY"}),
),
authority(&[], tool, &vault),
)
.await;
assert_eq!(code(&traversal), "invalid_input");
}
#[tokio::test]
async fn allowed_host_gets_the_secret_and_the_wire_back_never_carries_it() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", format!("Bearer {SECRET}").as_str()))
.respond_with(Echo)
.expect(1)
.mount(&server)
.await;
let vault = vault();
vault.set(&vault_slot("a", "KEY"), SECRET).unwrap();
let result = serve_with(
call(
"a",
"fetch",
json!({
"url": format!("{}/v1/models", server.uri()),
"headers": [["authorization", "Bearer codewhale-credential:KEY"]],
}),
),
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
)
.await
.unwrap();
assert_eq!(result["status"], 200);
let wire = result.to_string();
assert!(!wire.contains(SECRET), "{wire}");
let body = STANDARD
.decode(result["body_base64"].as_str().unwrap())
.unwrap();
assert_eq!(
String::from_utf8(body).unwrap(),
"you sent Bearer codewhale-credential:KEY"
);
assert!(
result["headers"]
.as_array()
.unwrap()
.iter()
.any(|pair| pair[0] == "x-echo" && pair[1] == "Bearer codewhale-credential:KEY")
);
}
#[tokio::test]
async fn unreviewed_denied_and_restricted_hosts_are_refused_before_any_connection() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let vault = vault();
let url = format!("{}/x", server.uri());
let unreviewed = serve_with(
call("a", "fetch", json!({"url": url})),
authority(&["api.example.com"], policy(&["127.0.0.1"], &[]), &vault),
)
.await;
assert_eq!(code(&unreviewed), "invalid_input");
assert!(message(&unreviewed).contains("reviewed network hosts"));
let denied = serve_with(
call("a", "fetch", json!({"url": url})),
authority(&["127.0.0.1"], policy(&[], &["127.0.0.1"]), &vault),
)
.await;
assert!(
message(&denied).contains("blocked by network policy"),
"{}",
message(&denied)
);
let unapproved = serve_with(
call("a", "fetch", json!({"url": url})),
authority(&["127.0.0.1"], policy(&[], &[]), &vault),
)
.await;
assert!(message(&unapproved).contains("egress refused"));
let metadata = serve_with(
call(
"a",
"fetch",
json!({"url": "http://169.254.169.254/latest"}),
),
authority(
&["169.254.169.254"],
policy(&["169.254.169.254"], &[]),
&vault,
),
)
.await;
assert!(
message(&metadata).contains("restricted"),
"{}",
message(&metadata)
);
let rebound = serve_with(
call("a", "fetch", json!({"url": "http://localhost/"})),
authority(
&["localhost"],
ToolContext::new(std::path::PathBuf::from(".")),
&vault,
),
)
.await;
assert!(
message(&rebound).contains("localhost"),
"{}",
message(&rebound)
);
let in_url = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{url}?k=codewhale-credential:KEY")}),
),
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
)
.await;
assert_eq!(code(&in_url), "invalid_input");
let host_header = serve_with(
call(
"a",
"fetch",
json!({"url": url, "headers": [["Host", "evil.example"]]}),
),
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
)
.await;
assert_eq!(code(&host_header), "invalid_input");
}
#[tokio::test]
async fn redirects_stay_on_origin_and_never_carry_the_secret_elsewhere() {
let server = MockServer::start().await;
let elsewhere = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&elsewhere)
.await;
Mock::given(path("/away"))
.respond_with(
ResponseTemplate::new(302)
.insert_header("location", format!("{}/steal", elsewhere.uri()).as_str()),
)
.mount(&server)
.await;
Mock::given(path("/rename"))
.respond_with(ResponseTemplate::new(302).insert_header(
"location",
server.uri().replace("127.0.0.1", "localhost").as_str(),
))
.mount(&server)
.await;
Mock::given(path("/hop"))
.respond_with(ResponseTemplate::new(307).insert_header("location", "/landing"))
.mount(&server)
.await;
Mock::given(path("/landing"))
.and(header("authorization", SECRET))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.expect(1)
.mount(&server)
.await;
let vault = vault();
vault.set(&vault_slot("a", "KEY"), SECRET).unwrap();
let headers = json!([["authorization", "codewhale-credential:KEY"]]);
let both = authority(
&["127.0.0.1", "localhost"],
policy(&["127.0.0.1", "localhost"], &[]),
&vault,
);
let away = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{}/away", server.uri()), "headers": headers}),
),
both,
)
.await;
assert!(message(&away).contains("may not leave the requested origin"));
let renamed = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{}/rename", server.uri()), "headers": headers}),
),
authority(
&["127.0.0.1", "localhost"],
policy(&["127.0.0.1", "localhost"], &[]),
&vault,
),
)
.await;
assert!(message(&renamed).contains("may not leave the requested origin"));
let hop = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{}/hop", server.uri()), "headers": headers}),
),
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
)
.await
.unwrap();
assert_eq!(hop["status"], 200);
assert_eq!(hop["redirected"], true);
}
}
+566 -3
View File
@@ -1,6 +1,569 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::DshService;
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Remote)
pub const PAIRING_TTL: Duration = Duration::from_secs(10 * 60);
pub const CLIENT_TTL: Duration = Duration::from_secs(30 * 60);
pub const MAX_PENDING_PAIRINGS: usize = 4;
const MAX_LABEL_CHARS: usize = 64;
const GRANT_PREFIX: &str = "cwrg_";
const PAIRING_PREFIX: &str = "cwrp_";
const CLIENT_PREFIX: &str = "cwrc_";
#[derive(Deserialize)]
#[serde(
tag = "method",
content = "input",
rename_all = "snake_case",
deny_unknown_fields
)]
enum RemoteMethod {
Status {},
Pair {
#[serde(default)]
label: Option<String>,
},
Clients {},
Revoke {
grant: String,
},
}
impl BrokerMethods for RemoteMethod {
const NAMES: &'static [&'static str] = &["status", "pair", "clients", "revoke"];
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePairing {
pub grant: String,
pub plugin_id: String,
pub session_id: String,
pub label: String,
pub secret: String,
pub expires_in_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemoteClient {
pub grant: String,
pub session_id: String,
pub token: String,
pub expires_in_seconds: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteDenial {
Unknown,
Expired,
Replayed,
ForeignSession,
}
struct Client {
token_digest: [u8; 32],
label: String,
expires_at: Instant,
}
struct Grant {
plugin_id: String,
session_id: String,
label: String,
pairing_digest: [u8; 32],
pairing: Option<String>,
pairing_expires_at: Instant,
client: Option<Client>,
}
impl Grant {
fn state(&self, now: Instant) -> &'static str {
match &self.client {
Some(client) if now < client.expires_at => "paired",
Some(_) => "expired",
None if now < self.pairing_expires_at => "awaiting_host",
None => "expired",
}
}
fn retire_at(&self, pairing_ttl: Duration) -> Instant {
self.client
.as_ref()
.map_or(self.pairing_expires_at, |client| client.expires_at)
+ pairing_ttl
}
}
#[derive(Default)]
struct Grants {
by_id: HashMap<String, Grant>,
by_pairing: HashMap<[u8; 32], String>,
by_client: HashMap<[u8; 32], String>,
}
impl Grants {
fn remove(&mut self, id: &str) -> Option<Grant> {
let grant = self.by_id.remove(id)?;
self.by_pairing.remove(&grant.pairing_digest);
if let Some(client) = &grant.client {
self.by_client.remove(&client.token_digest);
}
Some(grant)
}
fn prune(&mut self, now: Instant, pairing_ttl: Duration) {
let retired: Vec<String> = self
.by_id
.iter()
.filter(|(_, grant)| now >= grant.retire_at(pairing_ttl))
.map(|(id, _)| id.clone())
.collect();
for id in retired {
self.remove(&id);
}
}
}
pub struct RemoteGrants {
pairing_ttl: Duration,
client_ttl: Duration,
grants: Mutex<Grants>,
}
pub fn remote_grants() -> &'static RemoteGrants {
static GRANTS: OnceLock<RemoteGrants> = OnceLock::new();
GRANTS.get_or_init(|| RemoteGrants::new(PAIRING_TTL, CLIENT_TTL))
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(std::future::ready(remote_grants().serve(&call)))
}
impl RemoteGrants {
#[must_use]
pub fn new(pairing_ttl: Duration, client_ttl: Duration) -> Self {
Self {
pairing_ttl,
client_ttl,
grants: Mutex::new(Grants::default()),
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, Grants> {
let mut grants = self
.grants
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
grants.prune(Instant::now(), self.pairing_ttl);
grants
}
fn serve(&self, call: &BrokerCall) -> Result<Value, BrokerRefusal> {
let plugin = call.owner.plugin_id.as_str();
let session = call.session_id.as_str();
match decode::<RemoteMethod>(call)? {
RemoteMethod::Status {} => Ok(self.status(plugin, session)),
RemoteMethod::Pair { label } => self.pair(plugin, session, label.as_deref()),
RemoteMethod::Clients {} => Ok(self.clients(plugin, session)),
RemoteMethod::Revoke { grant } => self.revoke(plugin, session, &grant),
}
}
fn status(&self, plugin: &str, session: &str) -> Value {
let grants = self.lock();
let now = Instant::now();
let mut awaiting = 0;
let mut paired = 0;
for grant in grants.by_id.values() {
if grant.plugin_id != plugin || grant.session_id != session {
continue;
}
match grant.state(now) {
"awaiting_host" => awaiting += 1,
"paired" => paired += 1,
_ => {}
}
}
json!({
"transport": "runtime_http_sse",
"pairing": "host_presented",
"public_listener": false,
"tunnel": false,
"awaiting_host": awaiting,
"paired": paired,
"max_pending": MAX_PENDING_PAIRINGS,
})
}
fn pair(
&self,
plugin: &str,
session: &str,
label: Option<&str>,
) -> Result<Value, BrokerRefusal> {
let label = clean_label(label).map_err(|message| BrokerRefusal::InvalidInput {
service: DshService::Remote,
message,
})?;
let mut grants = self.lock();
let now = Instant::now();
let pending = grants
.by_id
.values()
.filter(|grant| {
grant.plugin_id == plugin
&& grant.session_id == session
&& grant.state(now) == "awaiting_host"
})
.count();
if pending >= MAX_PENDING_PAIRINGS {
return Err(BrokerRefusal::InvalidInput {
service: DshService::Remote,
message: format!(
"this plugin already has {MAX_PENDING_PAIRINGS} pairings awaiting the host in this session; revoke one first"
),
});
}
let id = random_capability(GRANT_PREFIX);
let secret = random_capability(PAIRING_PREFIX);
let pairing_digest = digest(&secret);
grants.by_pairing.insert(pairing_digest, id.clone());
grants.by_id.insert(
id.clone(),
Grant {
plugin_id: plugin.to_string(),
session_id: session.to_string(),
label: label.clone(),
pairing_digest,
pairing: Some(secret),
pairing_expires_at: now + self.pairing_ttl,
client: None,
},
);
Ok(json!({
"grant": id,
"label": label,
"state": "awaiting_host",
"expires_in_seconds": self.pairing_ttl.as_secs(),
}))
}
fn clients(&self, plugin: &str, session: &str) -> Value {
let grants = self.lock();
let now = Instant::now();
let mut rows: Vec<Value> = grants
.by_id
.iter()
.filter(|(_, grant)| grant.plugin_id == plugin && grant.session_id == session)
.map(|(id, grant)| {
let expires_at = grant
.client
.as_ref()
.map_or(grant.pairing_expires_at, |client| client.expires_at);
json!({
"grant": id,
"label": grant.label,
"client": grant.client.as_ref().map(|client| client.label.clone()),
"state": grant.state(now),
"expires_in_seconds": expires_at.saturating_duration_since(now).as_secs(),
})
})
.collect();
rows.sort_by(|a, b| a["grant"].as_str().cmp(&b["grant"].as_str()));
json!({ "clients": rows })
}
fn revoke(&self, plugin: &str, session: &str, grant: &str) -> Result<Value, BrokerRefusal> {
let mut grants = self.lock();
let owned = grants
.by_id
.get(grant)
.is_some_and(|row| row.plugin_id == plugin && row.session_id == session);
if !owned {
return Err(BrokerRefusal::InvalidInput {
service: DshService::Remote,
message: "no such remote grant for this plugin and session".into(),
});
}
grants.remove(grant);
Ok(json!({ "grant": grant, "revoked": true }))
}
#[must_use]
pub fn awaiting_host(&self) -> Vec<RemotePairing> {
let grants = self.lock();
let now = Instant::now();
let mut rows: Vec<RemotePairing> = grants
.by_id
.iter()
.filter(|(_, grant)| grant.state(now) == "awaiting_host")
.filter_map(|(id, grant)| {
Some(RemotePairing {
grant: id.clone(),
plugin_id: grant.plugin_id.clone(),
session_id: grant.session_id.clone(),
label: grant.label.clone(),
secret: grant.pairing.clone()?,
expires_in_seconds: grant
.pairing_expires_at
.saturating_duration_since(now)
.as_secs(),
})
})
.collect();
rows.sort_by(|a, b| a.grant.cmp(&b.grant));
rows
}
pub fn redeem(&self, secret: &str, client_label: &str) -> Result<RemoteClient, RemoteDenial> {
let mut grants = self.lock();
let now = Instant::now();
let id = grants
.by_pairing
.get(&digest(secret))
.cloned()
.ok_or(RemoteDenial::Unknown)?;
let client_ttl = self.client_ttl;
let grant = grants.by_id.get_mut(&id).ok_or(RemoteDenial::Unknown)?;
if grant.client.is_some() {
return Err(RemoteDenial::Replayed);
}
if now >= grant.pairing_expires_at {
return Err(RemoteDenial::Expired);
}
let token = random_capability(CLIENT_PREFIX);
let token_digest = digest(&token);
grant.pairing = None;
grant.client = Some(Client {
token_digest,
label: clean_label(Some(client_label)).unwrap_or_default(),
expires_at: now + client_ttl,
});
let session_id = grant.session_id.clone();
grants.by_client.insert(token_digest, id.clone());
Ok(RemoteClient {
grant: id,
session_id,
token,
expires_in_seconds: client_ttl.as_secs(),
})
}
pub fn authorize(&self, token: &str, session_id: &str) -> Result<String, RemoteDenial> {
let grants = self.lock();
let id = grants
.by_client
.get(&digest(token))
.ok_or(RemoteDenial::Unknown)?;
let grant = grants.by_id.get(id).ok_or(RemoteDenial::Unknown)?;
let client = grant.client.as_ref().ok_or(RemoteDenial::Unknown)?;
if Instant::now() >= client.expires_at {
return Err(RemoteDenial::Expired);
}
if grant.session_id != session_id {
return Err(RemoteDenial::ForeignSession);
}
Ok(id.clone())
}
pub fn revoke_session(&self, session_id: &str) -> usize {
let mut grants = self.lock();
let ids: Vec<String> = grants
.by_id
.iter()
.filter(|(_, grant)| grant.session_id == session_id)
.map(|(id, _)| id.clone())
.collect();
for id in &ids {
grants.remove(id);
}
ids.len()
}
}
fn clean_label(label: Option<&str>) -> Result<String, String> {
let label = label.unwrap_or("").trim();
if label.chars().any(char::is_control) {
return Err("a remote label may not contain control characters".into());
}
if label.chars().count() > MAX_LABEL_CHARS {
return Err(format!(
"a remote label is at most {MAX_LABEL_CHARS} characters"
));
}
Ok(label.to_string())
}
fn digest(secret: &str) -> [u8; 32] {
Sha256::digest(secret.as_bytes()).into()
}
fn random_capability(prefix: &str) -> String {
format!(
"{prefix}{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_config::OwnerRef;
fn call(plugin: &str, session: &str, method: &str, input: Value) -> BrokerCall {
let owner = OwnerRef {
plugin_id: plugin.into(),
generation: 1,
owner_token: "owner".into(),
};
BrokerCall {
context: super::super::dsh_broker::BrokerContext::detached_for_test(
&owner,
DshService::Remote,
),
owner,
session_id: session.into(),
service: DshService::Remote,
method: method.into(),
input,
}
}
#[tokio::test]
async fn remote_grants_pair_present_redeem_once_and_revoke_bound_to_plugin_and_session() {
let grants = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
let paired = grants
.serve(&call("a", "s1", "pair", json!({"label": "phone"})))
.unwrap();
let id = paired["grant"].as_str().unwrap().to_string();
assert_eq!(paired["state"], "awaiting_host");
assert!(!paired.to_string().contains(PAIRING_PREFIX));
let shown = grants.awaiting_host();
assert_eq!(shown.len(), 1);
assert_eq!(
(shown[0].plugin_id.as_str(), shown[0].session_id.as_str()),
("a", "s1")
);
let client = grants.redeem(&shown[0].secret, "pixel").unwrap();
assert_eq!(client.session_id, "s1");
assert_eq!(
grants.redeem(&shown[0].secret, "pixel"),
Err(RemoteDenial::Replayed)
);
assert!(grants.awaiting_host().is_empty());
assert_eq!(grants.authorize(&client.token, "s1"), Ok(id.clone()));
assert_eq!(
grants.authorize(&client.token, "s2"),
Err(RemoteDenial::ForeignSession)
);
let listed = grants
.serve(&call("a", "s1", "clients", json!({})))
.unwrap();
assert_eq!(listed["clients"][0]["state"], "paired");
assert_eq!(listed["clients"][0]["client"], "pixel");
assert!(!listed.to_string().contains(CLIENT_PREFIX));
for (plugin, session) in [("b", "s1"), ("a", "s2")] {
assert_eq!(
grants
.serve(&call(plugin, session, "clients", json!({})))
.unwrap(),
json!({"clients": []})
);
assert!(matches!(
grants.serve(&call(plugin, session, "revoke", json!({"grant": id}))),
Err(BrokerRefusal::InvalidInput { .. })
));
}
assert_eq!(grants.authorize(&client.token, "s1"), Ok(id.clone()));
assert_eq!(
grants
.serve(&call("a", "s1", "revoke", json!({"grant": id})))
.unwrap(),
json!({"grant": id, "revoked": true})
);
assert_eq!(
grants.authorize(&client.token, "s1"),
Err(RemoteDenial::Unknown)
);
}
#[tokio::test]
async fn remote_grants_refuse_stale_pairings_and_clients_and_cap_pending_pairings() {
let stale = RemoteGrants::new(Duration::ZERO, Duration::from_secs(60));
stale.serve(&call("a", "s", "pair", json!({}))).unwrap();
let secret = {
let grants = stale.grants.lock().unwrap();
grants.by_id.values().next().unwrap().pairing.clone()
};
assert!(stale.awaiting_host().is_empty());
assert_eq!(
stale.redeem(&secret.unwrap_or_default(), "x"),
Err(RemoteDenial::Unknown)
);
let expiring = RemoteGrants::new(Duration::from_secs(60), Duration::ZERO);
expiring.serve(&call("a", "s", "pair", json!({}))).unwrap();
let secret = expiring.awaiting_host()[0].secret.clone();
let client = expiring.redeem(&secret, "x").unwrap();
assert_eq!(
expiring.authorize(&client.token, "s"),
Err(RemoteDenial::Expired)
);
let capped = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
for _ in 0..MAX_PENDING_PAIRINGS {
capped.serve(&call("a", "s", "pair", json!({}))).unwrap();
}
assert!(matches!(
capped.serve(&call("a", "s", "pair", json!({}))),
Err(BrokerRefusal::InvalidInput { .. })
));
assert!(capped.serve(&call("b", "s", "pair", json!({}))).is_ok());
assert!(matches!(
capped.serve(&call("a", "s", "pair", json!({"label": "a\u{7}"}))),
Err(BrokerRefusal::InvalidInput { .. })
));
assert_eq!(capped.revoke_session("s"), MAX_PENDING_PAIRINGS + 1);
assert!(capped.awaiting_host().is_empty());
}
#[tokio::test]
async fn remote_methods_decode_strictly_and_refuse_unknown_methods() {
let grants = RemoteGrants::new(PAIRING_TTL, CLIENT_TTL);
for method in ["listen", "tunnel", "expose", "anything"] {
assert!(matches!(
grants.serve(&call("a", "s", method, json!({}))),
Err(BrokerRefusal::UnknownMethod {
service: DshService::Remote,
..
})
));
}
for (method, input) in [
("pair", json!({"label": 3})),
("pair", json!({"label": "x", "public": true})),
("revoke", json!({})),
("status", Value::Null),
] {
assert!(
matches!(
grants.serve(&call("a", "s", method, input.clone())),
Err(BrokerRefusal::InvalidInput { .. })
),
"{method} {input}"
);
}
let status = grants.serve(&call("a", "s", "status", json!({}))).unwrap();
assert_eq!(status["public_listener"], false);
assert_eq!(status["transport"], "runtime_http_sse");
}
}
@@ -1,6 +1,524 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
use std::path::Path;
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Terminal)
use codewhale_agent_contract::worker_profile::ShellPolicy;
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::protocol::DshService;
use crate::tools::execution_envelope::enforce_tool_denial;
const SUPPORTED: bool = cfg!(all(unix, not(target_env = "ohos")));
const DEFAULT_SESSION: &str = "term-1";
const MAX_SESSION_NAME: usize = 64;
const MAX_COMMAND_BYTES: usize = 16 * 1024;
const MAX_INPUT_BYTES: usize = 64 * 1024;
const MAX_TIMEOUT_SECS: u64 = 600;
const MAX_SETTLE_MS: u64 = 60_000;
pub(super) const MAX_TERMINALS: usize = 8;
const LOG: &str = "codewhale::dsh_terminal";
static REGISTRY_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
#[derive(Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum TerminalMethod {
#[serde(rename = "list")]
List(NoInput),
#[serde(rename = "run")]
Run(RunInput),
#[serde(rename = "send")]
Send(SendInput),
#[serde(rename = "wait")]
Wait(WaitInput),
#[serde(rename = "cancel")]
Cancel(SessionInput),
#[serde(rename = "reset")]
Reset(SessionInput),
}
impl BrokerMethods for TerminalMethod {
const NAMES: &'static [&'static str] = &["list", "run", "send", "wait", "cancel", "reset"];
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct NoInput {}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RunInput {
command: String,
#[serde(default)]
session: Option<String>,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SendInput {
session: String,
text: String,
#[serde(default)]
wait_ms: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WaitInput {
session: String,
#[serde(default)]
timeout_secs: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct SessionInput {
session: String,
}
#[derive(Serialize)]
struct Terminal {
terminal_id: String,
session: String,
state: String,
running: bool,
exit_code: Option<u32>,
updated_at: String,
}
struct Live {
instance: String,
terminals: Vec<Terminal>,
}
impl Live {
fn holds(&self, session: &str) -> bool {
self.terminals
.iter()
.any(|terminal| terminal.session == session)
}
fn existing(&self, session: &str) -> Result<(), BrokerRefusal> {
if self.holds(session) {
Ok(())
} else {
Err(invalid(
"this plugin has no live terminal with that name in this session; `list` reports the live ones",
))
}
}
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(async move {
let method = decode::<TerminalMethod>(&call)?;
if call.context.cancel().is_cancelled() {
return Err(refused("the terminal call was withdrawn"));
}
match method {
TerminalMethod::List(NoInput {}) => {
let live = live(&call, "terminal/wait").await?;
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, terminals = live.terminals.len(), "terminals listed");
Ok(json!({
"instance": live.instance,
"limit": MAX_TERMINALS,
"terminals": live.terminals,
}))
}
TerminalMethod::Run(RunInput {
command,
session,
timeout_secs,
}) => {
let session = session_name(session.as_deref().unwrap_or(DEFAULT_SESSION))?;
if command.trim().is_empty()
|| command.len() > MAX_COMMAND_BYTES
|| command.contains('\0')
{
return Err(invalid(format!(
"a command is 1 to {MAX_COMMAND_BYTES} bytes with no NUL"
)));
}
let timeout_secs = timeout(timeout_secs)?;
let live = live(&call, "terminal/run").await?;
if !live.holds(session) && live.terminals.len() >= MAX_TERMINALS {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "terminal limit reached");
return Err(refused(
"this plugin already holds the most terminals it may open in this session",
));
}
let mut input =
json!({ "command": command, "session": owned_name(&call, session) });
if let Some(timeout_secs) = timeout_secs {
input["timeout_secs"] = json!(timeout_secs);
}
nested(&call, "terminal/run", session, input).await
}
TerminalMethod::Send(SendInput {
session,
text,
wait_ms,
}) => {
let session = session_name(&session)?;
if text.is_empty() || text.len() > MAX_INPUT_BYTES {
return Err(invalid(format!(
"terminal input is 1 to {MAX_INPUT_BYTES} bytes"
)));
}
if wait_ms.is_some_and(|wait_ms| wait_ms > MAX_SETTLE_MS) {
return Err(invalid(format!("wait_ms is at most {MAX_SETTLE_MS}")));
}
live(&call, "terminal/send").await?.existing(session)?;
let mut input = json!({ "session": owned_name(&call, session), "text": text });
if let Some(wait_ms) = wait_ms {
input["wait_ms"] = json!(wait_ms);
}
nested(&call, "terminal/send", session, input).await
}
TerminalMethod::Wait(WaitInput {
session,
timeout_secs,
}) => {
let session = session_name(&session)?;
let timeout_secs = timeout(timeout_secs)?;
live(&call, "terminal/wait").await?.existing(session)?;
let mut input = json!({ "session": owned_name(&call, session) });
if let Some(timeout_secs) = timeout_secs {
input["timeout_secs"] = json!(timeout_secs);
}
nested(&call, "terminal/wait", session, input).await
}
TerminalMethod::Cancel(SessionInput { session }) => {
let session = session_name(&session)?;
live(&call, "terminal/cancel").await?.existing(session)?;
let input = json!({ "session": owned_name(&call, session) });
nested(&call, "terminal/cancel", session, input).await
}
TerminalMethod::Reset(SessionInput { session }) => {
let session = session_name(&session)?;
live(&call, "terminal/reset").await?.existing(session)?;
let input = json!({ "session": owned_name(&call, session) });
nested(&call, "terminal/reset", session, input).await
}
}
})
}
fn invalid(message: impl Into<String>) -> BrokerRefusal {
BrokerRefusal::InvalidInput {
service: DshService::Terminal,
message: message.into(),
}
}
fn refused(message: &str) -> BrokerRefusal {
BrokerRefusal::Refused {
service: DshService::Terminal,
message: message.to_string(),
}
}
fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
let ok = !name.is_empty()
&& name.len() <= MAX_SESSION_NAME
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
if ok {
Ok(name)
} else {
Err(invalid(format!(
"a terminal name is 1 to {MAX_SESSION_NAME} letters, digits, `_`, `-` or `.`"
)))
}
}
fn timeout(secs: Option<u64>) -> Result<Option<u64>, BrokerRefusal> {
match secs {
Some(secs) if !(1..=MAX_TIMEOUT_SECS).contains(&secs) => Err(invalid(format!(
"timeout_secs is between 1 and {MAX_TIMEOUT_SECS}"
))),
other => Ok(other),
}
}
fn prefix(plugin: &str) -> String {
format!("dsh:{plugin}:")
}
fn owned_name(call: &BrokerCall, session: &str) -> String {
format!("{}{session}", prefix(&call.owner.plugin_id))
}
async fn live(call: &BrokerCall, tool_name: &'static str) -> Result<Live, BrokerRefusal> {
if !SUPPORTED {
return Err(BrokerRefusal::NotAvailable(DshService::Terminal));
}
let mut tool = call.context.tool().clone();
let _ = tool.refresh_live_posture();
let needs_full = matches!(
tool_name,
"terminal/run" | "terminal/send" | "terminal/reset"
);
if !tool.shell_policy.allows_shell()
|| (needs_full && tool.shell_policy != ShellPolicy::Full)
|| enforce_tool_denial(&tool, tool_name, &json!({})).is_err()
{
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, tool = tool_name, "terminal refused: the caller may not use this shell tool");
return Err(refused("terminals are not available to this caller"));
}
let workspace = tool.workspace.clone();
let owner = tool.state_namespace.clone();
let mine = prefix(&call.owner.plugin_id);
let read = async move {
let permit = REGISTRY_GATE.acquire().await.map_err(|_| ())?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
owned(&workspace, &owner, &mine)
})
.await
.map_err(|_| ())?
.map_err(|error| {
tracing::warn!(target: LOG, %error, "terminal registry read failed");
})
};
tokio::select! {
biased;
() = call.context.cancel().cancelled() => Err(refused("the terminal call was withdrawn")),
live = read => live.map_err(|()| refused("the terminal registry could not be read")),
}
}
#[cfg(all(unix, not(target_env = "ohos")))]
fn owned(workspace: &Path, owner: &str, mine: &str) -> Result<Live, String> {
use crate::tools::terminal_session;
let terminals = terminal_session::owned_sessions(workspace, owner)?
.into_iter()
.filter_map(|info| {
let session = info.name.strip_prefix(mine)?;
session_name(session).ok()?;
Some(Terminal {
session: session.to_string(),
state: match info.exit_code {
Some(_) => "exited".to_string(),
None => serde_json::to_value(info.state)
.ok()
.and_then(|state| state.as_str().map(str::to_owned))
.unwrap_or_default(),
},
running: info.exit_code.is_none(),
exit_code: info.exit_code,
terminal_id: info.id,
updated_at: info.updated_at,
})
})
.collect();
Ok(Live {
instance: terminal_session::instance_id().to_string(),
terminals,
})
}
#[cfg(not(all(unix, not(target_env = "ohos"))))]
fn owned(_workspace: &Path, _owner: &str, _mine: &str) -> Result<Live, String> {
Err("terminal sessions are not supported on this platform".to_string())
}
async fn nested(
call: &BrokerCall,
tool: &'static str,
session: &str,
input: Value,
) -> Result<Value, BrokerRefusal> {
let plugin = &call.owner.plugin_id;
match call.context.core_call(tool, input).await {
Ok(response) => {
tracing::info!(target: LOG, %plugin, tool, session, ok = response.ok, "terminal call ran through the gate");
Ok(outcome(session, response))
}
Err(refusal) => {
let code = refusal.code();
let detail = match &refusal {
BrokerRefusal::Denied { message, .. } | BrokerRefusal::Refused { message, .. } => {
message.as_str()
}
_ => "",
};
tracing::info!(target: LOG, %plugin, tool, session, code, detail, "terminal call not run");
Err(match refusal {
BrokerRefusal::Denied { .. } => BrokerRefusal::Denied {
service: DshService::Terminal,
message: "the terminal call was denied".to_string(),
},
_ => refused("the terminal call was refused"),
})
}
}
}
fn text(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn outcome(session: &str, response: ToolCallResponse) -> Value {
if !response.ok {
return json!({ "ok": false, "session": session, "output": text(&response.result) });
}
let metadata = &response.result["metadata"];
json!({
"ok": true,
"session": session,
"output": text(&response.result["content"]),
"truncated": !response.result["truncated"].is_null(),
"status": metadata["status"],
"exit_code": metadata["exit_code"],
"cwd": metadata["cwd"],
"terminal_id": metadata["terminal_session_id"],
"state": metadata["terminal_state"],
"previous_terminal_id": metadata["previous_session_id"],
})
}
#[cfg(test)]
mod tests {
use super::super::dsh_broker::BrokerContext;
use super::*;
use codewhale_config::OwnerRef;
fn call(owner: &OwnerRef, method: &str, input: Value) -> BrokerCall {
BrokerCall {
owner: owner.clone(),
session_id: "session".into(),
service: DshService::Terminal,
method: method.into(),
input,
context: BrokerContext::detached_for_test(owner, DshService::Terminal),
}
}
fn owner() -> OwnerRef {
OwnerRef {
plugin_id: "a".into(),
generation: 1,
owner_token: "t".into(),
}
}
#[tokio::test]
async fn terminal_methods_decode_strictly_and_bound_every_input_before_anything_runs() {
let owner = owner();
for method in ["spawn", "open", "kill", "resize", "read", "exec", "core"] {
let refusal = serve(call(&owner, method, json!({}))).await.unwrap_err();
assert_eq!(refusal.code(), "unknown_method", "{method}");
}
let long_name = "n".repeat(MAX_SESSION_NAME + 1);
let long_command = "c".repeat(MAX_COMMAND_BYTES + 1);
let long_text = "t".repeat(MAX_INPUT_BYTES + 1);
for (method, input) in [
("list", json!({ "workspace": "/" })),
("run", json!({})),
("run", json!({ "command": "" })),
("run", json!({ "command": " \n" })),
("run", json!({ "command": "a\u{0}b" })),
("run", json!({ "command": long_command })),
("run", json!({ "command": "true", "session": "" })),
("run", json!({ "command": "true", "session": "../term-1" })),
("run", json!({ "command": "true", "session": "dsh:b:x" })),
("run", json!({ "command": "true", "session": long_name })),
("run", json!({ "command": "true", "timeout_secs": 0 })),
("run", json!({ "command": "true", "timeout_secs": 601 })),
(
"run",
json!({ "command": "true", "sandbox_permissions": "danger" }),
),
("run", json!({ "command": "true", "name": "exec_shell" })),
("send", json!({ "session": "x" })),
("send", json!({ "session": "x", "text": "" })),
("send", json!({ "session": "x", "text": long_text })),
(
"send",
json!({ "session": "x", "text": "y", "wait_ms": 60_001 }),
),
("wait", json!({ "session": "x", "timeout_secs": 601 })),
("wait", json!({})),
("cancel", json!({ "session": "a b" })),
("reset", json!({ "session": "x", "force": true })),
] {
let refusal = serve(call(&owner, method, input.clone()))
.await
.unwrap_err();
assert_eq!(refusal.code(), "invalid_input", "{method} {input}");
}
}
#[tokio::test]
async fn a_terminal_call_whose_cancel_has_fired_has_no_effect() {
let owner = owner();
let fired = call(&owner, "run", json!({ "command": "true" }));
fired.context.cancel().cancel();
let refusal = serve(fired).await.unwrap_err();
assert_eq!(refusal.code(), "refused");
}
#[test]
fn terminal_names_are_scoped_to_the_plugin_and_a_result_is_a_typed_projection() {
assert_eq!(prefix("a"), "dsh:a:");
assert!(
"dsh:a:b:x"
.strip_prefix(&prefix("a"))
.is_some_and(|rest| session_name(rest).is_err())
);
let ran = outcome(
"build",
ToolCallResponse {
ok: true,
result: json!({
"content": 42,
"truncated": null,
"metadata": {
"status": "completed",
"exit_code": 0,
"cwd": "/w",
"terminal_session_id": "id-1",
"terminal_state": "idle",
"state_path": "/private/state.json",
},
}),
},
);
assert_eq!(
ran,
json!({
"ok": true,
"session": "build",
"output": "42",
"truncated": false,
"status": "completed",
"exit_code": 0,
"cwd": "/w",
"terminal_id": "id-1",
"state": "idle",
"previous_terminal_id": null,
})
);
let failed = outcome(
"build",
ToolCallResponse {
ok: false,
result: json!("boom"),
},
);
assert_eq!(
failed,
json!({ "ok": false, "session": "build", "output": "boom" })
);
}
}
@@ -1,6 +1,713 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
use std::path::{Path, PathBuf};
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Workspace)
use codewhale_workflow_js::ToolCallResponse;
use serde::Deserialize;
use serde_json::{Value, json};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::DshService;
use crate::tools::execution_envelope::enforce_tool_denial;
use crate::tools::spec::{ToolContext, resolve_strict_authority_path};
const MAX_PATH_BYTES: usize = 512;
const MAX_WRITE_BYTES: usize = 1024 * 1024;
const MAX_READ_LINES: u64 = 10_000;
const MAX_ARGS: usize = 8;
const MAX_ARG_BYTES: usize = 1024;
const PROTECTED: &[&str] = &[".git", ".codewhale"];
const LOG: &str = "codewhale::dsh_workspace";
static CONFINE_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
#[derive(Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum WorkspaceMethod {
#[serde(rename = "read")]
Read(ReadInput),
#[serde(rename = "list")]
List(ListInput),
#[serde(rename = "write")]
Write(WriteInput),
#[serde(rename = "exec")]
Exec(ExecInput),
}
impl BrokerMethods for WorkspaceMethod {
const NAMES: &'static [&'static str] = &["read", "list", "write", "exec"];
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadInput {
path: String,
#[serde(default)]
start_line: Option<u64>,
#[serde(default)]
max_lines: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ListInput {
#[serde(default)]
path: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct WriteInput {
path: String,
content: String,
#[serde(default)]
expected_hash: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ExecInput {
argv: Vec<String>,
#[serde(default)]
cwd: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Access {
Read,
Overlay,
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(async move {
let method = decode::<WorkspaceMethod>(&call)?;
if call.context.cancel().is_cancelled() {
return Err(refused("the workspace call was withdrawn"));
}
match method {
WorkspaceMethod::Read(ReadInput {
path,
start_line,
max_lines,
}) => {
if start_line == Some(0) {
return Err(invalid("start_line starts at 1"));
}
if max_lines.is_some_and(|lines| !(1..=MAX_READ_LINES).contains(&lines)) {
return Err(invalid(format!(
"max_lines is between 1 and {MAX_READ_LINES}"
)));
}
let tool = caller(&call, "read_file")?;
let (shown, resolved) = confine(&call, &tool, &path, Access::Read).await?;
let mut input = json!({ "path": resolved });
if let Some(start_line) = start_line {
input["start_line"] = json!(start_line);
}
if let Some(max_lines) = max_lines {
input["max_lines"] = json!(max_lines);
}
nested(&call, "read_file", input)
.await
.map(|response| file_outcome(&shown, response))
}
WorkspaceMethod::List(ListInput { path }) => {
let tool = caller(&call, "list_dir")?;
let raw = path.as_deref().unwrap_or(".");
let (shown, resolved) = confine(&call, &tool, raw, Access::Read).await?;
nested(&call, "list_dir", json!({ "path": resolved }))
.await
.map(|response| file_outcome(&shown, response))
}
WorkspaceMethod::Write(WriteInput {
path,
content,
expected_hash,
}) => {
if content.len() > MAX_WRITE_BYTES {
return Err(invalid(format!(
"a write is at most {MAX_WRITE_BYTES} bytes"
)));
}
if expected_hash
.as_deref()
.is_some_and(|hash| hash.is_empty() || hash.len() > 128)
{
return Err(invalid("expected_hash is 1 to 128 characters"));
}
if !overlay_granted() {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "workspace write refused: no reviewed overlay grant");
return Err(refused("this plugin may not write to the workspace"));
}
let tool = caller(&call, "write_file")?;
let (shown, resolved) = confine(&call, &tool, &path, Access::Overlay).await?;
let mut input = json!({ "path": resolved, "content": content });
if let Some(expected_hash) = expected_hash {
input["expected_hash"] = json!(expected_hash);
}
nested(&call, "write_file", input)
.await
.map(|response| file_outcome(&shown, response))
}
WorkspaceMethod::Exec(ExecInput { argv, cwd }) => {
let query = literal_git(&argv)?;
let tool = caller(&call, "Git")?;
if let Some(cwd) = cwd.as_deref() {
let (shown, _) = confine(&call, &tool, cwd, Access::Read).await?;
if shown != "." {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "workspace exec refused: cwd is not the workspace root");
return Err(refused("git runs only at the workspace root"));
}
}
let mut input = query;
input["action"] = json!("review_scope");
nested(&call, "Git", input).await.map(exec_outcome)
}
}
})
}
fn invalid(message: impl Into<String>) -> BrokerRefusal {
BrokerRefusal::InvalidInput {
service: DshService::Workspace,
message: message.into(),
}
}
fn refused(message: &str) -> BrokerRefusal {
BrokerRefusal::Refused {
service: DshService::Workspace,
message: message.to_string(),
}
}
#[cfg(test)]
thread_local! {
static TEST_OVERLAY: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
#[cfg(test)]
pub(super) struct TestOverlay(bool);
#[cfg(test)]
impl TestOverlay {
pub(super) fn granted() -> Self {
Self(TEST_OVERLAY.with(|overlay| overlay.replace(true)))
}
}
#[cfg(test)]
impl Drop for TestOverlay {
fn drop(&mut self) {
TEST_OVERLAY.with(|overlay| overlay.set(self.0));
}
}
fn overlay_granted() -> bool {
#[cfg(test)]
if TEST_OVERLAY.with(std::cell::Cell::get) {
return true;
}
false
}
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
let mut tool = call.context.tool().clone();
let _ = tool.refresh_live_posture();
if enforce_tool_denial(&tool, tool_name, &json!({})).is_err() {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, tool = tool_name, "workspace refused: the caller may not use this tool");
return Err(refused(
"this workspace call is not available to this caller",
));
}
Ok(tool)
}
fn relative(workspace: &Path, raw: &str) -> Option<String> {
let path = Path::new(raw);
if !path.is_absolute() {
return Some(raw.to_string());
}
let canonical = workspace.canonicalize().ok();
let rest = path.strip_prefix(workspace).ok().or_else(|| {
canonical
.as_deref()
.and_then(|root| path.strip_prefix(root).ok())
})?;
let rest = rest.to_str()?;
Some(if rest.is_empty() {
".".into()
} else {
rest.into()
})
}
fn shape(raw: &str) -> Result<(), BrokerRefusal> {
if raw.is_empty()
|| raw.len() > MAX_PATH_BYTES
|| raw.trim() != raw
|| raw.chars().any(char::is_control)
{
return Err(invalid(format!(
"a path is 1 to {MAX_PATH_BYTES} bytes on one line with no surrounding spaces"
)));
}
Ok(())
}
fn protected(relative: &str) -> bool {
relative.replace('\\', "/").split('/').any(|segment| {
PROTECTED
.iter()
.any(|name| segment.eq_ignore_ascii_case(name))
})
}
fn resolve(tool: &ToolContext, raw: &str, access: Access) -> Result<(String, PathBuf), String> {
let relative = relative(&tool.workspace, raw).ok_or_else(|| "outside".to_string())?;
let resolved = resolve_strict_authority_path(tool, &relative).map_err(|e| e.to_string())?;
let root = tool.workspace.canonicalize().map_err(|e| e.to_string())?;
let shown = resolved
.strip_prefix(&root)
.map_err(|_| "outside".to_string())?
.to_str()
.ok_or_else(|| "not UTF-8".to_string())?
.replace('\\', "/");
let shown = if shown.is_empty() { ".".into() } else { shown };
if access == Access::Overlay && (shown == "." || protected(&shown)) {
return Err(format!("overlay excludes {shown}"));
}
Ok((shown, resolved))
}
async fn confine(
call: &BrokerCall,
tool: &ToolContext,
raw: &str,
access: Access,
) -> Result<(String, String), BrokerRefusal> {
shape(raw)?;
let owned = tool.clone();
let raw_owned = raw.to_string();
let work = async move {
let permit = CONFINE_GATE
.acquire()
.await
.map_err(|_| "closed".to_string())?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
resolve(&owned, &raw_owned, access)
})
.await
.map_err(|error| error.to_string())?
};
let resolved = tokio::select! {
biased;
() = call.context.cancel().cancelled() => return Err(refused("the workspace call was withdrawn")),
resolved = work => resolved,
};
match resolved {
Ok((shown, path)) => {
let path = path
.to_str()
.ok_or_else(|| refused("that path is not available to this plugin"))?
.to_string();
Ok((shown, path))
}
Err(detail) => {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, %detail, "workspace path refused");
Err(refused("that path is not available to this plugin"))
}
}
}
fn literal_git(argv: &[String]) -> Result<Value, BrokerRefusal> {
if argv.is_empty()
|| argv.len() > MAX_ARGS + 1
|| argv
.iter()
.any(|arg| arg.len() > MAX_ARG_BYTES || arg.chars().any(char::is_control))
{
return Err(invalid(format!(
"argv is 1 to {} strings of at most {MAX_ARG_BYTES} bytes with no control characters",
MAX_ARGS + 1
)));
}
let refuse = || {
tracing::info!(target: LOG, program = %argv[0].chars().take(64).collect::<String>(), "workspace exec refused: not a literal read-only git command");
refused("only read-only git metadata commands run for plugins")
};
if argv[0] != "git" {
return Err(refuse());
}
let args: Vec<&str> = argv[1..].iter().map(String::as_str).collect();
let args = match args.split_first() {
Some((&"--no-optional-locks", rest)) => rest,
_ => &args[..],
};
let query = match args {
["for-each-ref", "--format=%(refname:short)", "refs/heads"] => {
json!({ "query": "branches" })
}
["log", "-n", "100", "--format=%H%x09%s"] => json!({ "query": "commits" }),
["rev-parse", "--show-toplevel"] => json!({ "query": "root" }),
["rev-parse", "--verify", "--end-of-options", rev] => {
json!({ "query": "resolve", "rev": rev })
}
["rev-list", "--left-right", "--count", range] => match range.split_once("...") {
Some((ours, theirs)) if !theirs.contains("...") => {
json!({ "query": "ahead_behind", "ours": ours, "theirs": theirs })
}
_ => return Err(refuse()),
},
["merge-base", ours, theirs] => {
json!({ "query": "merge_base", "ours": ours, "theirs": theirs })
}
_ => return Err(refuse()),
};
Ok(query)
}
async fn nested(
call: &BrokerCall,
tool: &'static str,
input: Value,
) -> Result<ToolCallResponse, BrokerRefusal> {
let plugin = &call.owner.plugin_id;
match call.context.core_call(tool, input).await {
Ok(response) => {
tracing::info!(target: LOG, %plugin, tool, ok = response.ok, "workspace call ran through the gate");
Ok(response)
}
Err(refusal) => {
let code = refusal.code();
let detail = match &refusal {
BrokerRefusal::Denied { message, .. } | BrokerRefusal::Refused { message, .. } => {
message.as_str()
}
_ => "",
};
tracing::info!(target: LOG, %plugin, tool, code, detail, "workspace call not run");
Err(match refusal {
BrokerRefusal::Denied { .. } => BrokerRefusal::Denied {
service: DshService::Workspace,
message: "the workspace call was denied".to_string(),
},
_ => refused("the workspace call was refused"),
})
}
}
}
fn text(value: &Value) -> String {
match value {
Value::String(text) => text.clone(),
Value::Null => String::new(),
other => other.to_string(),
}
}
fn file_outcome(path: &str, response: ToolCallResponse) -> Value {
if !response.ok {
return json!({ "ok": false, "path": path, "output": text(&response.result) });
}
json!({
"ok": true,
"path": path,
"output": text(&response.result["content"]),
"truncated": !response.result["truncated"].is_null(),
})
}
fn exec_outcome(response: ToolCallResponse) -> Value {
if !response.ok {
return json!({ "ok": false, "stdout": "", "stderr": text(&response.result), "exit_code": null });
}
let parsed = serde_json::from_str::<Value>(&text(&response.result["content"]))
.ok()
.filter(|value| value["stdout"].is_string() && value["stderr"].is_string());
match parsed {
Some(value) => json!({
"ok": value["exit_code"] == json!(0),
"stdout": value["stdout"],
"stderr": value["stderr"],
"exit_code": value["exit_code"],
}),
None => json!({
"ok": false,
"stdout": "",
"stderr": "git output exceeded what a plugin call returns",
"exit_code": null,
}),
}
}
#[cfg(test)]
mod tests {
use super::super::dsh_broker::BrokerContext;
use super::*;
use codewhale_config::OwnerRef;
fn call(owner: &OwnerRef, method: &str, input: Value) -> BrokerCall {
BrokerCall {
owner: owner.clone(),
session_id: "session".into(),
service: DshService::Workspace,
method: method.into(),
input,
context: BrokerContext::detached_for_test(owner, DshService::Workspace),
}
}
fn owner() -> OwnerRef {
OwnerRef {
plugin_id: "a".into(),
generation: 1,
owner_token: "t".into(),
}
}
fn argv(args: &[&str]) -> Vec<String> {
args.iter().map(|arg| (*arg).to_string()).collect()
}
#[tokio::test]
async fn workspace_methods_decode_strictly_and_bound_every_input_before_anything_runs() {
let owner = owner();
for method in ["delete", "rename", "spawn", "shell", "watch", "core", "Git"] {
let refusal = serve(call(&owner, method, json!({}))).await.unwrap_err();
assert_eq!(refusal.code(), "unknown_method", "{method}");
}
let long_path = "p".repeat(MAX_PATH_BYTES + 1);
let long_content = "c".repeat(MAX_WRITE_BYTES + 1);
for (method, input) in [
("read", json!({})),
("read", json!({ "path": "" })),
("read", json!({ "path": " x" })),
("read", json!({ "path": "a\nb" })),
("read", json!({ "path": long_path })),
("read", json!({ "path": "x", "start_line": 0 })),
("read", json!({ "path": "x", "max_lines": 0 })),
(
"read",
json!({ "path": "x", "max_lines": MAX_READ_LINES + 1 }),
),
("read", json!({ "path": "x", "follow_symlinks": true })),
("list", json!({ "path": "x", "recursive": true })),
("write", json!({ "path": "x" })),
("write", json!({ "path": "x", "content": long_content })),
(
"write",
json!({ "path": "x", "content": "", "expected_hash": "" }),
),
("write", json!({ "path": "x", "content": "", "mode": 493 })),
("exec", json!({})),
("exec", json!({ "argv": [] })),
("exec", json!({ "argv": ["git", "a\u{0}b"] })),
("exec", json!({ "argv": ["git"], "env": {} })),
("exec", json!({ "argv": ["git"], "shell": true })),
] {
let refusal = serve(call(&owner, method, input.clone()))
.await
.unwrap_err();
assert_eq!(refusal.code(), "invalid_input", "{method} {input}");
}
}
#[tokio::test]
async fn a_workspace_call_whose_cancel_has_fired_has_no_effect() {
let owner = owner();
let _overlay = TestOverlay::granted();
for (method, input) in [
("read", json!({ "path": "x" })),
("list", json!({})),
("write", json!({ "path": "x", "content": "y" })),
(
"exec",
json!({ "argv": ["git", "rev-parse", "--show-toplevel"] }),
),
] {
let fired = call(&owner, method, input);
fired.context.cancel().cancel();
let refusal = serve(fired).await.unwrap_err();
assert_eq!(refusal.code(), "refused", "{method}");
}
}
#[tokio::test]
async fn a_write_without_the_reviewed_overlay_grant_is_refused_before_any_path_is_read() {
let owner = owner();
let refusal = serve(call(
&owner,
"write",
json!({ "path": "x", "content": "y" }),
))
.await
.unwrap_err();
assert_eq!(refusal.code(), "refused");
assert_eq!(
refusal.wire().message,
"this plugin may not write to the workspace"
);
}
#[test]
fn only_literal_read_only_git_metadata_argv_maps_to_the_git_tool() {
for (args, expected) in [
(
argv(&["git", "rev-parse", "--show-toplevel"]),
json!({ "query": "root" }),
),
(
argv(&[
"git",
"--no-optional-locks",
"for-each-ref",
"--format=%(refname:short)",
"refs/heads",
]),
json!({ "query": "branches" }),
),
(
argv(&["git", "log", "-n", "100", "--format=%H%x09%s"]),
json!({ "query": "commits" }),
),
(
argv(&["git", "rev-parse", "--verify", "--end-of-options", "main"]),
json!({ "query": "resolve", "rev": "main" }),
),
(
argv(&["git", "rev-list", "--left-right", "--count", "a...b"]),
json!({ "query": "ahead_behind", "ours": "a", "theirs": "b" }),
),
(
argv(&["git", "merge-base", "a", "b"]),
json!({ "query": "merge_base", "ours": "a", "theirs": "b" }),
),
] {
assert_eq!(literal_git(&args).unwrap(), expected, "{args:?}");
}
for args in [
argv(&["sh", "-c", "git status"]),
argv(&["/usr/bin/git", "rev-parse", "--show-toplevel"]),
argv(&["git"]),
argv(&["git", "status"]),
argv(&["git", "rev-parse", "--show-toplevel", "--git-dir"]),
argv(&[
"git",
"-c",
"core.fsmonitor=x",
"rev-parse",
"--show-toplevel",
]),
argv(&[
"git",
"--no-optional-locks",
"--no-optional-locks",
"rev-parse",
"--show-toplevel",
]),
argv(&["git", "log", "-n", "100", "--format=%H%x09%s", "-p"]),
argv(&["git", "rev-list", "--left-right", "--count", "a..b"]),
argv(&["git", "rev-list", "--left-right", "--count", "a...b...c"]),
argv(&["git", "push", "origin", "main"]),
argv(&["git", "config", "core.hooksPath", "/tmp"]),
] {
assert_eq!(
literal_git(&args).unwrap_err().code(),
"refused",
"{args:?}"
);
}
let too_many = vec!["git".to_string(); MAX_ARGS + 2];
assert_eq!(literal_git(&too_many).unwrap_err().code(), "invalid_input");
}
#[test]
fn paths_are_confined_to_the_workspace_and_the_overlay_excludes_its_internals() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/a.txt"), "a").unwrap();
let tool = ToolContext::new(&root);
let read = |raw: &str| resolve(&tool, raw, Access::Read).map(|(shown, _)| shown);
let write = |raw: &str| resolve(&tool, raw, Access::Overlay).map(|(shown, _)| shown);
assert_eq!(read("src/a.txt").unwrap(), "src/a.txt");
assert_eq!(read("./src//a.txt").unwrap(), "src/a.txt");
assert_eq!(
read(root.join("src/a.txt").to_str().unwrap()).unwrap(),
"src/a.txt"
);
assert_eq!(read(root.to_str().unwrap()).unwrap(), ".");
assert_eq!(read(".").unwrap(), ".");
assert_eq!(write("src/new/b.txt").unwrap(), "src/new/b.txt");
let outside = tempfile::tempdir().unwrap();
for raw in [
"../x",
"src/../../x",
outside.path().to_str().unwrap(),
"/etc/passwd",
] {
assert!(read(raw).is_err(), "{raw}");
assert!(write(raw).is_err(), "{raw}");
}
for raw in [
".",
".git/config",
".GIT/hooks/pre-commit",
"src/.git",
".codewhale/x",
] {
assert!(write(raw).is_err(), "{raw}");
}
assert_eq!(read(".git/HEAD").unwrap(), ".git/HEAD");
}
#[cfg(unix)]
#[test]
fn a_symlink_anywhere_on_the_path_is_refused_even_when_it_points_inside() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().canonicalize().unwrap();
let outside = tempfile::tempdir().unwrap();
std::fs::write(outside.path().join("secret"), "s").unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("src/a.txt"), "a").unwrap();
std::os::unix::fs::symlink(outside.path(), root.join("out")).unwrap();
std::os::unix::fs::symlink(root.join("src"), root.join("in")).unwrap();
std::os::unix::fs::symlink(root.join("src/a.txt"), root.join("link.txt")).unwrap();
let tool = ToolContext::new(&root);
for raw in ["out/secret", "out", "in/a.txt", "link.txt", "out/new.txt"] {
assert!(resolve(&tool, raw, Access::Read).is_err(), "{raw}");
assert!(resolve(&tool, raw, Access::Overlay).is_err(), "{raw}");
}
}
#[test]
fn results_are_typed_projections() {
let read = file_outcome(
"src/a.txt",
ToolCallResponse {
ok: true,
result: json!({ "content": "1: a", "truncated": null, "metadata": { "path": "/private" } }),
},
);
assert_eq!(
read,
json!({ "ok": true, "path": "src/a.txt", "output": "1: a", "truncated": false })
);
let ran = exec_outcome(ToolCallResponse {
ok: true,
result: json!({ "content": json!({ "stdout": "/w\n", "stderr": "", "exit_code": 0 }).to_string() }),
});
assert_eq!(
ran,
json!({ "ok": true, "stdout": "/w\n", "stderr": "", "exit_code": 0 })
);
let cut = exec_outcome(ToolCallResponse {
ok: true,
result: json!({ "content": "{\"stdout\": \"/w [output truncated" }),
});
assert_eq!(cut["ok"], false);
assert_eq!(cut["exit_code"], Value::Null);
}
}
+142 -14
View File
@@ -127,6 +127,7 @@ mod dsh_catalog;
mod dsh_llm;
mod dsh_network;
mod dsh_remote;
pub use dsh_remote::{RemoteClient, RemoteDenial, RemoteGrants, RemotePairing, remote_grants};
mod dsh_settings;
mod dsh_terminal;
mod dsh_workspace;
@@ -151,6 +152,8 @@ mod windows;
#[cfg(test)]
mod core_call_tests;
#[cfg(test)]
mod dsh_grant_tests;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
#[cfg(test)]
@@ -539,6 +542,7 @@ struct DesiredOwner {
plugin_name: String,
authority: PluginAuthority,
entries: Vec<(PathBuf, String)>,
host_services: Vec<protocol::DshService>,
}
/// What [`ExtensionHostManager::activate_owner`] activates, on either tier:
@@ -554,6 +558,7 @@ struct Activation {
/// pinned source digest.
content_hash: String,
entries: Vec<(PathBuf, String)>,
host_services: Vec<protocol::DshService>,
}
/// One engine's view: its plugin snapshot and the owners (plugin id →
@@ -1374,7 +1379,15 @@ fn restart_dirty_host_when_idle(
return false;
}
let mut supervision = runtime.supervision.lock().expect("supervision lock");
if !supervision.dirty_restart_pending || !host.terminate_if_idle(DIRTY_RESTART_REASON) {
let unload = host.tier == HostTier::Plugin
&& shared
.registry
.lock()
.expect("registry lock")
.host_services_await_restart();
if !(supervision.dirty_restart_pending || unload)
|| !host.terminate_if_idle(DIRTY_RESTART_REASON)
{
return false;
}
supervision.planned_restart = Some(generation);
@@ -2223,6 +2236,7 @@ impl ExtensionHostManager {
authority: Some(want.authority),
content_hash,
entries: want.entries,
host_services: want.host_services,
},
)
.await;
@@ -2250,6 +2264,14 @@ impl ExtensionHostManager {
state.settled = state.selection.revision;
}
}
let withheld = shared
.registry
.lock()
.expect("registry lock")
.take_withheld_report();
if let Some(withheld) = withheld {
shared.diagnostic(withheld);
}
match (plugins, builtin) {
(Err(error), Err(other)) => {
shared.diagnostic(other);
@@ -2396,6 +2418,7 @@ impl ExtensionHostManager {
authority: None,
content_hash: module.source_sha256.to_string(),
entries: vec![entry],
host_services: Vec::new(),
}),
Err(reason) => {
let mut registry = shared.registry.lock().expect("registry lock");
@@ -2443,13 +2466,17 @@ impl ExtensionHostManager {
{
Ok(owner.owner.clone())
} else {
registry.begin_owner(
want.tier,
plugin_id,
&want.name,
want.authority.clone(),
&want.content_hash,
)
registry
.begin_owner(
want.tier,
plugin_id,
&want.name,
want.authority.clone(),
&want.content_hash,
)
.inspect(|owner| {
registry.set_host_services(owner, want.host_services.clone());
})
}
};
let owner = match begun {
@@ -2770,6 +2797,11 @@ impl ExtensionHostManager {
supervision.planned_restart = None;
runtime.host_generation.fetch_add(1, Ordering::SeqCst) + 1
};
shared
.registry
.lock()
.expect("registry lock")
.host_process_started(tier);
// At most two launches: under `auto`, before anything is pinned, a
// Bun that cannot start is reported once and Node is resolved for the
// rest of the session. An explicit `bun` or `node` never falls back.
@@ -3187,10 +3219,18 @@ fn builtin_source_path(root: &Path, module: &BuiltinModule) -> PathBuf {
/// Reviewed, enabled plugins with `native` entries, keyed by plugin id, read
/// from Codewhale's immutable staged snapshot. Blocking.
fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap<String, DesiredOwner>, Vec<String>) {
let (sources, mut errors) = codewhale_plugins::runtime::active_component_sources(
let (sources, errors) = codewhale_plugins::runtime::active_component_sources(
plugins,
PluginActivationCapability::Native,
);
desired_owners_from(plugins, sources, errors)
}
fn desired_owners_from(
plugins: &PluginRegistry,
sources: Vec<codewhale_plugins::runtime::PluginComponentSource>,
mut errors: Vec<String>,
) -> (BTreeMap<String, DesiredOwner>, Vec<String>) {
let mut desired: BTreeMap<String, DesiredOwner> = BTreeMap::new();
let mut broken: BTreeSet<String> = BTreeSet::new();
let mut needs_selection: BTreeSet<String> = BTreeSet::new();
@@ -3230,11 +3270,60 @@ fn desired_owners(plugins: &PluginRegistry) -> (BTreeMap<String, DesiredOwner>,
) {
continue;
}
let owner = desired.entry(plugin_id).or_insert_with(|| DesiredOwner {
plugin_name: source.plugin_name.clone(),
authority: source.authority.clone(),
entries: Vec::new(),
});
if !desired.contains_key(&plugin_id) {
let record = match codewhale_plugins::registry::reviewed_record(
&source.authority,
) {
Ok(record) => record,
Err(reason) => {
if broken.insert(plugin_id) {
errors.push(format!(
"Plugin `{}` was not loaded because its review record could not be read: {reason}",
source.plugin_name
));
}
continue;
}
};
let by_agent =
|approver: Option<&codewhale_plugins::host_services::PluginApprover>| {
approver.is_some_and(|approver| {
approver.cohort()
!= codewhale_plugins::host_services::PluginCohort::Reviewed
})
};
if by_agent(record.approved_by.as_ref())
|| by_agent(
plugins
.get(&plugin_id)
.and_then(|plugin| plugin.approved_by.as_ref()),
)
{
if broken.insert(plugin_id) {
errors.push(format!(
"Plugin `{}` was approved by the agent, so its code does not run in the host of the plugins you reviewed; review it to run its code",
source.plugin_name
));
}
continue;
}
desired.insert(
plugin_id.clone(),
DesiredOwner {
plugin_name: source.plugin_name.clone(),
authority: source.authority.clone(),
entries: Vec::new(),
host_services: record
.host_services
.into_iter()
.filter_map(|service| protocol::DshService::parse(service.name()))
.collect(),
},
);
}
let Some(owner) = desired.get_mut(&plugin_id) else {
continue;
};
// The manifest may name one file twice; it is one entry.
if !owner.entries.iter().any(|(path, _)| *path == source.path) {
owner
@@ -3539,6 +3628,45 @@ impl ManagerShared {
&& state.selection.includes(plugin_id, hash, scope)
})
}
pub(super) fn broker_services(&self, owner: &OwnerRef) -> Vec<protocol::DshService> {
dsh_broker::granted_services(&self.registry.lock().expect("registry lock"), owner)
}
pub(super) async fn broker_granted(
&self,
owner: &OwnerRef,
service: protocol::DshService,
) -> bool {
if let Some(granted) = dsh_broker::test_grants() {
return granted.contains(&service);
}
let authorities = {
let registry = self.registry.lock().expect("registry lock");
if !dsh_broker::granted(&registry, owner, service) {
return false;
}
registry.plugin_host_authorities()
};
let Some(wanted) = codewhale_plugins::host_services::HostService::parse(service.name())
else {
return false;
};
let policy = activation::extension_host_policy_enabled();
#[cfg(any(test, feature = "test-support"))]
let env_scope = crate::test_support::env_scope_ticket();
tokio::task::spawn_blocking(move || {
#[cfg(any(test, feature = "test-support"))]
let _env_scope = crate::test_support::join_env_scope(env_scope);
let _scope = activation::PolicyScope::propagate(policy);
authorities.iter().all(|authority| {
codewhale_plugins::registry::granted_host_services(authority)
.is_ok_and(|granted| granted.contains(&wanted))
})
})
.await
.unwrap_or(false)
}
pub fn check_selection(
&self,
selection: Option<codewhale_plugins::composition_scope::SelectionRevision>,
+127 -3
View File
@@ -16,7 +16,7 @@ use std::sync::Arc;
use serde_json::Value;
use super::protocol::{RegisterKind, RegisterParams};
use super::protocol::{DshService, RegisterKind, RegisterParams};
use super::tier::HostTier;
use codewhale_config::PluginAuthority;
use codewhale_config::{EntryRef, OwnerRef};
@@ -78,10 +78,21 @@ pub struct OwnerEntry {
/// [`OwnerRegistry::set_config_hash`]). A change of config is a different
/// activation: reconcile revokes the owner and activates a new generation.
pub config_hash: String,
pub host_services: Vec<DshService>,
pub state: OwnerState,
pub scopes: HashMap<EntryRef, OwnerState>,
}
impl OwnerEntry {
fn live(&self) -> bool {
matches!(self.state, OwnerState::Activating | OwnerState::Active)
}
fn loaded_in_plugin_host(&self) -> bool {
self.tier == HostTier::Plugin && self.live()
}
}
/// Most schema violations one refused call reports back to the model.
const MAX_REPORTED_INPUT_ERRORS: usize = 5;
/// Bound on the refusal text (violations quote the offending values).
@@ -261,6 +272,8 @@ pub struct OwnerRegistry {
/// process build different native surfaces, and a name that is native
/// anywhere is refused everywhere.
native_names: HashSet<String>,
plugin_host_departed: bool,
withheld_reported: String,
}
fn mint_token() -> String {
@@ -428,6 +441,7 @@ impl OwnerRegistry {
authority,
content_hash: content_hash.to_string(),
config_hash: String::new(),
host_services: Vec::new(),
state: OwnerState::Activating,
scopes: HashMap::new(),
},
@@ -444,6 +458,110 @@ impl OwnerRegistry {
}
}
pub fn set_host_services(&mut self, owner: &OwnerRef, services: Vec<DshService>) {
if let Some(entry) = self.owners.get_mut(&owner.plugin_id)
&& entry.owner == *owner
&& entry.tier == HostTier::Plugin
&& entry.authority.is_some()
{
entry.host_services = services;
}
}
#[must_use]
pub fn host_service_granted(&self, owner: &OwnerRef, service: DshService) -> bool {
self.current(owner)
.is_some_and(|entry| entry.tier == HostTier::Plugin && entry.authority.is_some())
&& !self.plugin_host_departed
&& self
.owners
.values()
.filter(|entry| entry.loaded_in_plugin_host())
.all(|entry| entry.host_services.contains(&service))
}
#[must_use]
pub fn plugin_host_authorities(&self) -> Vec<PluginAuthority> {
self.owners
.values()
.filter(|entry| entry.loaded_in_plugin_host())
.filter_map(|entry| entry.authority.clone())
.collect()
}
#[must_use]
pub fn host_services_await_restart(&self) -> bool {
self.plugin_host_departed
&& self
.owners
.values()
.any(|entry| entry.loaded_in_plugin_host() && !entry.host_services.is_empty())
}
pub fn host_process_started(&mut self, tier: HostTier) {
if tier == HostTier::Plugin {
self.plugin_host_departed = false;
}
}
pub fn take_withheld_report(&mut self) -> Option<String> {
let loaded: Vec<&OwnerEntry> = self
.owners
.values()
.filter(|entry| entry.loaded_in_plugin_host())
.collect();
let mut held: Vec<DshService> = DshService::ALL
.into_iter()
.filter(|service| {
loaded
.iter()
.any(|entry| entry.host_services.contains(service))
})
.collect();
let report = if held.is_empty() {
String::new()
} else if self.plugin_host_departed {
"host services are off until the plugin host restarts: a plugin that was turned off, changed or failed is still loaded in it".to_string()
} else {
held.retain(|service| {
loaded
.iter()
.any(|entry| !entry.host_services.contains(service))
});
held.iter()
.map(|service| {
let mut without: Vec<&str> = loaded
.iter()
.filter(|entry| !entry.host_services.contains(service))
.map(|entry| entry.plugin_name.as_str())
.collect();
without.sort_unstable();
format!(
"host service `{}` is off: the {} of {} {} not include it",
service.name(),
if without.len() == 1 {
"review"
} else {
"reviews"
},
without
.iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", "),
if without.len() == 1 { "does" } else { "do" },
)
})
.collect::<Vec<_>>()
.join("; ")
};
if report == self.withheld_reported {
return None;
}
self.withheld_reported.clone_from(&report);
(!report.is_empty()).then_some(report)
}
#[must_use]
pub fn owner(&self, plugin_id: &str) -> Option<&OwnerEntry> {
self.owners.get(plugin_id)
@@ -482,6 +600,7 @@ impl OwnerRegistry {
if matches {
self.remove_registrations_of(&owner.plugin_id);
if let Some(entry) = self.owners.get_mut(&owner.plugin_id) {
self.plugin_host_departed |= entry.loaded_in_plugin_host();
entry.state = state;
}
}
@@ -1594,7 +1713,8 @@ impl OwnerRegistry {
pub fn revoke_owner(&mut self, plugin_id: &str) -> Option<OwnerRef> {
self.remove_registrations_of(plugin_id);
let entry = self.owners.get_mut(plugin_id)?;
let was_live = matches!(entry.state, OwnerState::Activating | OwnerState::Active);
let was_live = entry.live();
self.plugin_host_departed |= entry.loaded_in_plugin_host();
entry.state = OwnerState::Revoked;
was_live.then(|| entry.owner.clone())
}
@@ -1602,7 +1722,9 @@ impl OwnerRegistry {
/// Forget an owner entirely (after revocation, when its plugin is gone).
pub fn forget_owner(&mut self, plugin_id: &str) {
self.remove_registrations_of(plugin_id);
self.owners.remove(plugin_id);
if let Some(entry) = self.owners.remove(plugin_id) {
self.plugin_host_departed |= entry.loaded_in_plugin_host();
}
}
/// Forget owners that are not live (failed, faulted, revoked) so a new
@@ -1655,6 +1777,7 @@ impl OwnerRegistry {
/// owners are not touched: they live in another process.
pub fn host_exited(&mut self, tier: HostTier, reason: &str) {
self.clear_tier_registrations(tier);
self.host_process_started(tier);
let activating: Vec<_> = self
.owners
.values()
@@ -1679,6 +1802,7 @@ impl OwnerRegistry {
if entry.tier == tier
&& matches!(entry.state, OwnerState::Activating | OwnerState::Active)
{
self.plugin_host_departed |= entry.tier == HostTier::Plugin;
entry.state = OwnerState::Failed(reason.to_string());
}
}
@@ -2164,6 +2164,7 @@ fn attachment_changes_discard_the_complete_scan_before_owner_side_effects() {
plugin_name: "plugin".into(),
authority: fake_authority("plugin"),
entries: Vec::new(),
host_services: Vec::new(),
},
)]
.into(),
+98 -11
View File
@@ -130,6 +130,7 @@ pub enum Refusal {
WrongOwner,
WrongMethod,
WrongTarget,
Revoked,
}
impl Refusal {
@@ -158,12 +159,48 @@ pub struct Refused {
/// [`INVALID_WINDOW`] are a protocol violation.
pub const INVALID_BURST: usize = 8;
pub const INVALID_WINDOW: Duration = Duration::from_secs(60);
pub const REVOKED_GRACE: Duration = Duration::from_secs(10);
const MAX_REVOKED: usize = 4096;
#[derive(Default)]
struct State {
rows: HashMap<String, Row>,
/// Recent invalid presentations, by host process `(tier, generation)`.
invalid: HashMap<(HostTier, u64), VecDeque<Instant>>,
revoked: VecDeque<(String, Instant)>,
}
impl State {
fn note_revoked(&mut self, ids: impl IntoIterator<Item = String>, now: Instant) {
self.revoked.extend(ids.into_iter().map(|id| (id, now)));
while self.revoked.len() > MAX_REVOKED
|| self
.revoked
.front()
.is_some_and(|(_, at)| now.saturating_duration_since(*at) >= REVOKED_GRACE)
{
self.revoked.pop_front();
}
}
fn revoked_just_now(&self, ticket: &str, now: Instant) -> bool {
self.revoked
.iter()
.any(|(id, at)| id == ticket && now.saturating_duration_since(*at) < REVOKED_GRACE)
}
fn revoke_where(&mut self, gone: impl Fn(&Row) -> bool) {
let ids: Vec<String> = self
.rows
.iter()
.filter(|(_, row)| gone(row))
.map(|(id, _)| id.clone())
.collect();
for id in &ids {
self.rows.remove(id);
}
self.note_revoked(ids, Instant::now());
}
}
/// Every live ticket, behind one mutex.
@@ -210,7 +247,9 @@ impl TicketTable {
fn redeem_at(&self, presented: &Presented<'_>, now: Instant) -> Result<Value, Refused> {
let mut state = self.state.lock().expect("ticket lock");
let revoked = state.revoked_just_now(presented.ticket, now);
let verdict = match state.rows.get_mut(presented.ticket) {
None if revoked => Err(Refusal::Revoked),
None => Err(Refusal::Unknown),
Some(row) => Self::check(row, presented, now).map(|target| {
row.uses_left -= 1;
@@ -225,8 +264,8 @@ impl TicketTable {
Ok(target)
}
Err(reason) => {
let violation =
reason != Refusal::Exhausted && Self::note_invalid(&mut state, presented, now);
let violation = !matches!(reason, Refusal::Exhausted | Refusal::Revoked)
&& Self::note_invalid(&mut state, presented, now);
Err(Refused { reason, violation })
}
}
@@ -279,11 +318,10 @@ impl TicketTable {
/// Revoke one ticket (its invocation ended). Idempotent.
pub fn revoke(&self, ticket: &Ticket) {
self.state
.lock()
.expect("ticket lock")
.rows
.remove(ticket.expose());
let mut state = self.state.lock().expect("ticket lock");
if state.rows.remove(ticket.expose()).is_some() {
state.note_revoked([ticket.expose().to_string()], Instant::now());
}
}
/// Revoke every ticket of `plugin_id` (its owner was revoked).
@@ -291,8 +329,7 @@ impl TicketTable {
self.state
.lock()
.expect("ticket lock")
.rows
.retain(|_, row| row.owner.plugin_id != plugin_id);
.revoke_where(|row| row.owner.plugin_id == plugin_id);
}
/// Revoke every ticket of one host process and forget its invalid-burst
@@ -473,6 +510,56 @@ mod tests {
}
}
#[test]
fn a_ticket_revoked_a_moment_ago_does_not_end_the_host() {
let table = TicketTable::default();
let a = owner("a", "token-a");
let ended = table.mint(grant(5));
let withdrawn = table.mint(grant(5));
table.revoke(&ended);
table.revoke_owner("a");
let now = Instant::now();
for ticket in [&ended, &withdrawn] {
for _ in 0..(INVALID_BURST * 3) {
assert_eq!(
table.redeem_at(&presented(ticket, &a), now),
Err(Refused {
reason: Refusal::Revoked,
violation: false
})
);
}
}
assert_eq!(Refusal::Revoked.describe(), Refusal::Unknown.describe());
let guess = Ticket("cwt.guess".to_string());
let violations = (0..INVALID_BURST)
.filter(|_| {
table
.redeem_at(&presented(&guess, &a), now)
.unwrap_err()
.violation
})
.count();
assert_eq!(violations, 1, "a guess still counts");
let fresh = TicketTable::default();
let stale = fresh.mint(grant(5));
fresh.revoke(&stale);
let later = Instant::now() + REVOKED_GRACE;
let outcomes: Vec<Refused> = (0..INVALID_BURST)
.map(|_| fresh.redeem_at(&presented(&stale, &a), later).unwrap_err())
.collect();
assert!(
outcomes
.iter()
.all(|refused| refused.reason == Refusal::Unknown)
);
assert!(
outcomes.last().unwrap().violation,
"after the grace it is a forgery again"
);
}
#[test]
fn tickets_are_revoked_with_their_invocation_owner_and_host_and_never_printed() {
let table = TicketTable::default();
@@ -492,12 +579,12 @@ mod tests {
table.revoke(&by_call);
assert_eq!(
refusal_of(&table, &presented(&by_call, &a)),
Refusal::Unknown
Refusal::Revoked
);
table.revoke_owner("a");
assert_eq!(
refusal_of(&table, &presented(&by_owner, &a)),
Refusal::Unknown
Refusal::Revoked
);
assert_eq!(table.live(), 2, "only a's tickets went");
table.revoke_host(HostTier::Plugin, 9);
+1
View File
@@ -362,6 +362,7 @@ impl ToolSpec for HostToolSpec {
gate,
registration.scope.clone(),
registration.content_hash.clone(),
&self.manager.broker_services(&registration.owner),
)
});
let request = CoreRequest::ToolCall(ToolCallParams {
File diff suppressed because it is too large. Load diff
+2
View File
@@ -15,8 +15,10 @@ pub mod codex_model_cache;
pub mod command_catalog;
pub mod compaction;
pub mod compaction_archive;
pub mod conversation_modes;
pub mod core;
pub mod cost_status;
pub mod diagnostics_messages;
pub mod exec_events;
pub mod extension_host;
pub mod fleet;
+42 -16
View File
@@ -70,6 +70,12 @@ const CLAIM_CEILING: [&str; 3] = [
pub use codewhale_command_contract::facets::debug_receipts::*;
mod usage;
pub use usage::{
cost_projection_from_snapshot, receipt_cost, saved_session_usage, session_usage,
tokens_from_sources, with_usage,
};
// ---------------------------------------------------------------------------
// Normalized input
// ---------------------------------------------------------------------------
@@ -325,7 +331,15 @@ pub fn thread_receipt(
let notes = BTreeSet::from([
"Shell file changes: a Runtime thread's workspace snapshots are not tagged with the thread, so files a command changed are not itemized; only file tools are.".to_string(),
]);
Ok(assemble(
let covered: Vec<&TurnRecord> = ordered
.iter()
.copied()
.filter(|record| turn.is_none_or(|turn| record.id == turn))
.collect();
let tokens = usage::thread_tokens(&covered);
let mut notes = notes;
notes.insert(usage::thread_cost_note().to_string());
let mut receipt = assemble(
source,
steps,
approvals,
@@ -338,7 +352,12 @@ pub fn thread_receipt(
approvals_recorded: true,
},
notes,
))
);
receipt.usage = tokens.map(|tokens| ReceiptUsage {
tokens: Some(tokens),
cost: None,
});
Ok(receipt)
}
// ---------------------------------------------------------------------------
@@ -414,13 +433,16 @@ pub fn run_receipts_command(
Some(prefix) => {
let id = sessions.resolve_session_id_prefix(prefix)?;
let session = sessions.load_session_snapshot(&id)?;
session_receipt(
session_source(&session.metadata),
&session.messages,
&session.approval_receipts,
&session.turn_outcomes,
turn,
)?
with_usage(
session_receipt(
session_source(&session.metadata),
&session.messages,
&session.approval_receipts,
&session.turn_outcomes,
turn,
)?,
saved_session_usage(&session.metadata),
)
}
None => {
let latest_session = sessions.list_sessions()?.into_iter().next();
@@ -440,13 +462,16 @@ pub fn run_receipts_command(
}
(Some(metadata), _, _) => {
let session = sessions.load_session_snapshot(&metadata.id)?;
session_receipt(
session_source(&session.metadata),
&session.messages,
&session.approval_receipts,
&session.turn_outcomes,
turn,
)?
with_usage(
session_receipt(
session_source(&session.metadata),
&session.messages,
&session.approval_receipts,
&session.turn_outcomes,
turn,
)?,
saved_session_usage(&session.metadata),
)
}
_ => anyhow::bail!("no saved session or Runtime thread to read"),
}
@@ -2009,6 +2034,7 @@ fn assemble(
turn: turn.map(str::to_string),
postures,
totals,
usage: None,
actions,
omitted_actions,
not_recorded: notes.into_iter().collect(),
+120 -1
View File
@@ -490,7 +490,10 @@ fn markdown_and_json_share_one_record() {
"Changed 1 file (+2 −1) · ran 1 command · made 1 MCP call · 1 approved by you · 1 approved by session rule · 1 ran without asking under Ask · 1 denied by you · 2 other failures"
));
assert!(markdown.contains("\n1. edited `src/parse.rs` (+2 −1)"));
assert!(markdown.contains("\nNot recorded:\n- Shell file changes:"));
assert!(
markdown.contains("\nNot recorded:\n- Cost: ")
&& markdown.contains("\n- Shell file changes:")
);
let json: Value = serde_json::from_str(&render_json(&receipt)).expect("json");
assert_eq!(json["schema_id"], RECEIPT_SCHEMA_ID);
@@ -1173,3 +1176,119 @@ fn session_receipts_join_only_unambiguous_execution_identities() {
assert!(steps[0].output.is_none());
}
}
fn unpriced_cost() -> crate::session_manager::SessionCostSnapshot {
crate::session_manager::SessionCostSnapshot {
unpriced_turns: 1,
cny_unpriced_turns: 1,
unpriced_reasons: ["unknown_billing_basis".to_string()].into(),
cny_unpriced_reasons: ["unknown_billing_basis".to_string()].into(),
coverage_recorded: true,
..Default::default()
}
}
#[test]
fn session_receipt_carries_tokens_and_the_cost_line_cost_prints() {
let receipt = session_receipt(session_source_fixture(), &[], &[], &[], None).unwrap();
let projection = cost_projection_from_snapshot(&unpriced_cost());
let usage = session_usage(&projection, &Default::default(), 1290);
let receipt = with_usage(receipt, usage);
let usage = receipt.usage.as_ref().expect("usage section");
assert_eq!(usage.tokens.as_ref().map(|tokens| tokens.total), Some(1290));
let cost = usage.cost.as_ref().expect("cost");
assert_eq!(cost.state, ReceiptCostState::Unknown);
assert!(cost.amount.is_none());
assert!(
cost.report.contains("Estimated total: unknown"),
"{}",
cost.report
);
assert!(
cost.report.contains("billing basis unknown"),
"{}",
cost.report
);
assert_eq!(
cost.report,
codewhale_commands::debug::tokens::cost_report(
&projection,
&crate::diagnostics_messages::EnglishDiagnostics
)
.unwrap()
);
let json: Value = serde_json::from_str(&render_json(&receipt)).unwrap();
assert_eq!(json["usage"]["cost"]["state"], "unknown");
assert!(json["usage"]["cost"].get("amount").is_none());
assert_eq!(json["usage"]["tokens"]["total"], 1290);
let markdown = render_markdown(&receipt);
assert!(markdown.contains("Tokens: 1290 total"), "{markdown}");
assert!(markdown.contains("Estimated total: unknown"), "{markdown}");
}
#[test]
fn priced_session_receipt_reports_the_amount_and_a_partial_total_as_partial() {
let mut cost = crate::session_manager::SessionCostSnapshot {
priced_turns: 2,
session_cost_usd: 0.0123,
coverage_recorded: true,
..Default::default()
};
let priced = receipt_cost(&cost_projection_from_snapshot(&cost));
assert_eq!(priced.state, ReceiptCostState::Priced);
assert_eq!(priced.amount, Some(0.0123));
assert_eq!(priced.currency, "USD");
cost.unpriced_turns = 1;
cost.unpriced_reasons.insert("unknown_billing_basis".into());
let partial = receipt_cost(&cost_projection_from_snapshot(&cost));
assert_eq!(partial.state, ReceiptCostState::Partial);
assert_eq!(partial.amount, Some(0.0123));
}
#[test]
fn single_turn_session_receipt_leaves_usage_out_and_says_so() {
let messages: Vec<Message> = serde_json::from_value(json!([
{"role":"user","content":[{"type":"text","text":"hi\n<turn_meta>\npermission_posture: Ask\n</turn_meta>"}]}
]))
.unwrap();
let receipt =
session_receipt(session_source_fixture(), &messages, &[], &[], Some("1")).unwrap();
let usage = session_usage(
&cost_projection_from_snapshot(&unpriced_cost()),
&Default::default(),
10,
);
let receipt = with_usage(receipt, usage);
assert!(receipt.usage.is_none());
assert!(
receipt
.not_recorded
.iter()
.any(|note| note.starts_with("Usage:"))
);
}
#[test]
fn thread_receipt_sums_turn_tokens_and_points_to_the_usage_endpoint_for_cost() {
let (thread, mut turns, items, events) = thread_fixture();
turns[0].usage = Some(codewhale_models::Usage {
input_tokens: 100,
output_tokens: 20,
..Default::default()
});
let receipt = thread_receipt(&thread, &turns, &items, &events, None).unwrap();
let usage = receipt.usage.as_ref().expect("usage");
let tokens = usage.tokens.as_ref().expect("tokens");
assert_eq!(
(tokens.input, tokens.output, tokens.total),
(Some(100), Some(20), 120)
);
assert_eq!(tokens.calls, None);
assert!(usage.cost.is_none());
assert!(
receipt
.not_recorded
.iter()
.any(|note| note.starts_with("Cost:"))
);
}
+210
View File
@@ -0,0 +1,210 @@
use std::collections::BTreeMap;
use codewhale_command_contract::facets::{
DebugCostProjection, ReceiptCost, ReceiptCostState, ReceiptTokens, ReceiptUsage,
};
use codewhale_command_contract::types::CommandCurrency;
use codewhale_commands::debug::tokens::{
SUBSCRIPTION_ONLY_COST_NOTE, cost_is_subscription_only, cost_report,
};
use super::Receipt;
use crate::cost_status::UsageSourceContribution;
use crate::diagnostics_messages::EnglishDiagnostics;
use crate::llm_client::ProviderUsageCoverage;
use crate::session_manager::{SessionCostSnapshot, SessionMetadata};
const TURN_SCOPE_NOTE: &str = "Usage: token counts and cost are kept for the whole session, not per turn, so a single-turn receipt leaves them out.";
const NO_TOKENS_NOTE: &str = "Tokens: the session saved no provider token counts, so none are shown; that is not zero usage.";
const THREAD_COST_NOTE: &str = "Cost: a Runtime thread receipt carries token counts only; GET /v1/threads/{id}/usage reports the thread's cost and what it covers.";
fn sanitize(amount: f64) -> f64 {
if amount.is_finite() && amount >= 0.0 {
amount
} else {
0.0
}
}
pub fn cost_projection_from_snapshot(cost: &SessionCostSnapshot) -> DebugCostProjection {
let cny = cost.cny_priced_turns > 0 && cost.priced_turns == 0;
let (parent, auxiliary, high_water, priced, unpriced, reasons) = if cny {
(
cost.session_cost_cny,
cost.subagent_cost_cny,
cost.displayed_cost_high_water_cny,
cost.cny_priced_turns,
cost.cny_unpriced_turns,
&cost.cny_unpriced_reasons,
)
} else {
(
cost.session_cost_usd,
cost.subagent_cost_usd,
cost.displayed_cost_high_water_usd,
cost.priced_turns,
cost.unpriced_turns,
&cost.unpriced_reasons,
)
};
let parent_turns = sanitize(parent);
let subagents = sanitize(auxiliary);
let current = parent_turns + subagents;
let total = current.max(sanitize(high_water));
DebugCostProjection {
currency: if cny {
CommandCurrency::Cny
} else {
CommandCurrency::Usd
},
total,
parent_turns,
subagents,
display_floor: (total - current).max(0.0),
priced_turns: priced,
unpriced_turns: unpriced,
legacy_coverage_unknown: cost.coverage_is_legacy_unknown(),
user_declared_estimates: cost.pricing_provenances.contains("user_override"),
itemized_turns: 0,
route_amounts: Vec::new(),
turn_history_capacity: 0,
unpriced_reason_labels: reasons.iter().cloned().collect(),
unpriced_classes: cost.unpriced_classes.iter().cloned().collect(),
pricing_provenances: cost.pricing_provenances.iter().cloned().collect(),
live_pricing_defects: cost.live_pricing_defects.iter().cloned().collect(),
unusable_pricing_defects: cost.live_pricing_unusable_defects.iter().cloned().collect(),
route_receipts: cost.route_receipts.iter().cloned().collect(),
}
}
pub fn receipt_cost(projection: &DebugCostProjection) -> ReceiptCost {
let has_amount = projection.priced_turns > 0
|| (projection.legacy_coverage_unknown && projection.total > 0.0);
let state = if !has_amount {
ReceiptCostState::Unknown
} else if projection.unpriced_turns > 0 || projection.legacy_coverage_unknown {
ReceiptCostState::Partial
} else {
ReceiptCostState::Priced
};
let report = if cost_is_subscription_only(projection) {
SUBSCRIPTION_ONLY_COST_NOTE.to_string()
} else {
cost_report(projection, &EnglishDiagnostics)
.unwrap_or_else(|error| format!("The cost report is unavailable: {error}"))
};
ReceiptCost {
state,
amount: has_amount.then_some(projection.total),
currency: match projection.currency {
CommandCurrency::Usd => "USD",
CommandCurrency::Cny => "CNY",
},
priced_turns: projection.priced_turns,
unpriced_turns: projection.unpriced_turns,
report,
}
}
fn add(total: &mut Option<u64>, value: Option<u32>) {
if let Some(value) = value {
*total = Some(total.unwrap_or(0).saturating_add(u64::from(value)));
}
}
pub fn tokens_from_sources(
sources: &BTreeMap<String, UsageSourceContribution>,
saved_total: u64,
) -> Option<ReceiptTokens> {
let mut tokens = ReceiptTokens::default();
let (mut input, mut output, mut calls) = (0u64, 0u64, 0usize);
for contribution in sources.values() {
let record = &contribution.record;
let reported = record.settlement.as_ref().map_or_else(
|| record.usage.usage != Default::default(),
|revision| {
matches!(
revision.coverage,
ProviderUsageCoverage::Reported | ProviderUsageCoverage::Incomplete
)
},
);
if !reported {
continue;
}
let usage = &record.usage.usage;
input = input.saturating_add(u64::from(usage.input_tokens));
output = output.saturating_add(u64::from(usage.output_tokens));
add(&mut tokens.cache_read, usage.prompt_cache_hit_tokens);
add(&mut tokens.cache_write, usage.prompt_cache_write_tokens);
add(&mut tokens.reasoning, usage.reasoning_tokens);
calls += 1;
}
if calls > 0 {
tokens.input = Some(input);
tokens.output = Some(output);
tokens.total = input.saturating_add(output);
tokens.calls = Some(calls);
Some(tokens)
} else if saved_total > 0 {
tokens.total = saved_total;
Some(tokens)
} else {
None
}
}
pub fn session_usage(
projection: &DebugCostProjection,
sources: &BTreeMap<String, UsageSourceContribution>,
saved_total_tokens: u64,
) -> ReceiptUsage {
ReceiptUsage {
tokens: tokens_from_sources(sources, saved_total_tokens),
cost: Some(receipt_cost(projection)),
}
}
pub fn saved_session_usage(metadata: &SessionMetadata) -> ReceiptUsage {
session_usage(
&cost_projection_from_snapshot(&metadata.cost),
&metadata.cost.accounted_usage_sources,
metadata.total_tokens,
)
}
#[must_use]
pub fn with_usage(mut receipt: Receipt, usage: ReceiptUsage) -> Receipt {
if receipt.turn.is_some() {
receipt.not_recorded.push(TURN_SCOPE_NOTE.to_string());
return receipt;
}
if usage.tokens.is_none() {
receipt.not_recorded.push(NO_TOKENS_NOTE.to_string());
}
receipt.usage = Some(usage);
receipt
}
pub fn thread_tokens(turns: &[&crate::runtime_threads::TurnRecord]) -> Option<ReceiptTokens> {
let mut tokens = ReceiptTokens::default();
let (mut input, mut output, mut turns_with_usage) = (0u64, 0u64, 0usize);
for usage in turns.iter().filter_map(|turn| turn.usage.as_ref()) {
input = input.saturating_add(u64::from(usage.input_tokens));
output = output.saturating_add(u64::from(usage.output_tokens));
add(&mut tokens.cache_read, usage.prompt_cache_hit_tokens);
add(&mut tokens.cache_write, usage.prompt_cache_write_tokens);
add(&mut tokens.reasoning, usage.reasoning_tokens);
turns_with_usage += 1;
}
(turns_with_usage > 0).then(|| {
tokens.input = Some(input);
tokens.output = Some(output);
tokens.total = input.saturating_add(output);
tokens
})
}
pub fn thread_cost_note() -> &'static str {
THREAD_COST_NOTE
}
+2
View File
@@ -430,6 +430,8 @@ pub struct ThreadRecord {
pub executor: Option<ExecutorRecord>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<ThreadEnvironment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_preset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+91 -27
View File
@@ -1465,6 +1465,8 @@ pub struct CreateThreadRequest {
pub executor: Option<CreateThreadExecutor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub environment: Option<ThreadEnvironment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_preset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1514,6 +1516,8 @@ pub struct UpdateThreadRequest {
/// route name in `model_provider`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_preset: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -3966,6 +3970,12 @@ impl RuntimeThreadManager {
self.config.read()
}
pub fn extension_host_options(&self) -> crate::extension_host::ExtensionHostOptions {
crate::extension_host::ExtensionHostOptions::from_config(
self.read_config().extension_host.as_ref(),
)
}
pub(super) fn resolved_route_for_thread(
&self,
config: &Config,
@@ -7171,6 +7181,18 @@ impl RuntimeThreadManager {
if let Some(environment) = req.environment.as_ref() {
return Err(environment.refusal().into());
}
if req.executor.is_some() && req.agent_preset.is_some() {
return Err(crate::conversation_modes::ModeRefusal::with_executor().into());
}
let agent_preset = match req.agent_preset.as_deref() {
Some(id) => {
let selection =
crate::conversation_modes::select(id, self.extension_host_options()).await?;
selection.require_available()?;
crate::conversation_modes::stored_form(&selection.id)
}
None => None,
};
let executor = match req.executor.as_ref() {
Some(requested) => {
anyhow::ensure!(
@@ -7311,6 +7333,7 @@ impl RuntimeThreadManager {
last_read: None,
executor,
environment: None,
agent_preset,
};
if let Some(operation_key) = req.operation_key {
let key_fingerprint = runtime_turn_operation_key_fingerprint(
@@ -7318,28 +7341,32 @@ impl RuntimeThreadManager {
":thread-create:",
&operation_key,
)?;
let mut fingerprint_body = json!({
"version": 1,
"model": thread.model,
"model_provider": thread.model_provider,
"model_provider_id": thread.model_provider_id,
"reasoning_effort": thread.reasoning_effort,
"allowed_tools": thread.allowed_tools,
"add_dirs": thread.add_dirs,
"workspace": thread.workspace,
"mode": thread.mode,
"permission_posture": thread.permission_posture,
"allow_shell": thread.allow_shell,
"trust_mode": thread.trust_mode,
"auto_approve": thread.auto_approve,
"archived": thread.archived,
"system_prompt": thread.system_prompt,
"task_id": thread.task_id,
"dynamic_tools": req.dynamic_tools,
"environments": req.environments,
"executor": thread.executor,
});
if let Some(preset) = thread.agent_preset.as_ref() {
fingerprint_body["agent_preset"] = json!(preset);
}
let request_fingerprint =
codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(&json!({
"version": 1,
"model": thread.model,
"model_provider": thread.model_provider,
"model_provider_id": thread.model_provider_id,
"reasoning_effort": thread.reasoning_effort,
"allowed_tools": thread.allowed_tools,
"add_dirs": thread.add_dirs,
"workspace": thread.workspace,
"mode": thread.mode,
"permission_posture": thread.permission_posture,
"allow_shell": thread.allow_shell,
"trust_mode": thread.trust_mode,
"auto_approve": thread.auto_approve,
"archived": thread.archived,
"system_prompt": thread.system_prompt,
"task_id": thread.task_id,
"dynamic_tools": req.dynamic_tools,
"environments": req.environments,
"executor": thread.executor,
}))?);
codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(&fingerprint_body)?);
let _emit_order = self.event_emit.lock().await;
let store = self.store.clone();
let (thread, event) = tokio::task::spawn_blocking(move || {
@@ -8400,9 +8427,26 @@ impl RuntimeThreadManager {
&& req.workspace.is_none()
&& req.model_provider.is_none()
&& req.model_provider_id.is_none()
&& req.agent_preset.is_none()
{
bail!("At least one thread field is required");
}
let preset_selection = match req.agent_preset.as_deref() {
Some(preset_id) => {
if self
.store
.load_thread(id)
.is_ok_and(|thread| thread.executor.is_some())
{
return Err(crate::conversation_modes::ModeRefusal::with_executor().into());
}
Some(
crate::conversation_modes::select(preset_id, self.extension_host_options())
.await?,
)
}
None => None,
};
if (req.model.is_some()
|| req.model_provider.is_some()
|| req.model_provider_id.is_some()
@@ -8571,6 +8615,24 @@ impl RuntimeThreadManager {
thread.trust_mode = trust_mode;
changes.insert("trust_mode".to_string(), json!(trust_mode));
}
if let Some(selection) = preset_selection.as_ref() {
let current =
crate::conversation_modes::effective_id(thread.agent_preset.as_deref());
if selection.id != current {
if thread.latest_turn_id.is_some()
|| active
.engines
.get(id)
.and_then(|state| state.active_turn.as_ref())
.is_some()
{
return Err(crate::conversation_modes::ModeRefusal::locked().into());
}
selection.require_available()?;
thread.agent_preset = crate::conversation_modes::stored_form(&selection.id);
changes.insert("agent_preset".to_string(), json!(selection.id));
}
}
let requested_model = match provider_switch {
Some((model_provider, model_provider_id, model)) => {
if thread.model_provider.as_deref() != Some(model_provider.as_str())
@@ -8639,7 +8701,9 @@ impl RuntimeThreadManager {
}
let workspace_changed = changes.contains_key("workspace");
let rebuild_engine = workspace_changed || changes.contains_key("system_prompt");
let rebuild_engine = workspace_changed
|| changes.contains_key("system_prompt")
|| changes.contains_key("agent_preset");
if rebuild_engine
&& active
.engines
@@ -14568,10 +14632,7 @@ source_usage_records: Default::default(),
}
manager
};
let mut features = cfg.features();
if self.host_profile.is_acp() {
features.disable(codewhale_runtime::features::Feature::Mcp);
}
let features = cfg.features();
let engine_cfg = EngineConfig {
user_input_responder: true,
session_checkpoint_backend: Some(
@@ -14741,6 +14802,7 @@ source_usage_records: Default::default(),
cfg.subagent_heartbeat_timeout_secs_for_provider(&route_identity),
),
prefer_bwrap: cfg.prefers_bwrap(),
tool_launcher: cfg.tool_launcher.clone(),
bwrap_extensions: codewhale_sandbox::BwrapMountExtensions {
read_only_roots: cfg.bwrap_ro_roots.clone(),
device_roots: cfg.bwrap_dev_roots.clone(),
@@ -15158,10 +15220,11 @@ source_usage_records: Default::default(),
if let Some(environment) = thread.environment.as_ref() {
return Err(environment.refusal().into());
}
let (prefer_bwrap, bwrap_extensions, denied_read_paths) = {
let (prefer_bwrap, tool_launcher, bwrap_extensions, denied_read_paths) = {
let cfg = self.read_config();
(
cfg.prefers_bwrap(),
cfg.tool_launcher.clone(),
codewhale_sandbox::BwrapMountExtensions {
read_only_roots: cfg.bwrap_ro_roots.clone(),
device_roots: cfg.bwrap_dev_roots.clone(),
@@ -15176,6 +15239,7 @@ source_usage_records: Default::default(),
let manager = new_shared_shell_manager(thread.workspace.clone());
if let Ok(mut guard) = manager.lock() {
guard.set_prefer_bwrap(prefer_bwrap);
guard.set_tool_launcher(tool_launcher);
guard.set_bwrap_extensions(bwrap_extensions);
guard.set_denied_read_subpaths(denied_read_paths);
}
@@ -2668,6 +2668,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
}
}
@@ -19967,6 +19968,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
};
manager.store.save_thread(&thread)?;
@@ -21758,6 +21760,7 @@ async fn report_store_failure_names_the_item_file_without_terminalizing() -> Res
Ok(())
}
mod conversation_modes;
mod external_agent;
mod task_ownership;
@@ -0,0 +1,134 @@
use super::*;
use crate::conversation_modes::{CREATOR, CreatorAvailabilityGuard, ModeRefusal, STANDARD};
fn refusal_code(error: &anyhow::Error) -> &'static str {
error
.downcast_ref::<ModeRefusal>()
.expect("typed refusal")
.code
}
fn create_request(preset: Option<&str>) -> CreateThreadRequest {
CreateThreadRequest {
agent_preset: preset.map(str::to_string),
..Default::default()
}
}
#[test]
fn thread_record_without_agent_preset_loads_as_standard() -> Result<()> {
let thread = sample_thread("thr_modes_legacy");
let mut raw = serde_json::to_value(&thread)?;
assert!(raw.get("agent_preset").is_none());
let legacy: ThreadRecord = serde_json::from_value(raw.clone())?;
assert_eq!(legacy.agent_preset, None);
assert_eq!(
crate::conversation_modes::effective_id(legacy.agent_preset.as_deref()),
STANDARD
);
raw["agent_preset"] = json!(CREATOR);
let creator: ThreadRecord = serde_json::from_value(raw)?;
assert_eq!(creator.agent_preset.as_deref(), Some(CREATOR));
Ok(())
}
#[tokio::test]
async fn mode_is_set_at_creation_and_refused_when_unknown_or_unavailable() -> Result<()> {
let tmp = tempfile::tempdir()?;
let manager = test_manager(tmp.path().to_path_buf())?;
{
let _available = CreatorAvailabilityGuard::set(None);
let created = manager.create_thread(create_request(Some(CREATOR))).await?;
assert_eq!(created.agent_preset.as_deref(), Some(CREATOR));
let standard = manager
.create_thread(create_request(Some(STANDARD)))
.await?;
assert_eq!(standard.agent_preset, None);
let unknown = manager
.create_thread(create_request(Some("minimal")))
.await
.expect_err("unknown id");
assert_eq!(refusal_code(&unknown), ModeRefusal::UNKNOWN_MODE);
}
let _missing = CreatorAvailabilityGuard::set(Some("no runtime"));
let unavailable = manager
.create_thread(create_request(Some(CREATOR)))
.await
.expect_err("unavailable");
assert_eq!(refusal_code(&unavailable), ModeRefusal::MODE_UNAVAILABLE);
assert_eq!(unavailable.to_string(), "no runtime");
let table = crate::conversation_modes::current_table(manager.extension_host_options()).await;
assert!(table.modes[0].available && table.modes[0].reason.is_none());
assert!(!table.modes[1].available);
assert_eq!(table.modes[1].reason.as_deref(), Some("no runtime"));
Ok(())
}
#[tokio::test]
async fn mode_changes_only_before_the_first_turn_and_never_with_an_executor() -> Result<()> {
let tmp = tempfile::tempdir()?;
let manager = test_manager(tmp.path().to_path_buf())?;
let _available = CreatorAvailabilityGuard::set(None);
let thread = manager.create_thread(create_request(None)).await?;
let patch = |preset: &str| UpdateThreadRequest {
agent_preset: Some(preset.to_string()),
..Default::default()
};
let updated = manager.update_thread(&thread.id, patch(CREATOR)).await?;
assert_eq!(updated.agent_preset.as_deref(), Some(CREATOR));
let unknown = manager
.update_thread(&thread.id, patch("nope"))
.await
.expect_err("unknown id");
assert_eq!(refusal_code(&unknown), ModeRefusal::UNKNOWN_MODE);
let mut with_turn = manager.store.load_thread(&thread.id)?;
with_turn.latest_turn_id = Some("turn_first".to_string());
manager.store.save_thread(&with_turn)?;
let locked = manager
.update_thread(&thread.id, patch(STANDARD))
.await
.expect_err("locked");
assert_eq!(refusal_code(&locked), ModeRefusal::MODE_LOCKED);
manager.update_thread(&thread.id, patch(CREATOR)).await?;
let with_executor = CreateThreadRequest {
agent_preset: Some(CREATOR.to_string()),
executor: Some(CreateThreadExecutor {
kind: "acp".to_string(),
agent_id: "opencode".to_string(),
config: CreateThreadExecutorConfig::default(),
}),
..Default::default()
};
let refused = manager
.create_thread(with_executor)
.await
.expect_err("executor");
assert_eq!(
refusal_code(&refused),
ModeRefusal::MODE_NOT_SUPPORTED_WITH_EXECUTOR
);
Ok(())
}
#[tokio::test]
async fn creator_is_listed_unavailable_until_it_loads_its_tools() -> Result<()> {
let _real = CreatorAvailabilityGuard::unforced();
let tmp = tempfile::tempdir()?;
let manager = test_manager(tmp.path().to_path_buf())?;
let table = crate::conversation_modes::current_table(manager.extension_host_options()).await;
assert!(table.modes[0].available);
assert!(!table.modes[1].available);
assert_eq!(
table.modes[1].reason.as_deref(),
Some(crate::conversation_modes::CREATOR_NOT_READY_REASON)
);
let refused = manager
.create_thread(create_request(Some(CREATOR)))
.await
.expect_err("not ready");
assert_eq!(refusal_code(&refused), ModeRefusal::MODE_UNAVAILABLE);
Ok(())
}
@@ -1155,6 +1155,51 @@ async fn interrupting_a_pending_approval_answers_the_agents_request_as_cancelled
Ok(())
}
#[tokio::test]
async fn an_agent_that_ignores_cancel_is_stopped_after_the_grace_period() -> Result<()> {
if !node_available().await {
return Ok(());
}
let _env = crate::test_support::lock_test_env();
let _fixture = crate::test_support::EnvVarGuard::set(FIXTURE_AGENT_ENV, fixture_script());
let root = tempfile::tempdir()?;
let workspace = root.path().join("work");
std::fs::create_dir_all(&workspace)?;
std::fs::write(
workspace.join("fixture-plan.json"),
json!({ "ignoreCancel": true }).to_string(),
)?;
let manager = test_manager(root.path().join("runtime"))?;
let thread = manager
.create_thread(fixture_thread_request(&workspace, "ask"))
.await?;
let turn = manager.start_turn(&thread.id, prompt("never stop")).await?;
let deadline = Instant::now() + Duration::from_secs(30);
while fixture_lines(&workspace, "held-prompts.jsonl").is_empty() {
anyhow::ensure!(Instant::now() < deadline, "the agent never got the prompt");
sleep(Duration::from_millis(50)).await;
}
let interrupted = Instant::now();
manager.interrupt_turn(&thread.id, &turn.id).await?;
let settled = wait_for_turn(&manager, &turn.id).await?;
let waited = interrupted.elapsed();
assert_eq!(settled.status, RuntimeTurnStatus::Interrupted);
assert!(
settled
.error
.as_deref()
.is_some_and(|reason| reason.contains("ended its process")),
"the turn did not settle with the killed reason: {:?}",
settled.error
);
assert!(
waited >= Duration::from_secs(9) && waited < Duration::from_secs(15),
"the forced stop came after {waited:?}, outside the grace period plus five seconds"
);
manager.shutdown_and_wait().await?;
Ok(())
}
#[tokio::test]
async fn additional_directories_reach_only_an_agent_that_advertises_them() -> Result<()> {
if !node_available().await {
+4
View File
@@ -2090,6 +2090,8 @@ pub struct SavedSession {
/// record resume, export, and the Runtime API read back.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub turn_outcomes: Vec<SavedTurnOutcome>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_preset: Option<String>,
}
impl SavedSession {
pub fn capture_publication(
@@ -2302,6 +2304,7 @@ impl SavedSession {
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
})
}
}
@@ -7079,6 +7082,7 @@ fn create_saved_session_inner(
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
}
}
@@ -46,6 +46,7 @@
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
};
manager.save_session(&session).expect("save");
}
@@ -96,6 +97,7 @@
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
};
manager.save_session(&session).expect("save empty");
}
@@ -346,3 +346,19 @@
primary.join("sessions")
);
}
#[test]
fn saved_session_without_agent_preset_loads_and_round_trips_the_mode() {
let tmp = tempdir().expect("tempdir");
let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
let session = create_saved_session(&[], "test-model", tmp.path(), 0, None);
let mut raw = serde_json::to_value(&session).expect("serialize");
assert!(raw.get("agent_preset").is_none());
let legacy: SavedSession = serde_json::from_value(raw.clone()).expect("legacy load");
assert_eq!(legacy.agent_preset, None);
raw["agent_preset"] = serde_json::json!("creator");
let saved: SavedSession = serde_json::from_value(raw).expect("mode load");
manager.save_session(&saved).expect("save");
let loaded = manager.load_session(&saved.metadata.id).expect("load");
assert_eq!(loaded.agent_preset.as_deref(), Some("creator"));
}
+77 -5
View File
@@ -223,6 +223,15 @@ impl ToolSpec for GitTool {
}
}
fn apply_review_git_options(command: &mut std::process::Command, args: Vec<String>) {
command
.args(["--no-optional-locks", "--no-pager"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_NO_LAZY_FETCH", "1");
}
async fn review_scope(input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let query = input
.get("query")
@@ -318,11 +327,7 @@ async fn review_scope(input: Value, context: &ToolContext) -> Result<ToolResult,
.map_err(|error| {
ToolError::execution_failed(format!("Git preparation failed: {error}"))
})??;
command
.args(["--no-optional-locks", "--no-pager", "--no-lazy-fetch"])
.args(args)
.env("GIT_TERMINAL_PROMPT", "0")
.env("GIT_OPTIONAL_LOCKS", "0");
apply_review_git_options(&mut command, args);
let mut command = tokio::process::Command::from(command);
let cancel = context.cancel_token.clone().unwrap_or_default();
let stop = async {
@@ -377,6 +382,73 @@ mod tests {
.to_string()
}
#[test]
fn review_git_options_use_env_not_a_flag_old_git_rejects() {
let mut command = std::process::Command::new("git");
apply_review_git_options(&mut command, vec!["rev-parse".into(), "HEAD".into()]);
let args: Vec<_> = command
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
["--no-optional-locks", "--no-pager", "rev-parse", "HEAD"]
);
assert!(!args.iter().any(|a| a == "--no-lazy-fetch"));
let lazy = command
.get_envs()
.find(|(key, _)| *key == "GIT_NO_LAZY_FETCH")
.and_then(|(_, value)| value);
assert_eq!(lazy, Some(std::ffi::OsStr::new("1")));
}
#[tokio::test]
async fn review_scope_resolve_runs_against_system_git() {
if !<codewhale_dependencies::Git as codewhale_dependencies::ExternalTool>::available() {
return;
}
let tmp = tempdir().expect("tempdir");
let run = |args: &[&str]| {
let out = std::process::Command::new("git")
.args(args)
.current_dir(tmp.path())
.output()
.expect("git runs");
assert!(
out.status.success(),
"{args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init", "-q"]);
run(&[
"-c",
"user.name=t",
"-c",
"[email protected]",
"commit",
"-q",
"--allow-empty",
"-m",
"init",
]);
let ctx = ToolContext::new(tmp.path().to_path_buf());
let result = GitTool::new("Git")
.execute(
json!({"action": "review_scope", "query": "resolve", "rev": "HEAD"}),
&ctx,
)
.await
.expect("review_scope runs");
let body: Value = serde_json::from_str(&result.content).expect("json");
assert_eq!(body["exit_code"], 0, "{body}");
assert_eq!(
body["stdout"].as_str().map(|s| s.trim().len()),
Some(40),
"{body}"
);
}
#[tokio::test]
async fn missing_action_is_refused_with_the_valid_values() {
let message = err(json!({"path": "src"})).await;
+2 -1
View File
@@ -708,7 +708,8 @@ pub fn enforce_tool_authority(
| "context_archive_get"
| "context_archive_search"
| "present"
) {
) && tool.extension_caller().is_none()
{
return Err(ToolError::not_available(format!(
"{name} is outside the ACP foreground tool profile"
)));
+40 -3
View File
@@ -2168,6 +2168,14 @@ impl ShellManager {
self.sandbox_manager.set_prefer_bwrap(prefer);
}
pub fn set_tool_launcher(&mut self, launcher: Option<Vec<String>>) {
self.sandbox_manager.set_tool_launcher(launcher);
}
pub fn tool_launcher_active(&self) -> bool {
self.sandbox_manager.tool_launcher_active()
}
/// Move the fallback working directory. Callers that pass an explicit
/// `working_dir` are unaffected; this only keeps `None` honest when a
/// thread's workspace changes while its jobs are still tracked here.
@@ -2226,7 +2234,10 @@ impl ShellManager {
)
.with_policy(policy.clone())
.with_env(env.clone());
let prepared = self.sandbox_manager.prepare(&spec);
let prepared = self
.sandbox_manager
.try_prepare(&spec)
.map_err(anyhow::Error::msg)?;
if matches!(spec.sandbox_policy, ExecutionSandboxPolicy::ReadOnly { .. }) {
require_native_readonly_execution(&prepared)?;
}
@@ -2462,7 +2473,11 @@ impl ShellManager {
CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
};
let spec = spec.with_policy(policy).with_env(extra_env);
let exec_env = self.sandbox_manager.prepare(&spec);
let exec_env = self.sandbox_manager.try_prepare(&spec).map_err(|message| {
anyhow::Error::msg(message.clone())
.context(ShellLaunchNotStarted)
.context(message)
})?;
if matches!(spec.sandbox_policy, ExecutionSandboxPolicy::ReadOnly { .. })
&& readonly_workspace.is_none()
{
@@ -2534,7 +2549,10 @@ impl ShellManager {
let spec = CommandSpec::shell(command, work_dir.clone(), Duration::from_millis(timeout_ms))
.with_policy(policy)
.with_env(extra_env);
let exec_env = self.sandbox_manager.prepare(&spec);
let exec_env = self
.sandbox_manager
.try_prepare(&spec)
.map_err(anyhow::Error::msg)?;
if matches!(spec.sandbox_policy, ExecutionSandboxPolicy::ReadOnly { .. }) {
require_native_readonly_execution(&exec_env)?;
}
@@ -6043,6 +6061,25 @@ impl ToolSpec for BashTool {
input: &serde_json::Value,
context: &ToolContext,
) -> Option<ToolResult> {
if input
.get("sandbox_permissions")
.and_then(serde_json::Value::as_str)
== Some("danger-full-access")
&& context
.shell_manager
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.tool_launcher_active()
{
return Some(ToolResult {
content: "BLOCKED: this host runs tool commands as another user, and only inside the sandbox, so a danger-full-access escalation cannot run. Retry without sandbox_permissions.".to_string(),
success: false,
metadata: Some(json!({
"blocked": true,
"reason": "tool_launcher_requires_sandbox",
})),
});
}
if context.auto_approve {
return None;
}
+47
View File
@@ -6085,3 +6085,50 @@ async fn a_foreground_command_past_its_wait_moves_to_the_background_alive() {
);
assert!(manager.processes.get(&task_id).expect("tracked").background);
}
#[test]
fn a_tool_launcher_clamps_a_danger_full_access_escalation() {
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path());
let escalation = json!({
"command": "id",
"sandbox_permissions": "danger-full-access",
"justification": "needs the host",
});
let tool = BashTool::new("bash");
assert!(tool.refusal_before_approval(&escalation, &ctx).is_none());
ctx.shell_manager
.lock()
.unwrap()
.set_tool_launcher(Some(vec!["/usr/bin/env".to_string()]));
let refusal = tool
.refusal_before_approval(&escalation, &ctx)
.expect("escalation refused before approval");
assert!(!refusal.success);
assert!(
refusal.content.contains("danger-full-access"),
"{}",
refusal.content
);
let error = ctx
.shell_manager
.lock()
.unwrap()
.execute_with_options_env(
"id",
Some(&tmp.path().to_string_lossy()),
1_000,
false,
None,
false,
Some(ExecutionSandboxPolicy::DangerFullAccess),
HashMap::new(),
)
.expect_err("an approved escalation still never runs unwrapped");
assert!(
format!("{error:#}").contains("nothing was run"),
"{error:#}"
);
}
+42
View File
@@ -291,6 +291,20 @@ fn format_skill_body(skill: &Skill) -> String {
out.push_str("## SKILL.md\n\n");
out.push_str(skill.body.trim());
out.push('\n');
if matches!(skill.source, SkillSource::Native)
&& let Some(context) =
codewhale_skills::bundled_skill_load_context(&skill.name, &skill.path)
{
out.push_str(&format!(
"\nBase directory for this skill: `{}`\n",
context.base_dir.display()
));
if let Some(notes) = context.host_notes {
out.push_str("\n## Host notes: Codewhale\n\n");
out.push_str(notes.trim());
out.push('\n');
}
}
let companions = collect_companion_files(skill);
if !companions.is_empty() {
@@ -884,4 +898,32 @@ mod tests {
.contains("secret steps for model")
);
}
#[test]
fn creator_skill_body_is_followed_by_base_directory_and_host_notes() {
let _lock = crate::test_support::lock_test_env();
let tmp = tempdir().unwrap();
let _home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path().join("home"));
let owned = codewhale_runtime::config::owned_skills_dir().unwrap();
codewhale_skills::install_system_skills(&owned, &Default::default()).unwrap();
let registry = SkillRegistry::discover(&owned);
let skill = registry.get("cordis-plugin-development").unwrap();
let rendered = format_skill_body(skill);
let body_end = rendered.find(skill.body.trim()).unwrap() + skill.body.trim().len();
let tail = &rendered[body_end..];
let base = format!(
"\nBase directory for this skill: `{}`\n",
owned.join("cordis-plugin-development").display()
);
let tail = tail.strip_prefix('\n').unwrap();
assert!(tail.starts_with(&base), "{tail}");
assert!(tail[base.len()..].starts_with("\n## Host notes: Codewhale\n\n"));
let plain = registry.get("agent-experience").unwrap();
let plain = format_skill_body(plain);
assert!(plain.contains("Base directory for this skill"));
assert!(!plain.contains("Host notes"));
}
}
@@ -0,0 +1,197 @@
#[path = "support/acp_conformance.rs"]
mod conformance;
use codewhale_engine::fleet::acp::{AcpClient, ControlWrite, PermissionPolicy};
use codewhale_engine::fleet::host::{
FleetHostAdapter, FleetWorkerCommand, FleetWorkerStartRequest, FleetWorkerTransport,
LocalProcessFleetHostAdapter,
};
use conformance::{Allowed, Conformance, Side};
use std::path::{Path, PathBuf};
use std::time::Duration;
const KNOWN: &[Allowed] = &[
Allowed {
side: Side::Client,
frame: "session/set_model request",
problem: "`session/set_model` is not a client request in official v1",
reason: "compatibility, kept: sent only to an agent that offers the pre-v1 `models` object and no `model` config option",
},
Allowed {
side: Side::Agent,
frame: "session/new response",
problem: "field `.models` is not in the official type",
reason: "the fixture's legacy leg offers the pre-v1 `models` object on purpose, to make the client send session/set_model",
},
Allowed {
side: Side::Agent,
frame: "initialize response",
problem: "field `.agentCapabilities.sessionCapabilities.fork` is not in the official type",
reason: "options fixture advertises session/fork, which is unstable_session_fork, not stable v1",
},
Allowed {
side: Side::Agent,
frame: "session/prompt response",
problem: "field `.usage` is not in the official type",
reason: "options fixture reports PromptResponse.usage, which is unstable_end_turn_token_usage, not stable v1",
},
];
const STEP: Duration = Duration::from_secs(20);
fn fixture(name: &str) -> String {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
.display()
.to_string()
}
fn captured(
agent: &str,
workspace: &Path,
log: &Path,
drive: impl FnOnce(&mut AcpClient),
) -> Vec<(Side, serde_json::Value)> {
let mut host = LocalProcessFleetHostAdapter::new(workspace);
let mut request = FleetWorkerStartRequest::new(
"w-acp-conformance",
FleetWorkerCommand::new(
"node",
[
fixture("acp-tee.mjs"),
log.display().to_string(),
fixture(agent),
],
),
);
request.transport = FleetWorkerTransport::JsonRpc;
request.cwd = Some(workspace.to_path_buf());
host.start_worker(request).unwrap();
let mut client = AcpClient::new(host.take_worker_io("w-acp-conformance").unwrap());
drive(&mut client);
host.stop_worker("w-acp-conformance").unwrap();
std::fs::read_to_string(log)
.unwrap()
.lines()
.map(|line| {
let (tag, frame) = line.split_once(' ').unwrap();
let side = if tag == "C" {
Side::Client
} else {
Side::Agent
};
(side, serde_json::from_str(frame).unwrap())
})
.collect()
}
#[test]
fn the_acp_client_and_its_fixture_agents_speak_official_v1_shapes() {
let has_node = std::process::Command::new("node")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success());
if !has_node {
eprintln!("skipped: node is not installed");
return;
}
let capture = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let mut frames = captured(
"acp-agent.mjs",
workspace.path(),
&capture.path().join("agent.log"),
|client| {
let identity = client.initialize(STEP).unwrap();
let session = client
.new_session(&identity, workspace.path(), &[], STEP)
.unwrap()
.session_id;
client
.set_config_option(&session, "model", "fixture-model", STEP)
.unwrap();
for policy in [
PermissionPolicy::AllowOnce,
PermissionPolicy::RejectOnce,
PermissionPolicy::Cancel,
] {
client.prompt(&session, "conform", policy, STEP).unwrap();
}
client.cancel(&session).unwrap();
client
.reopen_session(&identity, &session, workspace.path(), &[], STEP)
.unwrap();
},
);
let options_workspace = tempfile::tempdir().unwrap();
frames.extend(captured(
"acp-options-agent.mjs",
options_workspace.path(),
&capture.path().join("options.log"),
|client| {
let identity = client.initialize(STEP).unwrap();
let created = client
.new_session(&identity, options_workspace.path(), &[], STEP)
.unwrap();
let mut applied = 0;
for control in &created.offer.controls {
if control.write.is_none() || control.unavailable.is_some() {
continue;
}
if let Some(choice) = control.choices.last() {
client
.apply_control(&created.session_id, control, &choice.value, STEP)
.unwrap();
applied += 1;
}
}
assert!(
applied >= 2,
"options fixture offered {applied} writable controls"
);
client
.prompt(
&created.session_id,
"conform",
PermissionPolicy::AllowOnce,
STEP,
)
.unwrap();
},
));
let legacy_workspace = tempfile::tempdir().unwrap();
std::fs::write(
legacy_workspace.path().join("fixture-plan.json"),
r#"{"legacyModels":true}"#,
)
.unwrap();
frames.extend(captured(
"acp-agent.mjs",
legacy_workspace.path(),
&capture.path().join("legacy.log"),
|client| {
let identity = client.initialize(STEP).unwrap();
let created = client
.new_session(&identity, legacy_workspace.path(), &[], STEP)
.unwrap();
let model = created.offer.control("model").unwrap();
assert_eq!(model.write, Some(ControlWrite::SetModel));
client
.apply_control(&created.session_id, model, "legacy-b", STEP)
.unwrap();
},
));
let mut observed = Conformance::checking(&[Side::Client, Side::Agent]);
for (side, frame) in &frames {
observed.observe(*side, frame);
}
assert!(
observed.frames() > 30,
"only {} frames observed",
observed.frames()
);
observed.assert_only(KNOWN);
}
+13 -1
View File
@@ -96,7 +96,12 @@ function handle(message) {
record('session-params.jsonl', { method: message.method, params: message.params });
sessionId = `fixture-session-${process.pid}`;
cwd = message.params?.cwd ?? cwd;
result(message.id, { sessionId });
result(message.id, {
sessionId,
...(plan().legacyModels
? { models: { currentModelId: 'legacy-a', availableModels: [{ modelId: 'legacy-a', name: 'Legacy A' }, { modelId: 'legacy-b', name: 'Legacy B' }] } }
: {}),
});
break;
case 'session/load':
record('session-params.jsonl', { method: message.method, params: message.params });
@@ -106,11 +111,18 @@ function handle(message) {
notify('session/update', { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'replayed answer' } } });
result(message.id, {});
break;
case 'session/set_model':
result(message.id, {});
break;
case 'session/set_config_option':
configModel = message.params?.value ?? null;
result(message.id, { configOptions: [] });
break;
case 'session/prompt':
if (plan().ignoreCancel) {
record('held-prompts.jsonl', { id: message.id });
break;
}
if (HANG) {
pendingPrompt = message;
break;
+33
View File
@@ -0,0 +1,33 @@
import { spawn } from 'node:child_process';
import { appendFileSync } from 'node:fs';
const [logPath, agentScript] = process.argv.slice(2);
const child = spawn(process.execPath, [agentScript], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
function tap(tag) {
let buffer = '';
return (chunk) => {
buffer += chunk;
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
if (line.trim()) appendFileSync(logPath, `${tag} ${line}\n`);
}
};
}
const fromClient = tap('C');
const fromAgent = tap('A');
process.stdin.setEncoding('utf8');
child.stdout.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
fromClient(chunk);
child.stdin.write(chunk);
});
process.stdin.on('end', () => child.stdin.end());
child.stdout.on('data', (chunk) => {
fromAgent(chunk);
process.stdout.write(chunk);
});
child.on('exit', (code) => process.exit(code ?? 0));
@@ -0,0 +1,314 @@
use agent_client_protocol_schema::v1 as acp;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::collections::{BTreeSet, HashMap};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Side {
Client,
Agent,
}
impl Side {
fn name(self) -> &'static str {
match self {
Side::Client => "client",
Side::Agent => "agent",
}
}
fn peer(self) -> Side {
match self {
Side::Client => Side::Agent,
Side::Agent => Side::Client,
}
}
}
pub struct Allowed {
pub side: Side,
pub frame: &'static str,
pub problem: &'static str,
pub reason: &'static str,
}
pub struct Conformance {
checked: Vec<Side>,
pending: HashMap<(Side, String), String>,
findings: BTreeSet<(Side, String, String)>,
frames: usize,
}
fn check<T: DeserializeOwned + Serialize>(value: &Value) -> Vec<String> {
let typed: T = match serde_json::from_value(value.clone()) {
Ok(typed) => typed,
Err(error) => {
let text = error.to_string();
let text = text.split(" at line ").next().unwrap_or(&text);
return vec![format!("not a valid {}: {text}", short_type::<T>())];
}
};
let back = match serde_json::to_value(&typed) {
Ok(back) => back,
Err(error) => return vec![format!("does not re-serialize: {error}")],
};
let mut problems = Vec::new();
diff("", value, &back, &mut problems);
problems
}
fn short_type<T>() -> &'static str {
let name = std::any::type_name::<T>();
name.rsplit("::").next().unwrap_or(name)
}
fn skipped_default(key: &str, value: &Value) -> bool {
match value {
Value::Null => true,
Value::Array(items) => items.is_empty(),
Value::Object(map) => key == "env" && map.is_empty(),
Value::String(text) => matches!(
(key, text.as_str()),
("kind", "other") | ("status", "pending")
),
_ => false,
}
}
fn diff(path: &str, ours: &Value, official: &Value, problems: &mut Vec<String>) {
match (ours, official) {
(Value::Object(ours), Value::Object(official)) => {
for (key, value) in ours {
if key == "_meta" {
continue;
}
let at = format!("{path}.{key}");
match official.get(key) {
Some(back) => diff(&at, value, back, problems),
None if skipped_default(key, value) => {}
None => problems.push(format!("field `{at}` is not in the official type")),
}
}
}
(Value::Array(ours), Value::Array(official)) if ours.len() == official.len() => {
for (value, back) in ours.iter().zip(official) {
diff(&format!("{path}[]"), value, back, problems);
}
}
(Value::Number(a), Value::Number(b)) if a.as_f64() == b.as_f64() => {}
_ if ours == official => {}
_ => problems.push(format!(
"value at `{}` reads back as {official} instead of {ours}",
if path.is_empty() { "." } else { path }
)),
}
}
fn request_params(side: Side, method: &str, params: &Value) -> Option<Vec<String>> {
Some(match (side, method) {
(Side::Client, "initialize") => check::<acp::InitializeRequest>(params),
(Side::Client, "authenticate") => check::<acp::AuthenticateRequest>(params),
(Side::Client, "session/new") => check::<acp::NewSessionRequest>(params),
(Side::Client, "session/load") => check::<acp::LoadSessionRequest>(params),
(Side::Client, "session/resume") => check::<acp::ResumeSessionRequest>(params),
(Side::Client, "session/list") => check::<acp::ListSessionsRequest>(params),
(Side::Client, "session/close") => check::<acp::CloseSessionRequest>(params),
(Side::Client, "session/delete") => check::<acp::DeleteSessionRequest>(params),
(Side::Client, "session/set_mode") => check::<acp::SetSessionModeRequest>(params),
(Side::Client, "session/set_config_option") => {
check::<acp::SetSessionConfigOptionRequest>(params)
}
(Side::Client, "session/prompt") => check::<acp::PromptRequest>(params),
(Side::Agent, "session/request_permission") => {
check::<acp::RequestPermissionRequest>(params)
}
(Side::Agent, "fs/read_text_file") => check::<acp::ReadTextFileRequest>(params),
(Side::Agent, "fs/write_text_file") => check::<acp::WriteTextFileRequest>(params),
(Side::Agent, "terminal/create") => check::<acp::CreateTerminalRequest>(params),
(Side::Agent, "terminal/output") => check::<acp::TerminalOutputRequest>(params),
(Side::Agent, "terminal/release") => check::<acp::ReleaseTerminalRequest>(params),
(Side::Agent, "terminal/wait_for_exit") => check::<acp::WaitForTerminalExitRequest>(params),
(Side::Agent, "terminal/kill") => check::<acp::KillTerminalRequest>(params),
(Side::Agent, "elicitation/create") => check::<acp::CreateElicitationRequest>(params),
_ => return None,
})
}
fn response_result(method: &str, result: &Value) -> Option<Vec<String>> {
Some(match method {
"initialize" => check::<acp::InitializeResponse>(result),
"authenticate" => check::<acp::AuthenticateResponse>(result),
"session/new" => check::<acp::NewSessionResponse>(result),
"session/load" => check::<acp::LoadSessionResponse>(result),
"session/resume" => check::<acp::ResumeSessionResponse>(result),
"session/list" => check::<acp::ListSessionsResponse>(result),
"session/close" => check::<acp::CloseSessionResponse>(result),
"session/delete" => check::<acp::DeleteSessionResponse>(result),
"session/set_mode" => check::<acp::SetSessionModeResponse>(result),
"session/set_config_option" => check::<acp::SetSessionConfigOptionResponse>(result),
"session/prompt" => check::<acp::PromptResponse>(result),
"session/request_permission" => check::<acp::RequestPermissionResponse>(result),
"fs/read_text_file" => check::<acp::ReadTextFileResponse>(result),
"fs/write_text_file" => check::<acp::WriteTextFileResponse>(result),
"terminal/create" => check::<acp::CreateTerminalResponse>(result),
"terminal/output" => check::<acp::TerminalOutputResponse>(result),
"terminal/release" => check::<acp::ReleaseTerminalResponse>(result),
"terminal/wait_for_exit" => check::<acp::WaitForTerminalExitResponse>(result),
"terminal/kill" => check::<acp::KillTerminalResponse>(result),
"elicitation/create" => check::<acp::CreateElicitationResponse>(result),
_ => return None,
})
}
fn notification_params(side: Side, method: &str, params: &Value) -> Option<Vec<String>> {
Some(match (side, method) {
(_, "$/cancel_request") => check::<acp::CancelRequestNotification>(params),
(Side::Client, "session/cancel") => check::<acp::CancelNotification>(params),
(Side::Agent, "session/update") => check::<acp::SessionNotification>(params),
(Side::Agent, "elicitation/complete") => {
check::<acp::CompleteElicitationNotification>(params)
}
_ => return None,
})
}
fn envelope(frame: &Value, allowed: &[&str]) -> Vec<String> {
let mut problems = Vec::new();
if frame.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
problems.push("envelope lacks `jsonrpc: \"2.0\"`".to_string());
}
if let Some(map) = frame.as_object() {
for key in map.keys() {
if !allowed.contains(&key.as_str()) && key != "jsonrpc" {
problems.push(format!("envelope carries `{key}`"));
}
}
}
problems
}
impl Conformance {
pub fn checking(sides: &[Side]) -> Self {
Self {
checked: sides.to_vec(),
pending: HashMap::new(),
findings: BTreeSet::new(),
frames: 0,
}
}
pub fn observe(&mut self, side: Side, frame: &Value) {
self.frames += 1;
let method = frame.get("method").and_then(Value::as_str);
let id = frame.get("id");
let params = frame.get("params").cloned().unwrap_or(Value::Null);
let (label, mut problems) = match (method, id) {
(Some(method), Some(id)) => {
self.pending
.insert((side, id.to_string()), method.to_string());
let mut problems = envelope(frame, &["id", "method", "params"]);
match request_params(side, method, &params) {
Some(found) => problems.extend(found),
None if method.starts_with('_') => {}
None => problems.push(format!(
"`{method}` is not a {} request in official v1",
side.name()
)),
}
(format!("{method} request"), problems)
}
(Some(method), None) => {
let mut problems = envelope(frame, &["method", "params"]);
match notification_params(side, method, &params) {
Some(found) => problems.extend(found),
None if method.starts_with('_') => {}
None => problems.push(format!(
"`{method}` is not a {} notification in official v1",
side.name()
)),
}
(format!("{method} notification"), problems)
}
(None, Some(id)) => {
let answered = self.pending.remove(&(side.peer(), id.to_string()));
let label = match &answered {
Some(method) => format!("{method} response"),
None => "response".to_string(),
};
let mut problems = Vec::new();
if let Some(error) = frame.get("error") {
problems.extend(envelope(frame, &["id", "error"]));
problems.extend(check::<acp::Error>(error));
} else if let Some(result) = frame.get("result") {
problems.extend(envelope(frame, &["id", "result"]));
if let Some(found) = answered
.as_deref()
.and_then(|method| response_result(method, result))
{
problems.extend(found);
}
} else {
problems.push("response has neither result nor error".to_string());
}
if answered.is_none() {
problems.push(format!("answers no outstanding request (id {id})"));
}
(label, problems)
}
(None, None) => (
"frame".to_string(),
vec!["neither a request, a notification nor a response".to_string()],
),
};
let label = match frame
.pointer("/params/update/sessionUpdate")
.and_then(Value::as_str)
{
Some(kind) => format!("{label} ({kind})"),
None => label,
};
if self.checked.contains(&side) {
for problem in problems.drain(..) {
self.findings.insert((side, label.clone(), problem));
}
}
}
pub fn assert_only(&self, allowed: &[Allowed]) {
assert!(self.frames > 0, "no frames were observed");
let mut used = vec![false; allowed.len()];
let mut unexpected = Vec::new();
for (side, frame, problem) in &self.findings {
match allowed.iter().position(|entry| {
entry.side == *side && entry.frame == frame && problem.contains(entry.problem)
}) {
Some(index) => used[index] = true,
None => unexpected.push(format!("{} {frame}: {problem}", side.name())),
}
}
let stale: Vec<_> = allowed
.iter()
.zip(&used)
.filter(|(_, used)| !**used)
.map(|(entry, _)| {
format!(
"{} {}: {} ({})",
entry.side.name(),
entry.frame,
entry.problem,
entry.reason
)
})
.collect();
assert!(
unexpected.is_empty() && stale.is_empty(),
"ACP frames differ from the official v1 schema.\nnew deviations:\n {}\nallowlisted deviations no longer seen (remove them):\n {}",
unexpected.join("\n "),
stale.join("\n ")
);
}
pub fn frames(&self) -> usize {
self.frames
}
}
Loaded 100 of 261 files, more files were not shown because too many files have changed in this diff. Show more