Compare commits

..
Author SHA1 Message Date
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
CodeWhale BotandClaude Sonnet 5.5 9a1a64e708 chore(catalog): list the reviewed new models on the public list
Supersedes pull request 7286. The same discovery, rebuilt on current main with
the review's changes: mistral-large-4-0-8 goes to [discover].skip (Mistral does
not list that id); mistral-large-4 is dated 2026-11-05 per Mistral's page;
muse-ember-1.0 is held (Meta publishes no limits or prices); MiniMax-M3.1-Flash
carries no max_output because MiniMax does not state one. The lock is left at
main's pin so no unreviewed price or limit drift comes in.

apply_discoveries maps the models.dev vendor to a ModelFamily variant through
VENDOR_FAMILY instead of copying the public-list label into the family field
(which the config crate rejects, panicking bundled_models_dev_catalog()). One
unittest pins that every discover vendor maps to a variant parsed from
reviewed.rs. This commit is independent of the qwen4-max-preview commit.

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 B' review's own farm run on the source commit: config 834+9, models 108+1 doctest, run 20261111T042035Z-review3-catalogqwen4Bp-efd93c.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:23:54 -08:00
CodeWhale BotandClaude Sonnet 5.5 f0d18fd02a docs(providers): name Fable 5.2 and Gemini 4 Carbon, say what Gemini 4 does (CW-8038)
PROVIDERS.md and its zh_hans copy now list claude-fable-5-2 and
gemini-4-carbon beside their siblings and give Gemini 4 the 65,536 output
limit Google states. The Google row no longer says an unsigned tool-call replay
fails closed for every thinking model: it does for gemini-3*, gemini-2.5-pro
and gemini-2.5-flash, while Gemini 4 only logs a warning and sends, and Google
does not say whether Gemini 4 requires signatures. Reasoning effort is
described as the reasoning_effort field the Engine sends, not thinking_level.
web/data/agent-docs.generated.json regenerated with derive-facts; on the
stacked base only the PROVIDERS.md entries change.

Checks: agent-docs regenerated with web/scripts/derive-facts.mjs, not merged by hand; 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.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:23:50 -08:00
CodeWhale BotandClaude Sonnet 5.5 86cb595313 fix(engine): send xhigh effort as xhigh when the model's row lists it (CW-8038)
The adaptive-thinking branch mapped xhigh to max for every Anthropic model, so
a person choosing xhigh on a model that documents it was sent, and billed for,
max. A model whose catalog row lists xhigh now gets xhigh; a model whose row
does not list it keeps the max mapping. Anthropic's effort page lists xhigh on
Fable 5.2, Fable 5.1, Fable 5, Opus 5.5, Opus 5, Opus 4.8, Opus 4.7, Sonnet 5.5,
Sonnet 5 and Haiku 5.5.

Checks: 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. No live provider call: whether the API accepts xhigh on every row that lists it is unproven.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:23:46 -08:00
CodeWhale BotandClaude Sonnet 5.5 d85f3abde1 fix(catalog): Gemini 4 output limit is 65,536, sources cite the vendors (CW-8038)
Google's model pages give gemini-4-carbon and gemini-4-argon 65,536 output
tokens by default; the bundled rows carried Models.dev's 32,768. The limit is
held as a model correction as well as in the reviewed row so a Models.dev
refresh cannot put 32,768 back. gemini-4-argon's reviewed row now says it
reasons. claude-fable-5-2, gemini-4-carbon and gemini-4-argon cite the vendor
pages with receipts in place of models.dev. The Gemini limits test roster now
names both Gemini 4 ids.

The catalog cannot carry a dated price, so the 2026-12-01 Gemini 4 price
change is not recorded here.

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.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:23:42 -08:00
CodeWhale BotandClaude Fable 5.2 8ddd9025c0 chore(changelog): sync the TUI changelog slice for the install guide note and the INSTALL and FACTS-D website slices (CW-8048)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 21:20:07 -08:00
CodeWhale BotandClaude Fable 5.2 bcba9bdf2e docs(web): drop references to components that no longer exist (CW-8048 FACTS-D)
The localization guide, its Chinese copy and the website README still named
web/components/feed-card.tsx and its KIND_LABEL pairs, which the snapshot
change removed. The README tree also listed whale.tsx and ticker.tsx, gone
since 396cead3b4.

Checks: check-facts OK after regenerating the agent docs.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 21:20:05 -08:00
CodeWhale BotandClaude Opus 5.5 97014b29ee fix(web): announce the read date on /contribute and drop unused feed code (CW-8048 FACTS-D)
The merged-count paragraph carried its read date in aria-label, which
ARIA does not allow on a paragraph, and the milestone progress had only
a title. Both now keep the title and add the read date as visually
hidden text (the site's sr-only class), so screen readers announce it.
The visible text is unchanged. The repo-activity test now asserts the
hidden read date on the count and on every milestone progress, and no
aria-label on a paragraph; it fails against the previous page.

Removes the JSDoc line added on extractPluralBlocks in
check-locales.mjs (the repository takes no new code comments).

git grep shows nothing uses the .feed-card* classes or imports
lib/truncate.ts (only its own test did) after the feed card was
removed, so the seven CSS rules, lib/truncate.ts and its test go.

Checks from web/: vitest lib/repo-activity.test.ts 20/20 passed;
check:facts, check:docs, check:locales, tsc --noEmit, lint all exit 0.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:19:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 ddec614494 web: project people and milestones come from a server-side snapshot, the browser no longer calls GitHub (CW-8048 FACTS-D)
repo-stats-snapshot.json now carries the merged pull requests of the last 30
days, open versioned milestones and, for the last five published tags, the
authors merged between the previous tag's and that tag's publish dates, all
read by sync-repo-stats under a deadline and byte cap (any failure leaves the
file alone). contributor-exclusions.json is the public exclusion list; its
confirmed flag switches the count noun from accounts to people. /contribute
renders faces, the 30-day count and an id="milestones" release plan from the
snapshot; /api/facts gains liveRecord {source, readAt, fresh (7 days)}.

Removed: the browser GitHub fetch from e4208b5486, /api/github/feed and its
retry, feed-card, feed-retry, roadmap-feed, fetchFeed/loadFeed/fetchFaces/
fetchMilestones, the feed, community and roadmap dictionaries in all locales,
and data-funnel star_exit on the hero star. Count sentences use
Intl.PluralRules keyed forms (lib/i18n/plural.ts) and check-locales enforces
every category a locale needs plus {n}.

Checks: new repo-activity tests 19/19; npm test 90 files 1288 tests; tsc, lint,
check:locales, check:facts, check:docs pass; browser requests to
api.github.com on /en /zh /ar /en/contribute: 12 before, 0 after.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 21:19:08 -08:00
CodeWhale BotandClaude Sonnet 5.5 ef1eb2708b fix(web): keep the space before the pipe in the split install command (CW-8048 INSTALL)
The first segment sliced at pipeAt dropped the space, so at 560px and wider the command read 'install.sh| sh'. Slice at pipeAt + 1; the trailing space hangs under pre-wrap at 375. Static render and 1440/375 screenshots confirm. Rerun: tsc 0, lint 0, vitest 90 files 1314/1314, build 0, shoot 8 screenshots 0 problems.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 21:19:04 -08:00
CodeWhale BotandClaude Sonnet 5.5 0a7db594de fix(web): hanging indent only on single-line install commands; align the wrapped pipe (CW-8048 INSTALL)
Under 35rem text-indent -2ch moved only the first line, so every line after a
hard newline in the PATH and npm-prefix blocks sat 2ch right of the first and
read as one soft-wrapped line. The indent now applies to a .code-block-single
modifier set when the command has no newline; multi-line blocks keep pre-wrap
with no indent. The pipe segment is sliced at pipeAt + 1 so "| sh" aligns with
the URL continuation. At 375 the hero command wraps onto three lines.

Checks from web/ under the heavy lock: tsc 0, lint 0, check:locales 0, full
vitest 90 files / 1314 of 1314 passed, build 0, shoot.mjs 16 screenshots, 0
problems, 0 overflow. /en/install at 375 checked by eye (PATH block aligned).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 21:19:01 -08:00
CodeWhale BotandClaude Sonnet 5.5 16a19336d8 fix(web): drop an unused import in the docs search index (CW-8048 INSTALL)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 21:18:58 -08:00
CodeWhale BotandClaude Sonnet 5.5 5a31702b7b feat(web): rewrite /install for the released version: PATH, verify, first task, usage counting, update, removal, channels, mirrors (CW-8048 INSTALL)
Heading and lead come from the locked home section; version and the uninstall form come from the published release object and its capability row. The embedded v0.10.0 guide becomes a link. The install command wraps at the pipe below 560px with Copy on its own row. The confirmation control is a quiet optional text control.

Checks (web/): tsc clean, check:facts/docs/locales pass, 16 harness screenshots 0 problems, 0 overflow; npm test 1311 of 1314 passed first run, the 3 failures were stale assertions fixed here and rerun green (51 of 51 in the three affected files).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 21:18:54 -08:00
CodeWhale BotandClaude Fable 5.2 f768084c38 docs(skills): every push to main runs hosted CI; regenerate the routes table and agent docs after a rebase
The landing skill still said a push is not a CI request and that routine
commits carry a skip marker, and the release sweep said hosted CI runs once
at the release step. Both contradict the landing gate, which starts full CI
on every push to main. The landing skill also gains the runtime routes check
beside the facts check, with the reason: git has merged docs/RUNTIME_ROUTES.md
wrongly with no conflict marker.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 21:11:17 -08:00
CodeWhale BotandClaude Fable 5.2 e0ea0d58d9 docs(install): the first-message note describes v0.10.2
The note under the first task said v0.10.0 gives no warning without a key and
sent the reader to F3. v0.10.2 shows "no model connected · run /provider" on
its first screen and returns the first message to the composer, so the note
now says to connect a model with /provider, and to restart if messages stay
saved for later after connecting. The Chinese guide carries the same
correction. Proposed by the website install slice, which reads the same facts.

Checks: check-facts OK after regenerating the agent docs.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 21:11:14 -08:00
CodeWhale BotandClaude Fable 5.2 230f1bc333 chore(changelog): sync the TUI changelog slice for the Engine stacks landing (CW-7875, CW-7958)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 20:58:44 -08:00
CodeWhale BotandClaude Opus 5.5 97762ba5fb fix(app-server,runtime): quota route joins the test env scope; cap agent quota entries (CW-7993 AGENT-QUOTA)
The quota handler loaded settings on a spawn_blocking thread that was not
enrolled in the caller's lock_test_env scope. Under test, settings path
resolution calls with_test_env_lock, so the blocking worker waited on the
mutex the test thread held while the test awaited the HTTP reply: a
deadlock at 0% CPU, which review saw as the e2e test hanging and
GET /v1/agents/external/quota never answering. The handler now carries an
env_scope_ticket into the worker and joins it, as the roster, MCP and
command handlers in runtime_api.rs already do. The cfg-gated lines compile
out of production builds; mobile_page at runtime_api.rs:3591 has the same
shape but no test reaches it while holding the lock.

agents.quota.<id> keys were shape-validated with no bound, so any number of
ids could be persisted and each listed as unknown_agent. Settings::set now
refuses a new id once 32 entries exist (the registry has 10); existing ids
stay editable and clearing one frees a slot. The runtime crate cannot see
the engine registry, so the cap rather than registry membership is the
write-time guard. One runtime unit test covers validation, no value echo,
default-entry removal and the cap.

Checks (local, dev-cargo.sh under the build lock, hermetic HOME):
- test -p codewhale-app-server --lib external_agent: 4 passed, 0 failed
  (external_agent_quota e2e ok in 8.01s; previously hung >7m)
- test -p codewhale-config -p codewhale-runtime --lib settings: 5 passed
  + 102 passed, 0 failed, 1 ignored
- clippy -p codewhale-engine -p codewhale-app-server -p codewhale-runtime
  --all-targets -D warnings: clean
- test -p codewhale-tui --lib every_persisted_settings_field_is_declared_in_the_schema: 1 passed
- cargo fmt --all --check: clean
Engine unchanged since review (fleet::agents 7 passed there).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:58:43 -08:00
CodeWhale BotandClaude Opus 5.5 dc5d27dfed feat(engine,app-server): agent quota contract, per-agent flag and consent record (CW-7993 AGENT-QUOTA)
GET /v1/agents/external/quota reports, per registry agent, quota as
{status: "known", source, windows} or {status: "unknown", reason}, with
default_source "agent_reported" (EA-04 stays the default). This wave every
agent reports unknown: flag_off without agents.quota.<agent>,
consent_missing without a consent record, not_implemented when both are
present. A persisted id outside the registry reports unknown_agent.

The flag and consent are set and cleared only through POST /v1/config
(Settings::set): agents.quota.<agent> = on|off and
agents.quota.<agent>.consent = granted|revoked, persisted as
[agents.quota.<agent>] enabled / consented_at in settings.toml and declared
in SETTINGS_SCHEMA as the hidden "agents" entry. Error text never echoes the
submitted value.

No credential is read: LocalSigninReader is an empty enum seam that the
first vendor slice fills with one variant and one reader, without touching
the route. Known limits: vendor ids are shape-validated at write, registry
membership is reported at read; quota is not embedded in the list route.

docs/RUNTIME_ROUTES.md and web/data/agent-docs.generated.json are
regenerated for the new route (check-runtime-routes.mjs --write,
derive-facts.mjs).

Checks: Boat farm runs were queued when this commit was made; counts are in
the review packet, not claimed here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:58:41 -08:00
CodeWhale BotandClaude Sonnet 5.5 70ab5fc612 fix(engine): never write an allow after the turn is cancelled (CW-7958 REF-09)
The permission reply is written unguarded so a cancelled turn still answers
its pending request, but a decision that raced the interrupt could reach the
agent as selected. Force cancelled once the token has fired.

Checks: rustfmt clean; farm run 20261111T035138Z-REF-09-A1-18773e,
cargo test --locked -p codewhale-engine --lib -- fleet::acp external_agent:
23 passed, 0 failed, 7129 filtered out. Independent review rerun
20261111T035953Z-ref09rev2-a1-f25fe5: 23 passed, 0 failed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 20:58:40 -08:00
CodeWhale BotandClaude Sonnet 5.5 41b6b54582 fix(engine): answer a pending permission request as cancelled on interrupt, send additionalDirectories when advertised, project plan/mode/command updates (CW-7958 REF-09)
AUD-ACP ruled fleet/acp.rs the one ACP client and crates/acp-client stays
unlanded, so no Cargo.toml or Cargo.lock change.

- G1: the session/request_permission response is now written without the
  cancel guard, so an interrupt during a pending approval still tells the
  agent the request was cancelled. The guard stays on the initial request line.
- G3: AgentCapabilities gains additional_directories; session/new and
  session/load|resume send additionalDirectories (the thread's add_dirs) only
  when the agent advertises sessionCapabilities.additionalDirectories.
- Projection: plan_update is handled like plan; current_mode_update and
  available_commands_update emit turn.agent_mode and turn.agent_commands.
- abort_requests_on semantics are untouched.

Fixture records permission outcomes and session params and can advertise
additionalDirectories or emit the extra updates from fixture-plan.json.
Three new tests (interrupt cancelled answer, additionalDirectories gating,
projection).

Checks: farm run 20261111T032328Z-REF-09-060bd4, cargo test --locked -p
codewhale-engine --lib -- fleet::acp external_agent: 23 passed, 0 failed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 20:58:39 -08:00
CodeWhale BotandClaude Sonnet 5.5 f064d1829e fix(installer): install.bat forwards its arguments so -NoModifyPath reaches install.ps1 (CW-8046)
install.bat now passes %* to install.ps1. One static test in install-safety.test.js asserts the forward and the -NoModifyPath param. Install guide (en, zh_hans) says install.bat -NoModifyPath works; agent-docs regenerated by the web prebuild. Never executed on Windows.

Checks: install-safety.test.js 72/72; public-installer-contract vitest 33/33; check-facts, check-docs OK; preflight OK.

Landing adds the CHANGELOG line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:58:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 b73f16e0ef feat(app-server): serve reviewed DSH client closures on six seats with a plugin route bridge (CW-7956)
The plugin UI route now takes any of the six seats and an optional dsh_slot.
An unknown or missing seat is a coded 400 (plugin_ui_seat_unknown) instead of
the old uncoded one (DSH-00 defect 4). A native client is admitted only on
customize.plugins.detail; every other seat or a dsh_slot is a 404
plugin_ui_seat_not_admitted.

A DSH-converted plugin is no longer refused outright. Its retained
client_closures receipt (DSH-01) is re-verified byte for byte against the
reviewed file hashes, each file against the importer's own SHA-256, and the
declared slot keys found in the closure are mapped through the contract seat
table. A key whose seat is unsupported is a 422 plugin_ui_seat_unsupported;
more than one key on a seat without dsh_slot is a 400
plugin_ui_dsh_slot_required. Closures that need host modules the frame does not
provide, ES module syntax, or invalid JSON stay plugin_ui_unsupported. The
frame loads one Engine-generated CommonJS bootstrap built from the verified
closure bytes; every other path is a 404 plugin_ui_file_not_reviewed. The
response carries dsh_slot and a bridge identity (kind, dsh_version,
compatibility_digest).

Grants are now dropped the moment a plugin is disabled, revoked, restaged,
trusted, enabled, updated or uninstalled, so an open frame's revision is stale
at once rather than at its next read.

/v1/apps/plugins/{selector}/routes/{*path} checks the loopback peer, the single
If-Match revision, the selector and workspace binding, authority and expiry
and that the grant is a DSH client, then refuses with 501 plugin_route_not_served
because no plugin web server exists to forward to yet.

Evidence: codewhale-app-server --lib, filter plugin_ui, 3 passed on the build
farm (native detail-only seat, DSH admit/deny/revoke with consumer-contract
fixture, unprovided-module refusal).

Landing adds the CHANGELOG line.

docs/RUNTIME_ROUTES.md lists the new route (regenerated with
web/scripts/check-runtime-routes.mjs --write) and the agent docs are regenerated from it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:58:36 -08:00
CodeWhale BotandClaude Sonnet 5.5 d9a98c54cc fix(plugins): admit a top-level .gitignore in the DSH Native-source importer (CW-8023)
dsh-anchored-standard 1.2.1 ships a .gitignore, which the Native-source
hidden-file rule refused. The existing allow condition now also admits the
package-root .gitignore by exact relative path. Nested .gitignore files, other
hidden files, credential files, .git, symlinks and the MCP-source rule are
unchanged.

Adds one regression: a root .gitignore imports and is retained under source/;
nested .gitignore, .prettierrc, .env, .npmrc, .git/config and a symlinked
.gitignore still refuse.

Task: DSH-IMPORT-GITIGNORE. Landing adds the CHANGELOG line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:58:34 -08:00
CodeWhale BotandClaude Sonnet 5.5 4cc9108652 chore(tooling): preflight checks the website's generated facts, agent docs and feature registry (CW-8045)
scripts/preflight.sh now runs web/scripts/check-facts.mjs and check-cloud-facts.mjs, the same
commands ci web job runs as check:facts, so a doc change that feeds web/data/agent-docs.generated.json
without regeneration fails preflight and names the regenerate command. Skipped with a printed notice
when node is not on PATH. main-landing.yml sets up Node 22 so the step runs in the landing gate.
Negative control run: appended a line to docs/RUNTIME_API.md, preflight exit 1; regenerated, OK; reverted.
Clean tree: preflight OK. Step adds about 1.1 s warm. actionlint clean.

The same step also runs web/scripts/check-runtime-routes.mjs, so a route added in
crates/app-server/src/runtime_api.rs without regenerating docs/RUNTIME_ROUTES.md fails preflight
and the hint names node web/scripts/check-runtime-routes.mjs --write. All three scripts run even
when one fails. Negative control at landing: one row deleted from docs/RUNTIME_ROUTES.md, preflight
exit 1 with "[runtime-routes] FAIL" and the hint; restored.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:58:33 -08:00
1f9ad30c1f feat(app-server,tui): run read-only builtins over the Runtime API (#7277)
* feat(app-server,tui): run read-only builtins over the Runtime API

A remote client can now execute the terminal's read-only diagnostics against a
thread and read the engine's own answer, instead of being told where the
command lives. /system, /tokens and /cost are served; every other name,
including a name no host knows, answers exactly as it did before.

The served set is gated twice — by the terminal host's allowlist and by each
portable handler's declared data needs — so the remote surface cannot widen
without the host widening it first, and nothing is filled in from estimates:
absent telemetry keeps its absence encoding, per-route cost rows stay empty
until they can be attributed, and the cost currency resolves through the same
rule the terminal's display uses.

* fix(app-server): report the effective prompt and keep builtin diagnostics read-only

/system read thread.system_prompt, which is only the client override, so an
ordinary thread printed "(no system prompt)". The route now takes the system
prompt, mode and transcript length from the live engine session snapshot, and
an unreadable snapshot is an error instead of a zero message count.

The settings read used Settings::load(), which can migrate a legacy file
(a write), ran blocking I/O on a Tokio worker and defaulted on a malformed
file. It now runs Settings::load_read_only on the blocking pool and surfaces
failures as 500, as the mobile page does.

The route uses loaded_engine so a diagnostic never boots a cold engine (409
instead), and the name echoed in the 404 is capped at 64 characters.

The route test now starts with a runtime token and asserts 401 without it, 409
for a cold engine, and that the effective prompt is reported. The zh_hans
RUNTIME_API mirror gains the route paragraph, and both languages document the
403 for watch-only tokens.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* chore(web): regenerate agent-docs for the RUNTIME_API route paragraph

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* docs(changelog): credit @gaord's read-only builtins over the Runtime API (#7277)

Adds the CHANGELOG line for POST /v1/threads/{id}/commands/builtin, a
docs/CONTRIBUTORS.md entry under Unreleased and @gaord in
UNRELEASED_CONTRIBUTORS, following the #7261 credit. The agent-docs data
is regenerated with web/scripts/derive-facts.mjs because CONTRIBUTORS.md
is one of its sources. check-contributor-credit passes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(tui): sync the TUI changelog slice after the #7277 credit line

Generated by scripts/sync-changelog.sh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(runtime): regenerate the route table and agent docs for the builtin commands route (#7277)

docs/RUNTIME_ROUTES.md is generated from the route registrations in
runtime_api.rs; this pull request adds POST /v1/threads/{id}/commands/builtin.
Regenerated with node web/scripts/check-runtime-routes.mjs --write and
node web/scripts/derive-facts.mjs after merging main, which now carries the
Windows compile fix (a37d7fdd27) that this branch's required check needed.

---------

Co-authored-by: CodeWhale Bot <[email protected]>
Co-authored-by: Claude Sonnet 5.5 <[email protected]>
2026-11-11 12:58:06 +08:00
CodeWhale BotandClaude Opus 5.5 34a29a0f2a chore(changelog): sync the TUI changelog slice for the website slices landing (CW-8048)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:47 -08:00
CodeWhale BotandClaude Sonnet 5.5 1155537c7b feat(web): honour Global Privacy Control and Do Not Track before counting, and say how counts are keyed (CW-8048)
The recorder never starts counting, reading or creating an install id while the browser sends GPC or DNT, and clears earlier queued counts and the id. The forwarder answers Sec-GPC: 1 or DNT: 1 as disabled before reading the body. The notice and privacy text now state what is counted, the random 90-day install id, no URL or referrer, the signals, and the program's off switches; the word anonymous is gone. zh text needs a person's review.

Checks: owned vitest files 56/56 (4 files); tsc, eslint, check:locales, check:facts clean. check:docs fails on origin/main too (RUNTIME_ROUTES.md). Full npm test, build, browser and harness evidence not run yet.

Amended at landing: adds the MEASURE CHANGELOG line under Changed.

Comments removed at landing (no new code comments): the privacy-signals section
divider and the empty-catch note in clearUsageState are gone, and the
usage-counting.ts header keeps its main wording with only "anonymous"
dropped. For readers: clearUsageState removes the queued counters, funnel
counters and install id and keeps the person's own preference; a storage
error there is ignored because there is nothing to clear.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:17 -08:00
CodeWhale BotandClaude Sonnet 5.5 ae57369303 fix(web): keep English fallback text in its own direction and language on Arabic pages (CW-8048)
Pages and docs that fall back to English inside the right-to-left Arabic shell now carry lang="en" dir="ltr" on the English region, so punctuation and commands no longer render reversed and screen readers read them as English. A zero-specificity [dir="rtl"] rule lets each text block take its direction from its first strong character and keeps code and terminal frames left to right. English dates replace Arabic-locale dates inside English bodies. English, Chinese and translated regions gain no attributes.

Checks (web/): tsc clean, lint clean, check:locales/facts/docs pass, npm test 86 files 1286 tests pass, next build ok, 36 harness screenshots 0 problems.

Amended at landing: adds the RTL CHANGELOG line under Fixed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:16 -08:00
CodeWhale BotandClaude Sonnet 5.5 fd4d9504e1 web: correct docs facts to the published release and mirror them in zh (CW-8048)
Modes, vocabulary, tools, account sign-out, sandbox, receipts, hooks and
runtime pages now state what the published release does; source-only
controls sit in the labelled section. The page about bringing settings from
another coding agent names no product in its title, description or headings.
The constitution page drops the avoid-list word. Fleet drops a default
worker count. The Computer Use setup step no longer asks for Operate. zh
mirrored; 16 partial locales carry a machine-drafted computer-use line.
gt catalogs regenerated.

Amended at landing: adds the DOCS-TRUTH CHANGELOG line under Fixed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:13 -08:00
CodeWhale BotandClaude Sonnet 5.5 c2def1f43a web: docs describe the published release first and label what exists only in source (CW-8048)
A docs page can carry capability-keyed source groups and until-gated sections.
resolveDocsPage renders released text in the body, puts groups whose capability
row is above the published release in one labelled in-source section, and moves
a group into the body when a release carries its row, with no hand edit. The
version note now sits at body size and states the published tag. The tools
list, vocabulary controls, search index and reference pages follow the same
split. Tests: 5 in lib/docs-release.test.ts.

Amended at landing: no new code comments. Removed the doc comments on
DocsSection.until, DocsSourceGroup, releaseDescribes and sourceLead
(types.ts), the two ToolRow field comments (tools.ts) and the comments on
RELEASED_CONTROL_MODES and RELEASED_PERMISSION_POSTURES; the vocabulary.ts
header and the CONTROL_MODES and PERMISSION_POSTURES comments are back to
their main wording. For readers: a section's until claim keeps released text
only until that capability row is released; a source group is labelled while
its row is above the published release and joins the body once a release
carries it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 c9d8c758a2 feat(web): render released-version terminal frames and caption them by origin (CW-8048)
One converter, scripts/render-released-capture.mjs, turns the released v0.10.2
tmux captures (SGR text) into the cell format TerminalCapture renders; every
character is kept, only SGR codes are dropped, any other escape fails, and
--check joins check:docs. The palette helper moves to scripts/terminal-palette.mjs
and render-terminal-capture.mjs now uses it. Development frames gain
provenance: "development". TerminalCapture takes an optional data prop and
renders its root dir="ltr" lang="en". lib/capture-caption.ts picks the caption
key from provenance: the released caption only when a test run is shown and no
pre-task frame is; none for mixed origins; per-frame flags for the posture note.

Checks: tsc, eslint, check:locales, check:facts clean; new and updated tests 14
passed; npm test 85 files, 1282 tests passed plus 7 node tests; next build ok.

Amended at landing: adds the CAP-WEB CHANGELOG line under Fixed.

Comments removed at landing (no new code comments): the converter's file
header and its inline stale-check note are gone, and the TerminalCapture doc
comment keeps its main wording. For readers of the converter: input is
lib/terminal-captures/released/provenance.json and the .ansi files it names
(tmux capture-pane -p -e); SGR state carries from row to row as in the tmux
stream; tmux trims trailing blanks, so short rows are padded to the frame
width with default-coloured spaces; a crop is a whole-row slice rendered from
its raw frame's stream and must equal its own crop file's text; output is
lib/released-capture.generated.ts; run it with no argument to write the
module and with --check to fail when it is stale or missing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:11 -08:00
CodeWhale BotandClaude Sonnet 5.5 d5d2d531f9 web: footer Project column labels the storefront link Merchandise (CW-8048 ROUTES)
Adds chrome key footerMerch in all 18 locale chrome files and ChromeDict,
drops the merch argument from footerProjectLinks, regenerates GT catalogs.
Reran: tsc 0; vitest 6 files/134 passed; check:locales PASS; lint 0.

Amended at landing: adds the ROUTES CHANGELOG line under Changed.
Comments removed at landing: footerProjectLinks carries no doc comment.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:09 -08:00
CodeWhale BotandClaude Sonnet 5.5 ad67c57180 web: update tests that read the deleted pages (CW-8048 ROUTES)
public-copy, public-surface-contract, page-meta, docs-article-search and the
funnel test asserted on the source of the eight redirected pages; the
assertions that still describe something move to the pages that carry it
(home, docs/constitution) and the rest are removed with the pages.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 20:37:08 -08:00
CodeWhale BotandClaude Sonnet 5.5 a3308019c2 web: admin never names a secret; merch country names match on hydration (CW-8048 ROUTES)
Intl.DisplayNames disagreed between Node and the browser (Hong Kong), the text
mismatch behind React error 418. Names now render from the static list until
mounted, English always.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 20:37:07 -08:00
CodeWhale BotandClaude Sonnet 5.5 72baaf0ff0 web: header and footer carry the decided items; machine drafts say so (CW-8048 ROUTES)
Header: Docs, Models, Changelog, Contribute, GitHub with the snapshot star
count, Sign in, Install, and Desktop only when the desktop state is released.
Footer: four columns from links.ts, retired headline, Capabilities, Runtime,
Roadmap and Desktop-and-web removed, Security points at SECURITY.md. Locales
without native review show one machine-draft line. Nine chrome keys removed,
three added in all 18 locales; GT catalogs regenerated.

Amended at landing: the docs-ia sitemap test now expects /docs/claude-code in
the sitemap, matching the sitemap amendment in the previous commit.

Comments removed at landing (house rule: no new code comments): the new doc
comments on translationMachineDraft, navLinks, signInLink and
footerProjectLinks are gone with the false ones they replaced; the footer
Product, Docs and Legal functions keep the comments they had on main; the nav
header comment changes "five links" to "the page links" and adds nothing else.
The header order and footer columns are listed above.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:06 -08:00
CodeWhale BotandClaude Sonnet 5.5 9a4f0f3212 web: drop redirected and catalogue pages from the sitemap, release-true structured data (CW-8048 ROUTES)
Sitemap loses redirected routes, signin, features, docs/claude-code and the
component pages of ratatui, and gains lastModified from the build.
SoftwareApplication lists macOS, Linux and Windows, drops isAccessibleForFree,
takes the locale's home description and links the locale's install page.

Amended at landing: /docs/claude-code stays in the sitemap, as the DOCS-TRUTH
review asks, because DOCS-TRUTH rewrites that page in this same stack.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:04 -08:00
CodeWhale BotandClaude Sonnet 5.5 566ff5bf0d web: redirect repeated pages to the page that does the job (CW-8048 ROUTES)
One table (lib/route-redirects.ts) feeds the middleware: community, feed,
roadmap, digest, product, runtime, constitution and enterprise answer 308 to
contribute, contribute#milestones, changelog, the home, docs/runtime-api,
docs/constitution and faq#teams, bare and under every locale. download and
desktop stay 307 and go to /app only when the desktop release object parses.
The eight page files and the digest, enterprise and runtime dictionaries are
deleted; the constitution page body moves to docs/constitution, which used
to redirect back to it. The weekly digest schedule and the digest task of
/api/cron are removed; facts-drift and link-check schedules stay.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 20:37:03 -08:00
CodeWhale BotandClaude Sonnet 5.5 ecfd8afaf9 feat(web): put the locked line, belief, six sections and product map in the dictionaries (CW-8048)
heroTitle, heroIntro, metaTitle and metaDescription change in place; heroTitleLead
is the headline's first sentence and every locale's heroTitle starts with it
(new dictionaries test). New keys carry the install note, both capture captions,
the one coordinator-authorised sentence for frames that hide the model name and
posture, the five frame labels, the belief, the six section headings and
bodies, "One engine", the four state words and the six map parts with their
sentences, in all 18 locales. Chinese needs a person's review before deploy;
the other 16 are machine drafts with no native review yet.

lib/content/product-map.ts computes each row's state from surfaceState(),
the capability registry and the desktop release object, never typed; one
session is always "Being built". Catalogs regenerated locally (export, no
paid service).

Checks: dictionaries, gt-site and product-map tests 28 pass; npm test 85 files,
1283 tests pass; check:locales, gt check, tsc, lint, check:facts pass.

Amended at landing: adds the CHANGELOG line under Changed, and removes the two
new doc comments on heroTitleLead and installNote in types.ts (comment-only).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:37:02 -08:00
CodeWhale BotandClaude Sonnet 5.5 7aa3b0e06d docs(web): amend the copy rules for the locked line and rewrite the positioning note (CW-8048)
COPY_STYLE: a headline may be two sentences up to ten words, "we" is allowed in
the belief and in sentences about direction, "Being built" is a fourth state
label, the belief is exempt from the two-sentence limit, and rule 11 now bars a
desktop download offer only until the desktop release object is true.

MESSAGING now holds only the line, subhead, belief, six sections, the labelled
product map with its state rule, the churn rule and a translator note.
AGENTS points reviewers at the churn rule.

Amended at landing: COPY_STYLE rule 13 and the MESSAGING state paragraph now
write the locked state words "In development, no download" instead of
"In development (no download)".

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:36:59 -08:00
CodeWhale BotandClaude Opus 5.5 6e9879778d chore(changelog): sync the TUI changelog slice for the DSH-05 landing (CW-7964)
Output of `bash scripts/sync-changelog.sh` after the DSH-05 picks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:32:32 -08:00
CodeWhale BotandClaude Opus 5.5 7928ab1c62 feat(extension-host): serve DSH subagents and agents through the adapter table (CW-7964)
Moves the inline SubagentsShim and AgentsShim out of root.ts into
src/dsh/agents.ts as two DshAdapter entries registered in DSH_ADAPTERS.
Behaviour is unchanged: startActivation still needs the exact live calling
agent as parent, refuses a delegated caller or a call without its own
subagent ticket (AdapterHost.liveInvocation is not treated as
authorization), seals the agent/created window, and forwards owner, ticket
and child id on subagent/start, result and dispose. The test harness learns
subagent/* requests, and a new host test covers a consumer-shaped agent
team: persona and tool filter reach subagent/start, result settles, dispose
is sent, and delegated, ticketless, forged-parent and invalid-id calls are
refused before reaching the core. dist/ regenerated by npm run build (a
second build is byte-identical).

Checks in crates/engine/extension-host: typecheck exit 0; npm test 424
tests, 417 pass, 0 fail, 7 skipped; npm run test:bun 418 pass, 6 skip,
0 fail across 424 tests.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:32:31 -08:00
CodeWhale BotandClaude Opus 5.5 4c249a6d86 feat(runtime): durable Fleet member starts with a read route (CW-7964)
AUD-MEMBER gaps G1-G6, built on the job-operations store and lock rather
than a second store. A member start is reserved in the Runtime store under
sha256(owner, thread, operation_key) with a pre-minted agent id before the
Engine is asked; the spawn uses that id, persists the worker before launch,
and the reservation becomes accepted only once the persisted worker proves
it (owner session = thread, worker id, requested profile = member). Exact
replay returns the original receipt after a timeout or a restart; a
different body is 409 member_operation_mismatch; a key under another
thread is 404; expected_store_id pins the store (409
member_operation_store_changed); an optional request_fingerprint must match
the server's; the body is capped at 2 MiB. A timeout answers 202 with an
unknown receipt and settles in the background; an Engine refusal withdraws
the reservation; a reservation from an earlier Engine generation with no
worker is settled as abandoned. GET
/v1/threads/{id}/members/operations/{key} reads the receipt. Delete and
discard refuse a thread whose start is still in flight. Capability
thread_fleet_member_durable_operations_v1; the existing opaque 1-128
character key is kept.

Touches outside the brief's exact list (recorded in the review packet):
tools/subagent/mod.rs (reserved_agent_id on FleetMemberStart and
SubAgentSpawnOptions, durable registration for reserved spawns),
app-server-protocol capability + regenerated schema/TS, route registration,
docs/RUNTIME_API.md section, regenerated RUNTIME_ROUTES.md and
agent-docs.generated.json.

Checks (farm run 20261111T014308Z-dsh05-members-738d66, base f3f09e4421):
cargo test --locked -p codewhale-engine -p codewhale-app-server --lib --
member extension_host discard delete_thread: app-server 3 passed 0 failed,
engine 270 passed 0 failed. Local: cargo test -p
codewhale-app-server-protocol --all-features 22 passed 0 failed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 20:32:29 -08:00
CodeWhale BotandClaude Fable 5.2 a37d7fdd27 fix(engine): gate the external-agent option test helpers to unix so Windows compiles (CW-7879)
Five helpers in runtime_threads/tests/external_agent.rs (options_fixture,
received, pid_alive, refusal_code, options_thread_request) are called only
from tests that are #[cfg(unix)]. On Windows they were dead code, and the
workspace compiles tests with -D warnings, so 'Check every workspace target
compiles on Windows' has failed since 10ca1deee0 landed in cf4464f72a:
'could not compile codewhale-engine (lib test) due to 5 previous errors'.
It was caught on a contributor's pull request, whose required Windows check
failed for a reason it did not cause.

The helpers now carry the same gate as their callers. On unix the compiled
code is unchanged.

Checks on this commit: rustfmt clean, preflight OK. The Windows compile is
proved only by hosted CI on this commit; no Windows build was run locally.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 20:15:17 -08:00
CodeWhale BotandClaude Fable 5.2 77bdd783f4 docs(runtime): regenerate the route table for the external agent options route (CW-7875)
docs/RUNTIME_ROUTES.md is generated from the literal route registrations in
crates/app-server/src/runtime_api.rs. The ACP options route landed in
10ca1deee0 without regenerating it, so check-runtime-routes has failed on
main since cf4464f72a. Regenerated with
node web/scripts/check-runtime-routes.mjs --write: one row added
(GET /v1/agents/external/{id}/options), 238 paths.

Checks on this commit: check-runtime-routes PASS (238 paths), check-facts OK,
preflight OK.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 20:12:42 -08:00
CodeWhale BotandClaude Opus 5.5 4133ed98ec chore(changelog): sync the TUI changelog slice for the DSH-16 seam landing (CW-8012)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 19:12:22 -08:00
CodeWhale BotandClaude Opus 5.5 565c26f237 feat(extension-host): one ticketed dsh/broker envelope and refusing broker table (CW-8012)
DSH-16-SEAM, the first slice of DSH-16, implementing COORDINATOR-RULINGS
2026-11-11 §1 (option A).

- protocol.rs: one host-to-core row `dsh/broker` with strict params
  {owner, ticket, service, method, input}; `DshService` is the closed
  service list (workspace, llm, network, settings, catalog, terminal,
  remote). `service` is a string on the wire so an unknown name gets a
  typed refusal instead of a parse violation that ends the host. The
  service list is generated into protocol.generated.ts as DSH_SERVICES.
  `tool/call` gains optional `broker_tickets: [{service, ticket}]`.
- core_call.rs: an in-session extension tool invocation mints one ticket
  per service (ticket method `dsh/broker:<service>`, target
  {call_id, session_id}), revoked with the invocation, owner and host like
  every other invocation ticket. `serve_broker` redeems through the same
  `redeem_invocation` as core/call (owner, tier, host generation,
  lifetime, 50-use cap, current owner, selection), takes the session from
  the invocation and never from the host, then dispatches through the
  broker table, rechecking owner and selection afterwards.
- dsh_broker.rs: the Rust table (one arm per service), a typed method
  decode helper, and typed refusals carried in data.dsh_refusal:
  unknown_service, unknown_method, invalid_input, not_available,
  ticket_refused, cancelled. Ticket refusals stay one code with the
  existing generic message (no field oracle).
- dsh_{workspace,llm,network,settings,catalog,terminal,remote}.rs: stubs
  that refuse not_available; each leaf (DSH-06..13) fills only its own.
- mod.rs: module declarations and the one dispatch arm; supervisor.rs:
  the not-served fallback arm.
- extension-host: AdapterHost.broker(ctx, service, method, input) sends
  dsh/broker with the live invocation's ticket for that service and
  refuses locally (NotAvailable, nothing sent) without one. root.ts wires
  it and carries broker_tickets on ToolInvocation. dist rebuilt.

Known limits, kept here rather than in a module comment (no new code
comments): every service arm refuses not_available until its leaf
(DSH-06..13) fills its own module and adds the service to
compatibility.json provided_services; BrokerCall carries only owner,
the ticket's session, service, method and input, so a leaf that needs
the invocation's tool context or the nested-call gate asks for it to be
extended in dsh_broker.rs and core_call.rs; tickets are minted for every
service on every in-session extension tool invocation, and per-plugin
service grants are each leaf's to check before it acts.

Nothing new is advertised: compatibility.json provided_services is
unchanged. No route through core/call by tool name.

Outside the brief's exclusive paths, needed to compile and deliver
tickets: tool.rs (one line putting broker_tickets in tool/call),
extension_host/tests.rs (three `broker_tickets: None` literal lines),
root.ts (ToolInvocation field, identity field and population), new
protocol corpus fixtures 67-74, test/adapters.test.mjs (one test).

Evidence (farm run 20261111T011045Z-DSH-16-SEAM-55ba6a, base f3f09e4421):
cargo test --locked -p codewhale-engine --lib -- extension_host:
176 passed; 0 failed; 0 ignored; 6965 filtered out.
extension-host: npm run typecheck clean; npm run build exit 0;
npm test 440 tests, 433 pass, 0 fail, 7 skipped;
npm run test:bun 440 tests, 434 pass, 0 fail, 6 skip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 19:12:14 -08:00
CodeWhale BotandClaude Opus 5.5 ef9ec08cbc chore(changelog): sync the TUI changelog slice for the DSH-04 and installer landing (CW-7875)
Regenerated with scripts/sync-changelog.sh after adding the CW-7959 and
CW-8036 lines to the root CHANGELOG.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:49:58 -08:00
CodeWhale BotandClaude Opus 5.5 e6e3d79582 chore(web): regenerate the agent docs after the reviewed-items landing (CW-7875)
origin/main cf4464f72a changed docs/RUNTIME_API.md, docs/CONTRIBUTORS.md and
docs/RELEASE_CHECKLIST.md without regenerating web/data/agent-docs.generated.json,
so web/scripts/check-facts.mjs failed with "generated agent docs are stale".
Regenerated with node web/scripts/derive-facts.mjs; no hand edits. Only those
three docs' entries change.

check-facts: OK after regeneration.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:49:57 -08:00
CodeWhale BotandClaude Sonnet 5.5 083f18c045 docs(install): say the PATH edit starts with 0.10.3 archives; v0.10.2 archives need the manual block (CW-8036)
The v0.10.2 archive install.sh takes no options and never edits a profile, and its install.ps1 only tells the user to add the directory. Qualify the archive and Windows claims in INSTALL.md and zh_hans/INSTALL.md, point v0.10.2 readers to adding the three lines by hand, and regenerate agent-docs.generated.json. Checks: check-docs PASS, check:facts OK, git diff --check clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:44:23 -08:00
CodeWhale BotandClaude Sonnet 5.5 368a2b3407 fix(installer): make the printed PATH undo safe on BSD sed at end of file (CW-8036)
The undo sed used N;N, which on BSD sed quits without printing at EOF, so a lone start marker
within two lines of the end dropped the marker and the next line before the profile was
overwritten. Use {$!N;$!N;...} in both installers, INSTALL.md and zh_hans, regenerate
agent-docs, and add three lone-marker cases per installer (71 install-safety tests pass).
Also drop the new code comment lines from the installers.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:44:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 b066e6f996 feat(installer): add the marked PATH block to the shell profile by default, with an opt-out (CW-8036)
Founder decision 138.7: the Unix installers (web/public/install.sh and the
archive install.sh) append the three-line "# >>> codewhale PATH >>>" block to the
detected shell's profile when the install directory is not on PATH, print the
file, the lines and a portable sed command that removes exactly that block, and
skip it with --no-modify-path or CODEWHALE_NO_MODIFY_PATH=1. Unknown options now
fail instead of being ignored.

Safety: only the block is appended (newline-safe); nothing is edited when the
directory is already on PATH, the profile is a symlink or not a writable regular
file, its folder is missing, SHELL is empty or unrecognised (fish prints
fish_add_path), or the directory name has shell-special characters. A reinstall
sees the marker and leaves the file as is. zsh honours an absolute ZDOTDIR. The
end-of-output block (export for this shell, then --version) is unchanged, and the
uninstall sentence stays gated on the installed binary reporting an uninstall
command.

install.ps1 adds the install directory to the user PATH (HKCU Environment Path,
REG_EXPAND_SZ preserved) by default, prints how to undo it, skips with
-NoModifyPath, and never duplicates the entry. Not run on Windows.

Evidence: install-safety.test.js 65 pass / 0 fail (adds default add for zsh, bash
and sh, fresh profile, undo command, flag and env skip, unknown option, already
on PATH, reinstall, symlink, unwritable, undetermined shell, for both
installers); public-installer-contract.test.ts 33 pass; assemble-release-assets
7 pass; check:facts and check-docs pass. A clean Ubuntu 24.04 container run with
a fixture release: bash and zsh, web and archive installers, new non-login
interactive shell finds codewhale with no manual step, reinstall keeps one block,
--no-modify-path leaves the profile alone, GNU sed undo verified.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:44:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 c1b8a81e55 feat(extension-host): sessions and systemPrompt as one reviewed adapter, with its own checkpoint ticket check (CW-7959)
Replaces the inline SessionsShim and SystemPromptShim in root.ts with
src/dsh/session-prompt.ts registered through DSH_ADAPTERS. sessions.flush
now requires a non-empty checkpoint ticket and the invocation session itself
instead of relying on AdapterHost.liveInvocation. Behaviour is otherwise
unchanged: systemPrompt stays child-agent-only.

No dsh_sessions.rs and no `mod dsh_sessions;` line: by coordinator ruling
(CW-7875) the module is dropped from DSH-04, because with only
session/checkpoint available it would be empty dead code. systemPrompt
stays child-agent-only, as reviewed.

extension-host: typecheck clean; npm test 427 tests, 420 pass, 0 fail, 7 skipped;
test:bun 421 pass, 6 skip, 0 fail.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:44:18 -08:00
CodeWhale BotandClaude Opus 5.5 cf4464f72a chore(changelog): sync the TUI changelog slice for the reviewed-items landing (CW-7875)
Regenerated with scripts/sync-changelog.sh after the LEG-04, EA-04,
ENG-UNDO and PROV-7252 CHANGELOG edits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 18:22:07 -08:00
CodeWhale BotandClaude Sonnet 5.5 aced082f0e fix(engine): encode the Anthropic object-form tool_choice name
The encoding fix 0d1ffc1cb2 covers tool declarations, replayed tool_use
blocks, string tool_choice and decoding, but an object tool_choice
({"type":"tool","name":"web.run"}) still went out raw and would 400 against
^[a-zA-Z0-9_-]{1,128}$. It now goes through to_api_tool_name (function-shaped
choices are normalised to the Anthropic tool shape). Adds one regression test
covering every outbound name and both decode paths. Refs #7252.

Evidence: farm cargo test -p codewhale-engine --lib client::anthropic: 38
passed, 0 failed; the new test failed (0 passed, 1 failed) before the fix.

Landing: the existing 0.10.3 CHANGELOG entry from 0d1ffc1cb2 now covers the
forced object-form tool_choice, cites #7252 and thanks the reporter
@ychu824; no second entry. @ychu824 is credited in the 0.10.3 CHANGELOG
Contributors block, docs/CONTRIBUTORS.md (v0.10.3 band) and RELEASE_HELPERS
in web/lib/release-credits.ts. AUTHOR_MAP is not needed: the reporter
authored no commit. #7252 stays open.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:53 -08:00
CodeWhale BotandClaude Sonnet 5.5 ca3c2fd5be docs(changelog): remove the 0.10.3-beta.2 and App Server v2 claims
The work-in-progress paragraph still named beta.2 as a near-term
prerelease and kept App Server v2 in 0.10.3. Decision 96 dropped beta.2
and decision 124 paused v2. The paragraph now says the next release is the
full 0.10.3, v2 is paused, legacy JSON-RPC is not retired, the coarse
split and DSH all-33 stay, and nothing is declared complete.
crates/tui/CHANGELOG.md regenerated with scripts/sync-changelog.sh;
--check passes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:51 -08:00
CodeWhale BotandClaude Sonnet 5.5 839ba08856 docs(release): drop the 0.10.3-beta.2 example and the no-worktree rule
The prerelease checkpoint section named 0.10.3-beta.2 as its example, but
section 96 dropped that release: the next Engine release is the full
0.10.3. Use a neutral 0.11.0-beta.1 example, keep the generic prerelease
path, and say 0.10.3 does not use it. The checklist and runbook still told
agents to avoid worktrees; section 93 has agents build in short-lived
worktrees and land on main, with freeze, tag and publication from the
clean canonical main.

Prose only. Checks: git diff --check clean; scripts/sync-changelog.sh
--check up to date; relative links in both files resolve. No Rust build.
COARSE_SPLIT.md left to REF-01 (patch proposal in the review packet).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:49 -08:00
CodeWhale BotandClaude Sonnet 5.5 b87cb4ad2e fix(undo): full-turn patch-undo restores in the default posture
The Runtime's patch-undo refused with restore_requires_trust whenever the
thread was not in trust mode or Full Access, so a default (Ask) session could
never undo a turn's files even though the TUI's /undo already does so. The
ownership attribution, preimage and later-edit proofs decide what may be
reverted, so the trust gate is removed from the helper, its public signature
and the error-code table; the TUI caller drops its constant argument. The
doc comment and two test doc comments the change made false are rewritten.

Checks (review rerun, farm 20261110T153702Z-undo-review-11b834, base
9731fbbf1c): cargo test -p codewhale-app-server -p codewhale-tui --lib --
patch_undo undo_that_cannot_restore restore_undo: app-server 10 passed,
0 failed; tui 9 passed, 0 failed. Not run: Windows /undo, a process-restart
journey, macOS native, hosted CI.

Landing: squashes 2cc2da435f and its fixup 8aa3ec56c8, and adds the
CHANGELOG line from the review packet.

Landing: rustfmt reflows the patch_undo_workspace_files call in
debug_operations.rs after the dropped argument (format only).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:47 -08:00
CodeWhale BotandClaude Sonnet 5.5 f24a6e88be chore(engine,app-server): drop new code comments from ACP options slice, add CHANGELOG (CW-7879 EA-04)
Review repair: removes the 25 doc/comment lines the first commit added
(module doc in external_agent_options.rs, item docs in fleet/acp.rs and
manager/external_agent.rs) per the no-new-code-comments rule. The fixture
header in acp-options-agent.mjs follows the existing acp-agent.mjs
convention and stays. Adds the CHANGELOG line for the new route.

Reasoning that lived in the removed comments:
- GET /v1/agents/external/{id}/options?cwd= answers with the agent's own
  session/new offer, read on a throwaway process stopped before return.
  Nothing comes from the Codewhale provider catalog.
- Known limitations: probing starts a real agent session in cwd, which an
  agent may keep in its own history. The route is gated like the rest of
  the Runtime API and does not tie cwd to a thread owner.
- ControlWrite is the one ACP method that writes a control, so a choice is
  never sent through a different door than the agent offered it on.
- Choices are the only values a caller may write; names and descriptions
  are display text only. Over-limit or control-character values are
  dropped, never truncated into authorization input.
- A config option that names a model or mode owns that control; the legacy
  models/modes blocks fill only what no config option claimed.
- Usage is what the agent reported on the prompt answer; absent fields are
  unknown, cost is always unknown.

Shared-file touches runtime_api.rs (+5: mod and one route) and
runtime_api/tests.rs (+2: mod) are the minimum to register the route;
the integrator should accept them explicitly.

Known follow-up: apply_choices still sends a blind set_config_option
("model") when no controls parsed, including when every configOptions
entry was malformed; the agent validates and a refusal is typed
choice_rejected. A follow-up can require malformed_controls == 0.

Checks: clippy -D warnings on engine and app-server, run after this edit.

Landing: rustfmt drops the blank first line the comment removal left in
external_agent_options.rs (format only).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:44 -08:00
CodeWhale BotandClaude Sonnet 5.5 10ca1deee0 feat(engine,app-server): ACP options route, typed control refusals and agent-reported usage (CW-7879 EA-04)
GET /v1/agents/external/{id}/options?cwd= answers with the agent's own
session/new offer (models, modes, config options, capabilities) read on a
throwaway process that is stopped on every path, plus which Codewhale
actions it carries. A saved model choice is validated against the complete
offer at setup and written through the method it was offered on; a choice
the agent does not offer or no longer offers, and per-turn controls an
agent cannot honor, are stable typed refusals instead of strings.
Agent-reported usage is recorded as turn.agent_usage events: context
window, a per-turn token total, cost unknown, and unknown (not zero) when
the agent reports nothing. A started agent process is now owned by a guard
that stops it on error or panic and logs a failed stop.

Checks (Boat farm, CARGO_INCREMENTAL=0): engine --lib external_agent
fleet::acp 20 passed 0 failed; app-server --lib external_agent 3 passed
0 failed; clippy -D warnings engine+app-server --all-targets clean.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:41 -08:00
CodeWhale BotandClaude Sonnet 5.5 3da52ab015 test(plugins): bind DSH refusals to the pinned basis, drop self-asserted engine provenance (CW-7051)
Refused rows now require the Engine message to carry the DSH-00 pinned
peerDependencies refusal; the validator recomputes the match. --engine-kind is
required, source_sha is removed, and dev builds cannot yield an accepted
verdict. Selftest 15 passed (wrong-reason and dev-build cases added); pins 33 ok.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 f623d362b4 test(plugins): pinned DSH plugin acceptance driver and receipt validator (CW-7051)
One stdlib driver runs a pinned row's lifecycle L through the installed
Engine's /v1/apps/plugins routes (no second loader), writes a receipt, and a
validator recomputes the verdict from the pins: a missing required step,
zero-count PASS, evidence-less PASS, unnamed UNAVAILABLE, source-build engine
or hash mismatch is INVALID; provider/desktop gaps are UNAVAILABLE, never PASS.
Checks: selftest 13/13, pins 33/33 hashes, row 1 real run on macOS.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:35 -08:00
CodeWhale BotandClaude Sonnet 5.5 c71f0efefb fix(tui): bound @mention directory and line-range scan work (CW-7875)
Recovers the scan-effort guarantees from 51f321 on the current owner:
directory enumeration stops after 10,000 entries (listing marked
truncated, "more exist" only when entries remain), and a ranged mention
past the byte prefix stops after 64 MiB of scanning, reported truncated
with no text instead of beyond-eof. Real EOF still reads beyond-eof.
Multi-range and CJK mention fixes untouched.

Checks: cargo test -p codewhale-tui --lib -- file_mention:: 46 passed, 0 failed.

Landing: CHANGELOG line added from the review packet; the new doc comments
on the two scan constants and the new test are dropped (no new code comments).
The rewritten directory_listing, read_file_content and
read_line_range_past_prefix docs stay: they replace comments the change
made false.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:33 -08:00
CodeWhale BotandClaude Sonnet 5.5 1daa4aa6db test(engine): prove the side-browser account fence admits and refuses (CW-7841)
Adds a test-only account-epoch seam to profile_constitution.rs (like
TEST_ACCOUNT_LOAD_FAILURE) and one regression for admit_desktop_browser_call:
no fence refused, a changed epoch refused (A to B, B back to A), an unreadable
store refused, an unchanged epoch admitted, a newly admitted turn under the new
account admitted, and non-browser tools unaffected throughout.

Checks: codewhale-engine --lib desktop_browser_call_is_fenced 1 passed, 0 failed
(7142 filtered). Negative control with the fence check short-circuited to Ok:
the same test fails ("call must be refused"). No user-visible change, so no
CHANGELOG line. The live A-to-B-to-A proof (cw7841-fence-proof run.sh) was not
run here.

Landing: the two-line doc comment on pin_account_epoch_for_test is dropped
(no new code comments).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 18:21:28 -08:00
CodeWhale BotandClaude Sonnet 5.5 5cc8f8a8fd web: show a capability only when the published release has it, with counts at the tag (WEB-FACTS-C)
Every capability the site lists as available is now keyed to a docs/features.toml row and gated by the published Engine release.

- scripts/release-baseline-lib.mjs reads the registry, provider descriptors, model catalog and tool implementations at the published tag with git show and commits the result as data/release-baseline.json. derive-facts refreshes it when the tag is in the checkout and keeps the committed copy otherwise; check-facts fails when the baseline is for another tag or differs from the tagged source. Tag v0.10.2: 78 registry rows, 50 provider routes, 91 catalog models, 79 tool implementations.
- lib/capabilities.ts decides released, in_source or hidden per row: since above the release (or unreleased) is in_source, a row with no recorded since is judged by whether it is in the tagged registry, an unknown release hides everything. Released rows show the wording the tag had. A claim that describes the current row (Accept Edits as the default) is held back when the row's wording changed since the tag.
- Founder switches added, off by default: cloudComputersBeyondInvite (registry rows for cloud computers) and namedVendorAgentsOverAcp (rows naming other vendors' agents over ACP); plansPublished now also hides rows that describe Codewhale plans or pricing.
- New typed surface state in_source with copy in 18 locales (own drafts, need native review). /features and /features.json list only released and in-source rows, /product keys its work patterns, modes and permissions to rows, and structured-data featureList uses the same gate.
- Counts print the tag's values with what they count: provider routes, catalog models, tool implementations. Home, /models, /runtime, llms.txt and the FAQ read them; main-only models are no longer listed; a count that cannot be computed omits its sentence. /api/facts gains releaseCounts without changing existing fields.
- lib/capabilities.test.ts (402 tests incl. a per-file scan that no dictionary or content module carries a literal count).

Checks from web/: npm run check exit 0; check:locales PASS; tsc 0; lint 0; npm test 84 files, 1266 tests passed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 17:26:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 a34117942a chore(changelog): sync the TUI changelog slice for the website facts landing (CW-7933)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:50:30 -08:00
CodeWhale BotandClaude Sonnet 5.5 b0378f9b22 fix(installer): regenerate agent docs, translate zh_hans PATH note, test installer endings (CW-8030)
Review repairs: web/data/agent-docs.generated.json regenerated so check:facts passes; the zh_hans INSTALL PATH note is translated and mirrors the release-archive "last output" clause; install-safety.test.js now asserts the last three lines of both installers and that the macOS Computer Use notice precedes the PATH block (37 pass).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:50:26 -08:00
CodeWhale BotandClaude Sonnet 5.5 fe4548cdf1 fix(installer): end the output with the PATH command and the verify command (CW-8030)
When PATH does not select the install, the last lines the installer prints
are now the copy-paste `export PATH=...; hash -r` line and
`codewhale --version`, after the persistent profile line. When PATH already
selects it, the last line is `Next: codewhale --version`. The Computer Use
notice moves above the PATH block and is printed on macOS only. Both
installer copies change; the installer still never edits shell profiles.

Checks: web installer contract + static installer + public surface tests 45
passed; scripts/release/install-safety.test.js 33 passed. Clean Ubuntu 24.04
container, non-root user, v0.10.2 download: output ends with the export line
and `codewhale --version`; pasting the line then `codewhale --version`
prints `codewhale 0.10.2 (ff466b2ed0)`.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:47:18 -08:00
CodeWhale BotandClaude Sonnet 5.5 b0a1c8c00c fix(installer): print uninstall and update-mode lines only when the installed binary supports them (CW-8029)
Both installers printed `codewhale uninstall --yes`, the PATH-block marker
sentence and the `update.mode` sentence unconditionally. Releases before
0.10.3 have neither command nor key, and an unknown first word starts a chat
that sends it to the model. The installer now asks the binary it just
installed (`--help` lists an `uninstall` command) and otherwise prints the
manual removal. `update.mode` shares the same gate: both shipped together.
INSTALL.md and its zh_hans copy say "0.10.3 and later" and give the manual route.

Proof: install-safety.test.js 37/37 (4 new, website and archive, stub help
with and without uninstall); web vitest installer contract files 39/39;
Docker ubuntu:24.04 arm64: real v0.10.2 asset prints the manual removal and
no uninstall or update.mode text, a main-source build prints the uninstall line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:47:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 285db03a2a web: typed surface states, founder switches, desktop download gate (WEB-FACTS-B)
Each surface's status is now a typed state (released, available, beta, dev,
open, invite_only, not_open, not_public) looked up from the chrome dictionary
in all 18 locales instead of free text. Cloud invitations and plan publication
are committed values in lib/founder-switches.ts, default off, with the decision
beside each and no environment lookup. The desktop row shows a download only
when data/desktop-published-release.json holds a signed, notarized macOS asset
with a checksum (the object is null today). The partner strip expiry uses
lib/expiry.ts at render time. Snapshot numbers carry an "as of" title and
accessible label.

Checks (web/): check 0, check:locales PASS, tsc 0, lint 0, npm test 83 files
864 tests pass, offline build 0. Not deployed; not pushed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:45:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 3b0634321c web: drop new comments, print the release read date, soften asset-only drift (WEB-FACTS-A)
Review repairs for 064b1a38b6.

Comments removed per the no-new-comments rule; their reasoning lives here:
- withBuildProvenance: next.config.ts stamps revision, commit time and
  generatedAt in one step. They apply together or not at all, so a fresh
  revision never sits beside the tracked file's older date, and a partial stamp
  leaves the tracked snapshot's own consistent trio untouched.
- RepoStats: a number that could not be read is null, never 0 and never a
  floor. source says whether values were read live or come from the committed
  snapshot; fetchedAt is when they were read (null when nothing was).
- releaseAssets: the checked-in release record also carries each asset's name,
  size and sha256 digest plus the read time. Only the four core fields are
  mirrored to docs/ and the cloud facts.

llms.txt now prints "read YYYY-MM-DD" beside the latest published release, so
the offline build says when the release was read.

check:latest-release no longer exits 1 when only the asset list differs and
the tag, version and URL match: it warns (and emits a GitHub Actions warning)
and exits 0. Verified by perturbing one asset size locally: warning, exit 0.

Checks: vitest facts-invariants, github, sync-latest-release,
public-surface-contract 4 files 62 tests pass; tsc --noEmit exit 0;
sync-latest-release --check exit 0; check:facts OK.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:45:17 -08:00
CodeWhale BotandClaude Sonnet 5.5 fae936a8c5 web: read facts from published truth, never 0 or a floor (WEB-FACTS-A)
Published release: data/latest-published-release.json now also carries the
asset list (name, size, sha256) and the time it was read, written by
sync-latest-release.mjs; docs/ and cloud-facts mirrors still get the four
core fields only. Unknown renders nothing: RepoStats numbers are null when
unreadable, MIN_KNOWN_CONTRIBUTORS is removed, the production-build path
reads the committed repo-stats-snapshot.json (sync-repo-stats.mjs) instead
of returning zeros, and the browser fallback rejects a missing count rather
than defaulting it to 0. Provenance: next.config.ts stamps the generation
time with the revision, and withBuildProvenance applies revision, commit
time and generatedAt together or not at all. The desktop app is no longer
called native (availability row and app page, en and zh). The
SoftwareApplication structured data drops its price offer.

Invariant tests (lib/facts-invariants.test.ts, 18): installed-version line
equals the published-release object; no price, currency amount or plan name
on 19 pages in en and zh, in structured data or in the sitemap; home and
/contribute render with every GitHub call failing; a failed read is never 0.
Checks from web/: check 0, check:locales 0, tsc 0, lint 0, npm test 82 files
805 tests pass, offline build passes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 16:45:15 -08:00
CodeWhale BotandClaude Sonnet 5.5 f3f09e4421 changelog: drop duplicate exec prompt input line left by the rebase
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:54 -08:00
CodeWhale BotandClaude Sonnet 5.5 3790c538a7 docs: ACP sessions have no plugins, DSH or MCP attached yet (no release promise)
Founder decision: attaching them is in progress; state the current fact only.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:52 -08:00
CodeWhale BotandClaude Sonnet 5.5 82a5774e42 web: regenerate agent docs after the EXTENSIONS.md ACP wording
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 47f0a35e3e docs: ACP sessions do not run plugins, MCP or DSH; changelog line
Landing additions for the ACP server slice.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:18 -08:00
CodeWhale BotandClaude Sonnet 5.5 a0b59be23e fix(acp): refuse unsupported prompt blocks, relative cwd; classify write/edit tools; add stdio-style journey test (CW-7941)
The ACP server advertises image=false but silently dropped image/audio/blob
prompt blocks while running the rest of the prompt; it now answers invalid
params naming the block type. session/new and session/load accept only an
absolute cwd (ACP requires it; session/list already did). Permission requests
and tool-call cards for the real tool names write/edit/read carried kind
"other"; they now carry edit/read so editors render and gate them correctly.

capture_frontend and CapturedAcpFrontend::serve become pub so a protocol-level
journey test can drive the real server loop over line-framed duplex streams:
initialize, refusals, new, permission allow, cancel, list, load with replay,
and a prompt in the loaded session whose model request carries the saved history.

Checks (Boat farm): cargo test --locked -p codewhale-app-server --test acp_journey: 1 passed; 0 failed.
--lib acp_server with RUST_MIN_STACK=16777216: 38 passed; 0 failed (790 filtered out).
codewhale-core --test single_turn_loop: 14 passed; 0 failed. Journey runs in-process over duplex
streams with a mock model on a current-thread runtime; no real editor was run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:17 -08:00
CodeWhale BotandClaude Sonnet 5.5 42b1972743 changelog: plugin activation names core-owned services
Landing addition. After the stack, npm run build leaves crates/engine/extension-host/dist clean;
npm test there: 423 tests, 416 pass, 0 fail, 7 skipped.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:15 -08:00
CodeWhale BotandClaude Sonnet 5.5 39a240bc8b feat(extension-host): one registration table for DSH services and a named adapter seam (CW-7051)
HostRoot now installs every service from a single table and refuses to
construct when compatibility.json's provided_services and the installed
services disagree in either direction, so a service can never be advertised
without a shim or shipped without being declared. Reviewed adapters plug in
through DshAdapter (src/dsh/adapters.ts, DSH_ADAPTERS, empty today) and an
AdapterHost facade (tier, rpc, ownerOf, liveInvocation, log); HostRoot takes
the adapter list as an optional third argument so tests can supply one.
liveInvocation applies the owner/active/abort/disposing checks the inline
shims repeat. An adapter that duplicates an inline service or names an
undeclared one is refused.

An inject of a core-owned service the host does not provide (llm, credentials,
fs, ...) now says the Codewhale core owns it and no reviewed bridge exposes it
yet. No service, protocol method or compatibility entry is added, so
compatibility.json, the protocol table and generated parity are unchanged.

Checks: tsc clean; npm run build reproducible; npm test 416 pass / 0 fail / 7
skipped; bun test 417 pass / 0 fail (6 skipped counted in baseline); cargo
extension_host::protocol 4/4; extension_host 173 pass, 1 timing flake under
load that passes alone.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:14 -08:00
CodeWhale BotandClaude Sonnet 5.5 e203847684 fix(plugins): flag concatenated client specifiers as computed, drop doc comments, add changelog (CW-7054)
Review repairs for 4619f6d030: require/import whose quoted literal is followed by anything but ) or , is now a computed specifier (concat and concat-import cases added to the regression loop); removed the two new doc comments; the sibling-package refusal no longer says it leaves the package; CHANGELOG line for the client review outcome and client_closures.
Checks: codewhale-plugins --lib install:: 38 passed, 0 failed.
new URL(import.meta.url), import.meta.resolve and new Worker are still unscanned; CW-7704 must serve only listed, hash-matching files.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:55:12 -08:00
CodeWhale BotandClaude Sonnet 5.5 6a96c2983c feat(plugins): retain and review the dsh.client closure in the DSH importer (CW-7054)
The importer no longer drops `dsh.client` with a blanket "not converted"
note. For the root package and every selected nested package that
declares a client, it resolves the client entry (exports["./client"]),
walks the static require/import closure over the bytes already copied
under source/, and records path plus SHA-256 for every file in
CONVERSION.json (`client_closures`), DshConversion and a `client`
outcome. Host-provided modules (react, react-dom, @deepseek-ai/*) are
listed, not resolved. Ambient npm packages, Node built-ins, URL or
absolute specifiers, escapes from the package and computed
require/import make only the client half skipped, with the exact
reason; the server half still imports. Serving stays refused by the
App Server route until its bootstrap admits the closure.

Evidence: codewhale-plugins --lib 208 passed, 0 failed, 1 ignored
(new regression dsh_client_closure_is_retained_hashed_and_refuses_ambient_dependencies
included); the `dsh` filter selects 7; clippy -D warnings clean.
Probe over the 36 survey packages (temporary test, removed): 19
retained, 5 skipped with reasons, the rest refused earlier by existing
server-side gates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:53 -08:00
CodeWhale BotandClaude Sonnet 5.5 b6c20fb2ae changelog: exec prompt input line without tracker id
Landing addition. Reviewer Mac run: codewhale-tui --test integration exec_:
23 passed; 0 failed (101 filtered).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:50 -08:00
CodeWhale BotandClaude Sonnet 5.5 b2adfe48ba fix(exec): working stdin note, accurate doc comment, CHANGELOG line (CW-7891)
The grace-expiry note advised --prompt-file -, which clap rejects next to a
prompt argument. It now suggests piping the prompt together with the slow
producer, which does work. The resolve_exec_prompt doc comment no longer
claims a pipe always reaches the prompt. The reader thread left behind after
a timeout is unchanged: nothing else in the exec path reads stdin.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:39 -08:00
CodeWhale BotandClaude Sonnet 5.5 67558c8a48 fix(exec): an open pipe no longer hangs an explicit prompt; typographic flags after the prompt are rejected (CW-7891)
With a prompt argument, exec waits 2 seconds for the first byte of piped stdin. If none arrives it runs on the argument alone and says so on stderr; any data is read to EOF and appended as before. A prompt-less exec still reads stdin to EOF.

The flag-after-prompt check now folds full-width ASCII, typographic dashes and a single em or en dash to the ASCII long flag, so an IME or autocorrected "--auto" is rejected instead of sent to the model as text. The exec help no longer says plain exec has no tools.

Checks on the Boat farm at base 733c34fb32: new exec_prompt_input subprocess tests 5/5; integration exec_ filter 23/23; cli+tui lib filter "exec stdin" 14 + 79 passed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:23 -08:00
CodeWhale BotandClaude Sonnet 5.5 aaabfeef64 changelog: pet browser view one-time code
Landing addition. Reviewer farm run tui lib pet_watch: 37 passed; 0 failed;
npm --prefix pet run check: 76 tests, 76 pass, 0 fail.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 baac9e1986 fix(pet): send the pairing code as X-Pet-Code from the generated browser page source
pet/public/shared.html is the source of crates/tui/src/tui/pet_watch/shared.html;
15456465ff edited only the generated copy, so 'npm --prefix pet run check' failed
on a byte difference and the next sync would have reverted the browser half of
the pairing change. Mirrors the one-line connect() change into the source.

Checked: cmp of source and copy identical; npm --prefix pet run check exit 0
(76 node tests passed).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:20 -08:00
CodeWhale BotandClaude Sonnet 5.5 fe4b8cbc21 fix(tui): open the pet browser view with a one-time code instead of the owner token
The pet owner now mints a 30 s single-use pairing code over an authorized
POST /v1/pair; the launcher opens the browser at that code, and shared.html
redeems it once at /v1/attach for the existing HttpOnly SameSite=Strict
cookie. The long-lived owner token no longer appears in argv or the URL.
Adds regression tests for authorized() (wrong, missing, cookie, Host and
Origin pinning) and for single-use and expiry of the pairing code.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:19 -08:00
CodeWhale BotandClaude Sonnet 5.5 ed8a0faa6d changelog: file-tree shortcut needs explicit Shift
Landing addition. tui lib file_tree_shortcut: 1 passed; 0 failed (reviewer farm run);
worker filter shortcut: 36 passed; 0 failed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:18 -08:00
CodeWhale BotandClaude Sonnet 5.5 27cfad7332 fix(tui): require explicit Shift for the file-tree shortcut
Windows crossterm 0.29 reports Ctrl+E with Caps Lock on as Char('E') with
CONTROL and no SHIFT, so plain Ctrl+E (composer end-of-line) opened the file
tree. The toggle now needs SHIFT in every form (Ctrl+Shift+E, Cmd+Shift+E).
The pinned test asserts the Caps Lock event shape no longer toggles and that
Ctrl+Shift+E still does.

Proof: file_tree_shortcut_does_not_steal_plain_ctrl_e fails on old code
(farm run, 0 passed 1 failed) and `cargo test -p codewhale-tui --lib shortcut`
passes with the fix.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:54:15 -08:00
CodeWhale BotandClaude Fable 5.2 5af8a9a245 chore(changelog): sync the TUI changelog slice after the web maintainer entry (CW-7933)
The Main landing gate has failed on main since 0481377c4c: the root
CHANGELOG gained the "Website maintainer" line and crates/tui/CHANGELOG.md
was not regenerated. This runs scripts/sync-changelog.sh, which adds that
one line.

Checked: bash scripts/preflight.sh --check -> preflight: OK (rustfmt, TUI
changelog slice, README translations 18 in sync, locale link symmetry,
dead-code budget 121 at budget, blocking-calls budget 658 sites within
budget, release-note receipts).

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 15:22:55 -08:00
CodeWhale BotandClaude Sonnet 5.5 0481377c4c fix(web): harden the web maintainer after review (CW-7933)
Explicit file allowlists for copy lanes plus a deny list for legal, constitution,
enterprise, partner, merch, availability, pricing, roadmap and project copy; PR
bodies list every changed file. Fail-closed bubblewrap probe before the model
runs; scope check now fails on ignored files and changed .git config or hooks;
web checks run in a fresh checkout with no token; publish mints the App token
only when a patch exists; shared action pins with version comments; every job
repeats the WEB_MAINTAINER_ENABLED gate. Adds publish-prs.sh, which the previous
commit left out because *.sh is gitignored. Test fixtures renamed from the
retired dispatch key; adds an overlapping digest generation test.

Checks: actionlint clean, tsc and eslint clean, vitest 81 files / 787 tests, next build ok. Workflow never run live.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:07:59 -08:00
CodeWhale BotandClaude Sonnet 5.5 1e45725ded feat(web): PR-only DeepSeek website maintainer, retire Worker drafting tasks (CW-7933)
Adds .github/workflows/web-maintainer.yml (disabled unless WEB_MAINTAINER_ENABLED=true): a deterministic derived-files lane and three codewhale exec lanes on deepseek-flash with a post-run path allowlist, web checks, and a separate App-token publish job opening one PR per lane. Removes the Worker triage, pr-review, stale, dupes and curate cron tasks, their prompts, dispatch KV plumbing and tests; digest, facts-drift and content watchers stay.

Checks: actionlint clean, tsc clean, eslint clean, vitest 81 files / 786 tests passed, next build ok. Workflow never run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:07:58 -08:00
CodeWhale BotandClaude Opus 5.5 a6bbf51093 docs(design): the public repo vendors only token files (CW-7930)
The vendored design copy holds only what the site and scripts read: tokens.json,
its generated tokens.css/.ts/.rs/.dtcg.json, generate.py and the four inputs
generate.py --check needs (whale/source/spec.json, figma/sync.template.js,
figma/variables.json, figma/sync.js). docs/design/DESIGN.md now says
so, and warns against vendoring the design system's documentation here.

Checks: vendor generate.py --check verified; scripts/export-design-tokens.py
--check up to date (158 tokens); grep for file keys and local paths in
vendor/: none; web vitest 793 passed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 15:07:56 -08:00
CodeWhale BotandClaude Sonnet 5.5 435c80e7e1 fix(web): drop the stale warm-paper hex from the token comment; format the changelog line (CW-7933)
Review fixes. Verified: tsc, eslint, check:locales, check:tokens, check-facts, vitest 793/793 in 81 files, build ok. Reviewed the plugins and 390px dark screenshots.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:07:38 -08:00
CodeWhale BotandClaude Sonnet 5.5 ad287f3f49 feat(web): move codewhale.net to the 5.3 design tokens and Geist (CW-7933)
Syncs vendor/codewhale-design to 5.3.0 with codewhale-design sync-to.sh, exports the water gradients from export-design-tokens.py, maps the site roles to the 5.3 light and dark tokens, self-hosts Geist and Geist Mono in place of Shannon Sans, and retires the warm-paper kit values. Radii follow the tokens (10/18/pill).

Checks: tsc, eslint, check:locales, check:tokens, check-facts clean; vitest 793/793 (81 files); next build ok; screenshots of /en, /en/models, /en/plugins, /en/app at 1440 and 390 in both themes reviewed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 15:07:35 -08:00
CodeWhale BotandClaude Opus 5.5 71478f76f8 chore(ci): budget the v2 contract generator and pinned Codex export (CW-7704)
The main landing preflight has failed on every landing since acc0e30f65
(CW-7704 V2-01) because two ratchets counted new sites that are correct
where they are:

- blocking-calls: crates/app-server-protocol/src/bin/contract.rs makes 5
  std::fs calls. It is a synchronous command-line codegen binary
  (`--write` / `--check`) with no async runtime, so tokio::fs or
  spawn_blocking would add nothing. Its sibling bin/export.rs is already
  budgeted the same way.
- dead-code: the 2 #[allow(dead_code)] in
  crates/app-server-protocol/tests/pinned/codex/common.rs are in the
  unmodified pinned upstream Codex export that V2-01's frozen contract
  reads byte for byte; editing it would break the pin. App Server v2 is
  paused (§124) and its scaffolding is kept.

Raised with each script's --update. `scripts/preflight.sh --check`: OK
(dead-code 121 at budget, blocking calls 658 sites within budget).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 14:17:42 -08:00
CodeWhale BotandClaude Opus 5.5 773861823d docs(web): accounts are open, cloud computers invite-only (CURRENT_DECISIONS §132) (CW-7933)
Founder, 2026-11-10: new Codewhale accounts are open; cloud computers stay
invite-only. The sign-in entry page, its metadata and llms.txt no longer
say "new accounts are not open"; they name GitHub, Google and email-code
sign-in and say cloud computers are invite-only. /docs/account and the
docs-auth account note (en, zh) drop the "people who already have an
account" framing, and /app's cloud row says invite-only.

Checks in web/: tsc pass; check:locales pass; check-facts pass; vitest 793
passed. Not deployed (deploy waits for the Worker GITHUB_TOKEN, §132.2).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 14:16:42 -08:00
596 changed files with 43791 additions and 8332 deletions

No files matched your search

+6 -1
View File
@@ -14,7 +14,8 @@ name: Main landing gate
# This job is deliberately narrow. It runs `scripts/preflight.sh --check`,
# which is the checked-in local preflight for exactly the class of breakage that
# reaches `main` unnoticed: rustfmt, the CHANGELOG slice, README locale stamps,
# the dead-code and blocking-calls budgets, generated-asset drift, and the
# the dead-code and blocking-calls budgets, generated-asset drift (including
# the website's generated facts, agent docs and feature registry), and the
# feature receipts. Measured at 45 s on a warm Linux runner.
#
# Since 2026-11-08 (founder) the `full-ci` job also dispatches `ci.yml` on every
@@ -50,6 +51,10 @@ jobs:
toolchain: stable
components: rustfmt
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Preflight
env:
BEFORE_SHA: ${{ github.event.before }}
+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
+319
View File
@@ -0,0 +1,319 @@
name: Web maintainer
on:
schedule:
- cron: '43 6 * * *'
workflow_dispatch:
inputs:
lane:
description: Which lane to run
required: true
default: all
type: choice
options:
- all
- derived
- copy-freshness
- link-health
- docs-drift
permissions: {}
concurrency:
group: web-maintainer
cancel-in-progress: false
env:
WEB_MAINTAINER_CLI_VERSION: v0.10.2
jobs:
plan:
name: Plan
if: github.repository == 'codewhale-hq/Codewhale' && github.ref == 'refs/heads/main' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
outputs:
lanes: ${{ steps.plan.outputs.lanes }}
derived: ${{ steps.plan.outputs.derived }}
steps:
- id: plan
env:
INPUT_LANE: ${{ inputs.lane || 'all' }}
run: |
derived=false
lanes='[]'
case "$INPUT_LANE" in
all) derived=true; lanes='["copy-freshness","link-health","docs-drift"]' ;;
derived) derived=true ;;
copy-freshness|link-health|docs-drift) lanes="[\"${INPUT_LANE}\"]" ;;
*) echo "::error::unknown lane"; exit 1 ;;
esac
echo "derived=${derived}" >> "$GITHUB_OUTPUT"
echo "lanes=${lanes}" >> "$GITHUB_OUTPUT"
echo "Web maintainer plan: derived=${derived} lanes=${lanes}" >> "$GITHUB_STEP_SUMMARY"
derived:
name: Derived files
needs: plan
if: needs.plan.result == 'success' && needs.plan.outputs.derived == 'true' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Install web dependencies
working-directory: web
run: npm ci --no-audit --no-fund
- name: Regenerate derived files
working-directory: web
env:
GH_TOKEN: ${{ github.token }}
run: |
node scripts/sync-latest-release.mjs
node scripts/derive-facts.mjs
node scripts/derive-changelog.mjs
node scripts/derive-install.mjs
node scripts/render-terminal-capture.mjs
- name: Check what changed
id: drift
run: |
mkdir -p "$RUNNER_TEMP/out"
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
echo "::error::regeneration produced untracked files"
git ls-files --others --exclude-standard
exit 1
fi
git diff --name-status | node web/maintainer/check-allowlist.mjs web/maintainer/derived.txt derived
if [ -n "$(git diff --summary)" ]; then
echo "::error::regeneration created, deleted or changed modes"
exit 1
fi
git diff --binary > "$RUNNER_TEMP/out/change.patch"
if [ -s "$RUNNER_TEMP/out/change.patch" ]; then
echo "drift=true" >> "$GITHUB_OUTPUT"
git diff --stat >> "$GITHUB_STEP_SUMMARY"
else
echo "drift=false" >> "$GITHUB_OUTPUT"
echo "Derived files are current." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Run the web checks on the regenerated tree
if: steps.drift.outputs.drift == 'true'
working-directory: web
run: |
npm run check
npx vitest run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: steps.drift.outputs.drift == 'true'
with:
name: web-maintainer-derived
path: ${{ runner.temp }}/out/change.patch
retention-days: 3
if-no-files-found: error
lane:
name: Lane ${{ matrix.lane }}
needs: plan
if: needs.plan.result == 'success' && needs.plan.outputs.lanes != '[]' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 3
matrix:
lane: ${{ fromJSON(needs.plan.outputs.lanes) }}
env:
LANE: ${{ matrix.lane }}
steps:
- name: Require the DeepSeek key
env:
KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
run: |
if [ -z "$KEY" ]; then
echo "::error::WEB_MAINTAINER_DEEPSEEK_API_KEY is not set. Create a dedicated DeepSeek key with a provider-side monthly cap and add it as that repository secret, or unset WEB_MAINTAINER_ENABLED. See web/maintainer/README.md."
exit 1
fi
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Prove the command sandbox works or stop
run: |
sudo apt-get install -y --no-install-recommends bubblewrap util-linux >/dev/null
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null || true
probe="$GITHUB_WORKSPACE/.sandbox-probe"
rm -f "$probe" /usr/local/sandbox-probe
if ! timeout 10 bash -c 'exec 3<>/dev/tcp/api.deepseek.com/443'; then
echo "::error::the runner cannot reach api.deepseek.com, so the network denial cannot be proven."
exit 1
fi
if ! /usr/bin/bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
--bind "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE" --chdir "$GITHUB_WORKSPACE" -- \
bash -c 'touch .sandbox-probe && ! touch /usr/local/sandbox-probe 2>/dev/null && ! timeout 10 bash -c "exec 3<>/dev/tcp/api.deepseek.com/443" 2>/dev/null'; then
echo "::error::bubblewrap did not confine a child on this runner (writes outside the workspace or outbound network were not denied). Refusing to give a model a shell."
exit 1
fi
if [ ! -e "$probe" ] || [ -e /usr/local/sandbox-probe ]; then
echo "::error::sandbox probe left unexpected state. Refusing to give a model a shell."
exit 1
fi
rm -f "$probe"
echo "Sandbox probe passed: workspace-only writes, no outbound network." >> "$GITHUB_STEP_SUMMARY"
- name: Stage trusted tooling and the pinned CLI
run: |
mkdir -p "$RUNNER_TEMP/trusted" "$RUNNER_TEMP/home"
cp web/maintainer/allowlist.txt web/maintainer/check-allowlist.mjs "$RUNNER_TEMP/trusted/"
cp "web/maintainer/lanes/${LANE}.md" "$RUNNER_TEMP/trusted/prompt.md"
CODEWHALE_VERSION="$WEB_MAINTAINER_CLI_VERSION" CODEWHALE_INSTALL_DIR="$RUNNER_TEMP/bin" sh web/public/install.sh
"$RUNNER_TEMP/bin/codewhale" --version
- name: Probe outbound links
if: matrix.lane == 'link-health'
run: node web/maintainer/link-report.mjs "$GITHUB_WORKSPACE/.git/maintainer-input/link-report.json"
- name: Record the git state the model must not change
run: |
git config --local --list | sort > "$RUNNER_TEMP/git-config.before"
find .git/hooks -type f ! -name '*.sample' > "$RUNNER_TEMP/hooks.before"
test ! -s "$RUNNER_TEMP/hooks.before"
- name: Run the lane on DeepSeek
timeout-minutes: 25
env:
WEB_MAINTAINER_DEEPSEEK_API_KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
run: |
env -i \
PATH="$PATH" \
HOME="$RUNNER_TEMP/home" \
CODEWHALE_HOME="$RUNNER_TEMP/home/.codewhale" \
CODEWHALE_SECRET_BACKEND=file \
CODEWHALE_MEMORY=false \
CODEWHALE_TELEMETRY=false \
DEEPSEEK_API_KEY="$WEB_MAINTAINER_DEEPSEEK_API_KEY" \
"$RUNNER_TEMP/bin/codewhale" \
--no-project-config \
--skip-onboarding \
--workspace "$GITHUB_WORKSPACE" \
exec \
--provider deepseek \
--model deepseek-flash \
--reasoning-effort low \
--auto \
--sandbox workspace-write \
--max-turns 40 \
--output-format stream-json \
-- "$(cat "$RUNNER_TEMP/trusted/prompt.md")" \
> "$RUNNER_TEMP/exec.jsonl"
echo "Model run finished: $(wc -l < "$RUNNER_TEMP/exec.jsonl") stream events." >> "$GITHUB_STEP_SUMMARY"
- name: Enforce the path allowlist
id: scope
run: |
if git status --porcelain --untracked-files=all --ignored | grep -qv '^ M '; then
echo "::error::the lane left untracked, ignored, staged or non-edit entries"
git status --porcelain --untracked-files=all --ignored | grep -v '^ M '
exit 1
fi
git config --local --list | sort | diff - "$RUNNER_TEMP/git-config.before" || { echo "::error::the lane changed .git/config"; exit 1; }
find .git/hooks -type f ! -name '*.sample' | diff - "$RUNNER_TEMP/hooks.before" || { echo "::error::the lane added git hooks"; exit 1; }
git diff --name-status | node "$RUNNER_TEMP/trusted/check-allowlist.mjs" "$RUNNER_TEMP/trusted/allowlist.txt" "$LANE"
if [ -n "$(git diff --summary)" ]; then
echo "::error::the lane created, deleted or changed modes"
exit 1
fi
mkdir -p "$RUNNER_TEMP/out"
git diff --binary > "$RUNNER_TEMP/out/change.patch"
if [ ! -s "$RUNNER_TEMP/out/change.patch" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Lane ${LANE}: no change proposed." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "$(wc -c < "$RUNNER_TEMP/out/change.patch")" -gt 262144 ]; then
echo "::error::patch exceeds 256 KiB"
exit 1
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
{ echo "Lane ${LANE} proposes:"; echo '```text'; git diff --stat; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Fresh checkout for verification
if: steps.scope.outputs.changed == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
path: verify
persist-credentials: false
- name: Run the web checks on the proposal in a clean tree without secrets
if: steps.scope.outputs.changed == 'true'
env:
PATCH: ${{ runner.temp }}/out/change.patch
run: |
cd verify
git apply --index --whitespace=nowarn "$PATCH"
cd web
npm ci --no-audit --no-fund
npm run check
npx vitest run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: steps.scope.outputs.changed == 'true'
with:
name: web-maintainer-${{ matrix.lane }}
path: ${{ runner.temp }}/out/change.patch
retention-days: 3
if-no-files-found: error
publish:
name: Open pull requests
needs: [plan, derived, lane]
if: always() && needs.plan.result == 'success' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
continue-on-error: true
with:
pattern: web-maintainer-*
path: ${{ runner.temp }}/patches
- name: Look for proposed patches
id: patches
run: |
if [ -n "$(find "$RUNNER_TEMP/patches" -name change.patch -size +0c 2>/dev/null)" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No lane proposed a change; no pull request opened." >> "$GITHUB_STEP_SUMMARY"
fi
- if: steps.patches.outputs.found == 'true'
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3
id: app-token
with:
app-id: ${{ vars.CODEWHALE_APP_ID }}
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- if: steps.patches.outputs.found == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Open or update one pull request per lane
if: steps.patches.outputs.found == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_ID: ${{ vars.CODEWHALE_APP_ID }}
ARTIFACTS_DIR: ${{ runner.temp }}/patches
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
mkdir -p "$RUNNER_TEMP/trusted"
cp web/maintainer/allowlist.txt web/maintainer/derived.txt web/maintainer/check-allowlist.mjs web/maintainer/publish-prs.sh "$RUNNER_TEMP/trusted/"
TRUSTED_DIR="$RUNNER_TEMP/trusted" bash "$RUNNER_TEMP/trusted/publish-prs.sh"
+57 -6
View File
@@ -7,28 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **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.
- **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.
- **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 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.
- **Website docs:** documentation pages no longer stack a second "Codewhale documentation" header under the site navigation; the line saying which release the docs describe now sits under the breadcrumb.
- **Website languages:** the Roadmap and Contribute pages now show their titles, introductions and actions in all 18 site languages instead of English outside English and Chinese. The translations have had no native review yet.
- **Responses dialect profiles:** Codex, DeepSeek and Concentrate Responses behavior now comes from one checked dialect table instead of per-provider code, a custom `wire = "responses"` provider can set `responses_profile = "standard"` (the default when omitted), and preview shows the profile each Responses request used. A Responses client whose route cannot be resolved now fails when it is created rather than at its first request ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)). Thanks [@SamhandsomeLee](https://github.com/SamhandsomeLee).
- **Read-only builtins over the Runtime API:** `POST /v1/threads/{id}/commands/builtin` runs `/system`, `/tokens` or `/cost` against a thread and returns the same answer the terminal prints, including the effective system prompt the engine sends. Any other command name answers 404, a thread whose engine is not loaded or has no live context estimate answers 409, and the route never starts an engine or writes settings ([#7277](https://github.com/codewhale-hq/Codewhale/pull/7277)). Verified by the app-server route tests and terminal-host tests; no editor client uses it yet. Thanks [@gaord](https://github.com/gaord).
- **Automations:** a run missed while Codewhale was closed now waits on reopen for Run now or Skip instead of catching up silently; each automation can choose "If missed: ask" (default), "run once" or "skip". Tested in the engine only; no HTTP route tests, no desktop client yet, and a Mac asleep longer than the grace window counts as closed.
- **Website maintainer:** a scheduled `web-maintainer` workflow proposes website upkeep as pull requests only: regenerated facts, and three DeepSeek lanes (stale version mentions, moved links, docs drift) that may edit only allowlisted copy files and must pass `npm run check` and the web tests. It is off until the repository variable `WEB_MAINTAINER_ENABLED` is set and a capped `WEB_MAINTAINER_DEEPSEEK_API_KEY` secret exists, and it has never run. The Worker's triage, PR-review, stale, duplicate and curate (Today's Dispatch drafting) cron tasks are removed; the digest stays.
- **Website measurement:** the first-party ingest now accepts a closed website funnel schema while preserving existing runtime and browser batches. Reader confirmations remain self-reports, new website data stays first-party, and no deployment or live conversion gain is established by the source change.
- **Website facts:** the website now reads the installed version, asset list and repository numbers from the published release and a dated snapshot instead of the source tree, shows nothing rather than 0 when a live number cannot be read, stamps its facts revision and date together, no longer calls the desktop app native, says when the release was read, and drops the price from its structured data. This is a source change and is not deployed yet.
- **Website surface status:** the status of the terminal, browser, desktop, cloud and accounts surfaces now comes from typed states with copy in every locale, the desktop download appears only for a signed, notarized release with a checksum, the cloud-invitation and plan switches are committed values that default to off, the partner strip expires at render time, and repository numbers show when they were read. This is a source change and is not deployed yet.
- **Website capabilities:** the website now lists a capability as available only when the published release has it, labels features that exist only in source as not yet released, keeps held features (cloud computers beyond invite-only, other vendors' agents over ACP, and plans) hidden behind committed switches that default to off, and prints provider route, catalog model and tool counts as they are at the published release tag instead of on main. This is not deployed yet.
- **Installer uninstall lines:** the installers now print the uninstall command, the update-mode sentence and the shell-profile marker sentence only when the Codewhale they just installed reports an `uninstall` command, and older releases such as a pinned v0.10.2 get the manual removal steps instead of a command that would be sent to the model as a prompt. (CW-8029) The installers reach people through the website, so this is not deployed yet.
- **Installer PATH ending:** when `codewhale` is not on PATH, the installers' last lines are now the copy-paste `export PATH=...; hash -r` command and `codewhale --version`, and when it is on PATH the last line is `Next: codewhale --version`; the Computer Use notice moves above them and shows on macOS only. (CW-8030) The installers reach people through the website, so this is not deployed yet.
- **Installer PATH setup:** when the install directory is not on PATH, the macOS and Linux installers now add Codewhale's marked three-line PATH block to your shell profile, print exactly which file and lines they added and the command that removes them, and skip this with `--no-modify-path` or `CODEWHALE_NO_MODIFY_PATH=1`; the Windows archive installer adds the directory to your user PATH the same way and skips it with `-NoModifyPath`. Release archives carry this from 0.10.3 on, and the Windows installer has not been run yet. (CW-8036) The website serves install.sh, so this reaches people only when the website is next deployed.
- **Website and docs:** installation now leads to a version check and a verifiable first task, article search finds text and commands with section links, and GitHub star links show the fetched count. The hero reuses the generated drone whale, and control/tool references distinguish the unpublished source candidate from v0.10.2; these website changes still await deployment.
- **External agent options:** `GET /v1/agents/external/{id}/options?cwd=` returns the models, modes and options an external agent itself offers for a folder, and which Codewhale actions it will not carry. A saved agent model the agent no longer offers is refused with a stable `choice_not_offered` code instead of being ignored, per-turn settings an agent cannot honor are refused as `unsupported_control`, and each agent turn records the context window and tokens the agent reported, with cost shown as unknown (CW-7879). Probing starts a real agent session in the folder, which the agent may keep in its own history. Mode and config choices are listed but cannot yet be chosen through thread creation. Verified on Linux by unit and local HTTP tests against a fixture agent; no real installed agent, macOS, Windows or hosted CI run.
- **External agent interrupts:** Interrupting a turn while an external agent waits for approval now answers the agent's permission request as cancelled. (CW-7958)
- **External agent quota:** The Runtime API route `GET /v1/agents/external/quota` reports each external agent's quota as unknown with a reason (flag off, consent missing, not implemented). The new `agents.quota.<agent>` and `agents.quota.<agent>.consent` settings, saved through `POST /v1/config`, record the opt-in and consent (at most 32 agent entries). No agent credential is read. (CW-7993)
### 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. 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.
- **Website copy:** The site's headline is now "The model can be anyone's. The agent should be yours.", with its subhead, belief and labelled product map in every language.
- **Website routes:** Pages that repeated each other now redirect to the one page that does the job, the header and footer list only pages that exist under their current names, the sitemap drops redirected and catalogue pages, and machine-drafted translations say so.
- **Website privacy:** The website no longer counts anything when your browser sends Global Privacy Control or Do Not Track, and the privacy page says exactly what is counted and how it is keyed.
- Plugin UI: the Runtime API now serves reviewed DSH client closures on six seats (customize.plugins.detail, conversation.composer.dock, conversation.chat.node, workpane.panel, sidebar.section, command.row) with coded refusals, drops a plugin's UI grants the moment it is disabled, revoked or uninstalled, and adds a plugin route bridge that refuses until a plugin web server exists. No real DSH package has been served end to end yet, and a client that imports `@deepseek-ai/dsh-client-*` modules is still refused. (CW-7956)
### 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.
- The Runtime API's `POST /v1/git/push` now pushes exactly one branch at one commit (`<commit>:refs/heads/<branch>`, no follow-tags or submodule recursion), so `push.default`, `remote.<name>.push` and `push.followTags` can no longer publish other branches or tags. Clients can bind a push to a named branch and expected HEAD with `branch` + `expect.head` (advertised as `named_push` in `GET /v1/git`); a stale branch or HEAD answers `409 git_state_changed` and pushes nothing. A bare push from a detached HEAD or an unborn branch is now refused, and the desktop app has not yet adopted the named form. Verified on macOS by unit tests against a local bare remote; no real network remote, Linux, Windows or hosted CI run.
- Cancelling or timing out a read-only Git tool (git_status, git_diff, git_commit_plan, git_log, git_show, git_blame, git_merge_tree) now stops the git process and anything it started, instead of leaving it running in the background. Verified on macOS by unit tests; Linux, Windows and hosted CI are not yet run, and the filter-override probe is still not contained.
- TUI: the launch resume confirmation no longer treats Meta+Enter, Meta+Y or Hyper+Enter as "resume"; only a plain Enter/Y or Shift+Y answers it. Verified by the focused unit tests only; not tried in a terminal that sends Meta or Hyper.
- TUI: mentioning a huge directory or a far-away line range (`@file:N-M`) now stops after a bounded scan and says the result is partial instead of claiming end of file. Verified by the focused `file_mention` unit tests; no real 64 MiB file was tried.
- Runtime `patch-undo` now restores a turn's own file changes in the default (Ask) posture without trust mode or Full Access; later edits and changes the turn did not make are still refused with nothing changed, and the `restore_requires_trust` error code is gone ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)). Verified by app-server and TUI unit tests on Linux; no Windows `/undo` or process-restart run.
- **Website terminal frames:** The site can render terminal frames captured from the released version, and chooses each frame's caption from where it was captured.
- **Website docs truth:** Documentation pages now describe the released version first and show what exists only in source in a labelled section that switches on when a release carries it.
- **Website Arabic pages:** On Arabic pages, English text that has no translation yet keeps its own direction and language, so punctuation, commands and dates no longer appear reversed.
- A DSH plugin whose package includes a `.gitignore` at its root now installs instead of being refused as a hidden file; nested `.gitignore` files, other hidden files, credential files and links are still refused. (CW-8023)
- `install.bat` in the Windows release archive now passes its arguments to `install.ps1`, so `install.bat -NoModifyPath` declines the user PATH edit like the PowerShell installer does, and the install guide says so. It has not been run on Windows. The batch file reaches people in the next release archive, and the guide sentence when the website is next deployed, because the site serves the guide but not `install.bat`. (CW-8046)
## [0.10.3] - 2026-11-08
@@ -37,11 +86,12 @@ until the matching tag, packages, checksums, and release assets exist.
### Work in progress and known limits
This source candidate is not the final 0.10.3 tag. The 2026-11-08 scope
decision keeps App Server v2, the coarse Engine split and DSH all-33
compatibility in 0.10.3; the final tag waits for that full list. The near-term
`0.10.3-beta.2` is a GitHub prerelease for the Boat image and desktop, not this
tag. Items moved to 0.10.4 by name are marked below.
This source candidate is not the final 0.10.3 tag. There is no
`0.10.3-beta.2`: the next release is the full 0.10.3, and its final tag waits
for the full scope list. Codex App Server v2 is paused and is not part of
0.10.3; the legacy JSON-RPC dialect is not retired in this release. The coarse
Engine split and DSH all-33 compatibility stay in 0.10.3, and none of this
work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **Terminal lifetime (0.10.3 work item):** deleting a conversation now ends the
terminal sessions and background jobs owned by that Runtime thread. The
@@ -584,7 +634,7 @@ tag. Items moved to 0.10.4 by name are marked below.
- **Listing installed agent CLIs no longer blocks the runtime.** `GET /v1/agents/external` looks for each binary with `tokio::fs` instead of synchronous file calls on a runtime worker.
- **A failed custom-provider switch leaves the provider you were on.** When saving a custom provider succeeds but switching to it fails (its identity cannot be resolved, or the engine refuses the route), the next request keeps going to the provider the footer shows instead of the half-configured one.
- **A zero page size over stdio still advances.** `thread/loaded/list` on the App Server's stdio transport treats a requested limit of `0` as `1`, so a client that sends `0` reaches the next page. `GET /v1/threads` is unchanged.
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it.
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did, including when such a tool is forced through an object-form `tool_choice`. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)). Thanks [@ychu824](https://github.com/ychu824) for the report.
- **A large sub-agent fan-out no longer exhausts the usage journal.** The per-session limit of live usage receipts rises from 64 to 256, above the workflow ceiling of 128, so a full fan-out keeps its cost receipts instead of running with cost unknown.
- **A turn that stalls before it starts is now bounded.** The stall watchdog covers the setup before the first model request, so a wedged start is logged, cancelled after 180 seconds with a stated reason, and leaves a stall record instead of waiting silently. A warning names the case where all four blocking-work slots are held. Refs #6184.
- **One unreadable saved session no longer empties the recent-sessions list.** The launch card and ACP `session/list` show the readable sessions and log the failure. Refs #7095.
@@ -1056,6 +1106,7 @@ tag. Items moved to 0.10.4 by name are marked below.
- **[@Statter](https://github.com/Statter)** — reported black-on-black text on terminals that do not report their background ([#7043](https://github.com/codewhale-hq/Codewhale/issues/7043)).
- **[@eugenicum](https://github.com/eugenicum)** — asked for a visible Full Access flag for `serve --acp` ([#6992](https://github.com/codewhale-hq/Codewhale/issues/6992)).
- **[@luestr](https://github.com/luestr)** — reported file and conversation rollback diverging ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)), the Linux multiline send key ([#7093](https://github.com/codewhale-hq/Codewhale/issues/7093)) and session export guidance ([#6996](https://github.com/codewhale-hq/Codewhale/issues/6996)).
- **[@ychu824](https://github.com/ychu824)** — reported every Anthropic request failing with a 400 once `tool_search` added a tool whose name contains a dot, such as `web.run` ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)).
## [0.10.2] - 2026-10-22
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"] }
+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
@@ -391,6 +391,11 @@
"type": "boolean",
"default": false
},
"thread_fleet_member_durable_operations_v1": {
"type": "boolean",
"description": "Member starts are durable per `(store, thread, operation_key)`;\n`GET /v1/threads/{id}/members/operations/{operation_key}` reads them.",
"default": false
},
"fleet_run_start": {
"type": "boolean",
"default": false
@@ -482,6 +487,7 @@
"worker_runtime",
"fleet_run_create",
"thread_fleet_members",
"thread_fleet_member_durable_operations_v1",
"fleet_run_start",
"fleet_event_replay",
"fleet_event_stream",
@@ -86,6 +86,7 @@ export type RuntimeCapabilities = {
worker_runtime: boolean
fleet_run_create: boolean
thread_fleet_members: boolean
thread_fleet_member_durable_operations_v1: boolean
fleet_run_start: boolean
fleet_event_replay: boolean
fleet_event_stream: boolean
@@ -173,6 +173,10 @@ pub struct RuntimeCapabilities {
pub fleet_run_create: bool,
#[serde(default)]
pub thread_fleet_members: bool,
/// Member starts are durable per `(store, thread, operation_key)`;
/// `GET /v1/threads/{id}/members/operations/{operation_key}` reads them.
#[serde(default)]
pub thread_fleet_member_durable_operations_v1: bool,
#[serde(default)]
pub fleet_run_start: bool,
#[serde(default)]
@@ -550,6 +554,7 @@ mod tests {
worker_runtime: false,
fleet_run_create: true,
thread_fleet_members: true,
thread_fleet_member_durable_operations_v1: true,
fleet_run_start: true,
fleet_event_replay: true,
fleet_event_stream: true,
+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"
+78 -10
View File
@@ -13,6 +13,7 @@ use codewhale_engine::runtime_threads::{
};
use codewhale_execpolicy::ApprovalMode;
use codewhale_runtime::config::Config;
use codewhale_runtime::features::Feature;
use serde_json::{Value, json};
use std::collections::{HashMap, VecDeque};
use std::path::{Path, PathBuf};
@@ -54,7 +55,7 @@ pub async fn run_acp_server(
.await
}
pub(crate) struct CapturedAcpFrontend {
pub struct CapturedAcpFrontend {
config: Config,
model: String,
cwd: PathBuf,
@@ -63,7 +64,7 @@ pub(crate) struct CapturedAcpFrontend {
config_path: Option<PathBuf>,
config_profile: Option<String>,
}
pub(crate) fn capture_frontend(
pub fn capture_frontend(
config: Config,
model: String,
cwd: PathBuf,
@@ -84,7 +85,7 @@ pub(crate) fn capture_frontend(
}))
}
impl CapturedAcpFrontend {
pub(crate) fn serve<'a>(
pub fn serve<'a>(
&'a self,
input: Box<dyn AsyncBufRead + Send + Unpin>,
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
@@ -385,6 +386,11 @@ impl AcpServer {
if !self.sessions.contains_key(id) {
return Err(AcpError::invalid_params("unknown sessionId"));
}
if let Some(kind) = unsupported_prompt_block(params.get("prompt")) {
return Err(AcpError::invalid_params(format!(
"unsupported prompt content block `{kind}`: this agent accepts text, resource and resource_link blocks only"
)));
}
let prompt = extract_prompt_text(params.get("prompt"))
.filter(|text| !text.trim().is_empty())
.ok_or_else(|| AcpError::invalid_params("prompt must include text content"))?;
@@ -407,11 +413,7 @@ impl AcpServer {
}
async fn new_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
validate_mcp_servers(&params)?;
let cwd = params
.get("cwd")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| self.default_cwd.clone());
let cwd = absolute_cwd(&params, "session/new")?.unwrap_or_else(|| self.default_cwd.clone());
let identity = self
.config
.active_provider_identity()
@@ -506,6 +508,7 @@ impl AcpServer {
async fn load_session(&mut self, params: Value) -> std::result::Result<Value, AcpError> {
validate_mcp_servers(&params)?;
absolute_cwd(&params, "session/load")?;
let requested = params
.get("sessionId")
.and_then(Value::as_str)
@@ -971,7 +974,8 @@ fn tool_call_kind(call: &PendingToolCall) -> &'static str {
Some("write" | "edit" | "patch") => "edit",
_ => "read",
},
"apply_patch" => "edit",
"apply_patch" | "write" | "edit" | "write_file" | "edit_file" => "edit",
"read" | "read_file" => "read",
"Git" => "read",
"bash" | "Bash" | "terminal/run" | "terminal/send" | "terminal/wait"
| "terminal/cancel" | "terminal/reset" => "execute",
@@ -1119,13 +1123,14 @@ fn validate_mcp_servers(params: &Value) -> std::result::Result<(), AcpError> {
None => Ok(()),
Some(Value::Array(servers)) if servers.is_empty() => Ok(()),
Some(Value::Array(_)) => Err(AcpError::invalid_params(
"Client-supplied MCP servers are unsupported by Codewhale's ACP adapter. Use an empty mcpServers list, or configure MCP through codewhale app-server --http and the Runtime API. ACP v1 stdio MCP support is incomplete.",
"Client-supplied MCP servers are unsupported by Codewhale's ACP adapter. Use an empty mcpServers list; sessions use the MCP servers and plugins configured in Codewhale itself.",
)),
Some(_) => Err(AcpError::invalid_params("mcpServers must be an array")),
}
}
fn initialize_result(client_protocol_version: Option<u64>, config: &Config) -> Value {
let features = config.features();
json!({
"protocolVersion": client_protocol_version
.map(|version| version.min(ACP_PROTOCOL_VERSION))
@@ -1142,6 +1147,14 @@ fn initialize_result(client_protocol_version: Option<u64>, config: &Config) -> V
"http": false,
"sse": false
},
"_meta": {
"codewhale": {
"plugins": features.enabled(Feature::ExtensionHost)
&& codewhale_plugins::activation::extension_host_policy_enabled(),
"configuredMcpServers": features.enabled(Feature::Mcp),
"clientMcpServers": false
}
},
// ACP `SessionCapabilities` fields are objects, never booleans:
// `{}` means "supported", absent/null means "not supported"
// (#5969 — a boolean here made JetBrains' strictly-typed client
@@ -1196,6 +1209,31 @@ fn extract_prompt_text(prompt: Option<&Value>) -> Option<String> {
}
}
fn unsupported_prompt_block(prompt: Option<&Value>) -> Option<String> {
let Value::Array(blocks) = prompt? else {
return None;
};
blocks.iter().find_map(|block| {
let kind = block
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown");
content_block_text(block)
.is_none()
.then(|| kind.to_string())
})
}
fn absolute_cwd(params: &Value, method: &str) -> std::result::Result<Option<PathBuf>, AcpError> {
match params.get("cwd") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(path)) if Path::new(path).is_absolute() => Ok(Some(PathBuf::from(path))),
Some(_) => Err(AcpError::invalid_params(format!(
"{method} cwd must be an absolute path"
))),
}
}
fn content_block_text(block: &Value) -> Option<String> {
match block.get("type").and_then(Value::as_str)? {
"text" => block
@@ -1821,6 +1859,36 @@ mod tests {
assert_eq!(result["agentCapabilities"]["modelSelection"], true);
}
#[test]
fn initialize_advertises_configured_plugins_and_mcp_but_not_client_servers() {
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let mut config = Config::default();
config.set_feature("extension_host", true).unwrap();
config.set_feature("mcp", true).unwrap();
let result = initialize_result(Some(1), &config);
let meta = &result["agentCapabilities"]["_meta"]["codewhale"];
assert_eq!(meta["plugins"], true);
assert_eq!(meta["configuredMcpServers"], true);
assert_eq!(meta["clientMcpServers"], false);
assert_eq!(
result["agentCapabilities"]["mcpCapabilities"]["http"],
false
);
config.set_feature("extension_host", false).unwrap();
config.set_feature("mcp", false).unwrap();
let off = initialize_result(Some(1), &config);
assert_eq!(
off["agentCapabilities"]["_meta"]["codewhale"]["plugins"],
false
);
assert_eq!(
off["agentCapabilities"]["_meta"]["codewhale"]["configuredMcpServers"],
false
);
}
#[test]
fn extract_prompt_text_accepts_text_and_resource_blocks() {
let prompt = json!([
+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(),
+69 -27
View File
@@ -114,9 +114,11 @@ mod account;
mod auth;
mod automations;
mod avatars;
mod builtin_commands;
mod computer_display;
mod context;
mod diagnostics;
mod external_agent_options;
mod external_agents;
mod git;
mod jobs;
@@ -1576,10 +1578,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,
@@ -1619,6 +1652,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
worker_runtime: true,
fleet_run_create: true,
thread_fleet_members: true,
thread_fleet_member_durable_operations_v1: true,
fleet_run_start: true,
fleet_event_replay: true,
fleet_event_stream: true,
@@ -2032,7 +2066,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;
@@ -3128,7 +3162,12 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/threads/{id}/context", get(context::get_thread_context))
.route(
"/v1/threads/{id}/members",
post(members::start_thread_member),
post(members::start_thread_member)
.layer(DefaultBodyLimit::max(members::MAX_MEMBER_REQUEST_BYTES)),
)
.route(
"/v1/threads/{id}/members/operations/{operation_key}",
get(members::get_member_operation),
)
.route("/v1/threads/{id}/plan", get(plans::get_thread_plan))
.route("/v1/threads/{id}/todo", get(plans::get_thread_todo))
@@ -3277,6 +3316,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/skills", get(list_skills))
.route("/v1/commands", get(list_commands))
.route("/v1/threads/{id}/commands", post(run_thread_command))
.route(
"/v1/threads/{id}/commands/builtin",
post(builtin_commands::run_builtin_command),
)
.route(
"/v1/threads/{id}/commands/{operation_key}",
get(get_thread_command).delete(cancel_thread_command),
@@ -3368,6 +3411,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/apps/plugins/{selector}/ui/{*path}",
get(plugins::get_plugin_ui_asset),
)
.route(
"/v1/apps/plugins/{selector}/routes/{*path}",
any(plugins::plugin_route_bridge),
)
.route(
"/v1/apps/plugins/{selector}/update",
post(plugins::update_plugin_api),
@@ -3462,6 +3509,14 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/agents/external",
get(external_agents::list_external_agents),
)
.route(
"/v1/agents/external/quota",
get(external_agents::get_external_agent_quota),
)
.route(
"/v1/agents/external/{id}/options",
get(external_agent_options::get_external_agent_options),
)
.route("/v1/providers", get(list_providers))
.route("/v1/providers/{id}/models", get(list_provider_models))
.route(
@@ -7059,6 +7114,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,
@@ -7073,6 +7129,7 @@ async fn runtime_info(
.then(|| state.runtime_threads.job_operation_store_id().to_string()),
account,
experimental: RuntimeExperimentalCapabilities::default(),
sandbox,
version,
})
}
@@ -9211,14 +9268,13 @@ async fn preview_patch_undo_thread_turn(
.iter()
.map(|turn| turn.turn_id.clone())
.collect();
let trusted = thread.trust_mode || thread.auto_approve;
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
let (result, paths) = tokio::task::spawn_blocking(move || {
let _reservation = _reservation;
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
patch_undo_workspace_files_impl(&thread.workspace, &dropped_turns, trusted, true, None)
patch_undo_workspace_files_impl(&thread.workspace, &dropped_turns, true, None)
})
.await
.map_err(|error| ApiError::internal(format!("Patch undo preview failed: {error}")))??;
@@ -9255,10 +9311,6 @@ async fn patch_undo_thread_turn(
// Validate the selected turn/history before touching any file, so an invalid
// undo request cannot leave a half-applied workspace.
let prepared = prepare_patch_undo(&state, &id, &req).await?;
// File rollback is a workspace mutation, so it needs the trust the
// TUI's `/undo` requires. Read from the thread's own record: the
// client does not get to assert it.
let trusted = thread.trust_mode || thread.auto_approve;
let workspace = thread.workspace.clone();
// The restore points come from the dropped turns' own records, not
// from the thread's saved-session binding or a scan of the shared
@@ -9270,7 +9322,7 @@ async fn patch_undo_thread_turn(
let patch_result = tokio::task::spawn_blocking(move || {
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
patch_undo_workspace_files_impl(&workspace, &dropped_turns, trusted, false, req.expected_paths.as_deref()).map(|(result, _)| result)
patch_undo_workspace_files_impl(&workspace, &dropped_turns, false, req.expected_paths.as_deref()).map(|(result, _)| result)
})
.await
.map_err(|e| ApiError::internal(format!("Patch undo task failed: {e}")))??;
@@ -9313,7 +9365,6 @@ const PATCH_UNDO_NO_RESTORE_POINT: &str = "restore_point_unavailable";
const PATCH_UNDO_RESTORE_POINT_PRUNED: &str = "restore_point_pruned";
const PATCH_UNDO_PATH_NOT_SNAPSHOTTED: &str = "path_not_snapshotted";
const PATCH_UNDO_WORKSPACE_CHANGED: &str = "workspace_changed_since_turn";
const PATCH_UNDO_UNTRUSTED: &str = "restore_requires_trust";
const PATCH_UNDO_WORKSPACE_UNAVAILABLE: &str = "workspace_unavailable";
/// One pre-turn → post-turn window of a dropped turn, resolved to the trees
@@ -9545,21 +9596,23 @@ fn no_restore_point(turn_id: &str, why: &str) -> ApiError {
/// point, or a path changed since the turn is a `409` with a stable
/// `error.code`, never a `201` that forks while the files stay changed.
///
/// `trusted` mirrors the gate the TUI's `patch_undo()` applies
/// (`yolo || trust_mode`), evaluated once a real change is known.
/// # Posture
///
/// No trust mode or Full Access is required. Undo reverses only what the
/// dropped turns' own tool calls wrote, and only while every such path still
/// holds exactly the bytes the turn left; the same ownership and later-edit
/// proofs refuse anything else, so the default posture cannot lose work the
/// turn did not make.
pub fn patch_undo_workspace_files(
workspace: &FsPath,
dropped_turns: &[codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots],
trusted: bool,
) -> Result<PatchUndoResult, ApiError> {
patch_undo_workspace_files_impl(workspace, dropped_turns, trusted, false, None)
.map(|(result, _)| result)
patch_undo_workspace_files_impl(workspace, dropped_turns, false, None).map(|(result, _)| result)
}
fn patch_undo_workspace_files_impl(
workspace: &FsPath,
dropped_turns: &[codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots],
trusted: bool,
preview: bool,
expected_paths: Option<&[String]>,
) -> Result<(PatchUndoResult, Vec<String>), ApiError> {
@@ -9816,17 +9869,6 @@ fn patch_undo_workspace_files_impl(
));
}
// Restoring is a workspace mutation. Gate it exactly where the TUI gates
// it — after a real, owned change is known — so the two surfaces cannot
// drift into "one refuses, the other half-undoes".
if !trusted {
return Err(ApiError::conflict(
"Refusing to undo workspace files outside trusted mode. \
Turn on /trust or switch this thread to Full Access, then undo again.",
)
.with_code(PATCH_UNDO_UNTRUSTED));
}
let paths: Vec<String> = to_restore
.iter()
.map(|(path, _, _)| path.display().to_string())
@@ -0,0 +1,255 @@
//! `POST /v1/threads/{id}/commands/builtin` — execute a terminal built-in
//! command against a thread, remotely.
//!
//! The thread's state is assembled into the engine's
//! [`BuiltinCommandHostSnapshot`] (contract projections included) and handed
//! to the installed command catalog, whose terminal-host implementation maps
//! it into the facet envelope and runs the portable handlers. Which commands
//! this serves is the host's decision (`builtin_execution::SERVED`); a name
//! outside that set answers 404 the same way an unknown name does, so the
//! remote surface can never widen without the host widening it first.
//!
//! Phase 1 notes: executions are not receipt-tracked (the phase-1 set is
//! read-only — every served handler is pure over the snapshot), and the
//! `text_only` flag reports commands that also asked the terminal host for an
//! action, which a remote client cannot perform.
use axum::Json;
use axum::extract::{Path, State};
use codewhale_command_contract::facets::{
DebugCostProjection, DebugRouteCost, DebugSystemPrompt, DebugTokenProjection,
};
use codewhale_command_contract::types::{CommandCurrency, CommandMode};
use codewhale_engine::command_catalog::{self, BuiltinCommandHostSnapshot};
use codewhale_models::SystemPrompt;
use serde::Deserialize;
use super::{ApiError, RuntimeApiState};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct BuiltinCommandApiRequest {
/// The command name (canonical or alias), with or without the slash.
pub name: String,
/// The argument tail after the command word, if any.
#[serde(default)]
pub args: Option<String>,
}
#[derive(serde::Serialize)]
pub(super) struct BuiltinCommandApiResponse {
pub name: String,
pub text: String,
pub is_error: bool,
/// `false` when the command also asked the terminal host for an action
/// the remote client cannot perform.
pub text_only: bool,
}
pub(super) async fn run_builtin_command(
State(state): State<RuntimeApiState>,
Path(thread_id): Path<String>,
Json(request): Json<BuiltinCommandApiRequest>,
) -> Result<Json<BuiltinCommandApiResponse>, ApiError> {
let catalog = command_catalog::installed()
.ok_or_else(|| ApiError::internal("command catalog is not installed"))?;
// Refuse a name outside the host's remote surface before reading any
// thread state: an unknown command and a known-but-unserved one get the
// same answer, and neither can be distinguished by what the read says
// about the daemon's idle threads.
if !catalog.serves_builtin(&request.name) {
return Err(not_served(&request.name));
}
let thread = state
.runtime_threads
.get_thread(&thread_id)
.await
.map_err(super::map_thread_err)?;
let snapshot = assemble_snapshot(&state, &thread_id, &thread).await?;
match catalog.execute_builtin(&request.name, request.args.as_deref(), &snapshot) {
Some(execution) => Ok(Json(BuiltinCommandApiResponse {
name: execution.name,
text: execution.text,
is_error: execution.is_error,
text_only: execution.text_only,
})),
// The host refused the name: the same answer an unknown command gets.
None => Err(not_served(&request.name)),
}
}
fn not_served(name: &str) -> ApiError {
const MAX_ECHOED_NAME_CHARS: usize = 64;
let shown: String = name.chars().take(MAX_ECHOED_NAME_CHARS).collect();
let ellipsis = if shown.len() < name.len() { "..." } else { "" };
ApiError::not_found(format!(
"no remotely executable builtin command '{shown}{ellipsis}'"
))
}
/// Assemble the host snapshot for one thread from the record, the live
/// engine's context budget, the usage aggregate, and the canonical session
/// document. Every projection field is filled from measured data or left at
/// its honest absence encoding; nothing is estimated into place.
async fn assemble_snapshot(
state: &RuntimeApiState,
thread_id: &str,
thread: &codewhale_engine::runtime_threads::ThreadRecord,
) -> Result<BuiltinCommandHostSnapshot, ApiError> {
// The live engine carries the context budget; an idle daemon thread has
// no honest context estimate, so the served diagnostics refuse rather
// than print zeros.
let engine = state
.runtime_threads
.loaded_engine(thread_id)
.await
.ok_or_else(|| {
ApiError::conflict("this thread's engine is not loaded; send a message first")
})?;
let budget = engine
.get_context_budget()
.await
.map_err(super::map_thread_err)?
.ok_or_else(|| {
ApiError::conflict("this thread has no live context estimate yet; send a message first")
})?;
let usage = state
.runtime_threads
.aggregate_usage_for_thread(thread_id)
.await
.map_err(super::map_thread_err)?;
let parent = &usage.parent;
let children = &usage.routed_children;
let combined = usage.combined();
// The daemon tracks no UI chat history (that is the client's layer), so
// the chat count is a true zero; the model transcript and the effective
// system prompt come from the live engine session.
let session = engine
.get_session_snapshot()
.await
.map_err(|err| ApiError::internal(format!("engine session snapshot unavailable: {err}")))?;
let api_message_count = session.messages.len();
let chat_message_count = 0;
// Currency follows the same rule the TUI's cost display applies, so the
// same config answers the same way on both surfaces: a Simplified-Chinese
// UI reading the default "usd" shows CNY, and every other spelling goes
// through the canonical parser (which accepts rmb and yuan) rather than a
// bare string compare. The CNY column is the provider-published subtotal
// only; a USD-picked currency keeps the authoritative USD numbers.
#[cfg(test)]
let env_ticket = codewhale_runtime::test_support::env_scope_ticket();
let settings = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = codewhale_runtime::test_support::join_env_scope(env_ticket);
codewhale_runtime::settings::Settings::load_read_only()
})
.await
.map_err(|err| ApiError::internal(format!("settings task failed: {err}")))?
.map_err(|err| ApiError::internal(format!("settings unavailable: {err}")))?;
let locale = codewhale_localization::resolve_locale(&settings.locale);
let currency = match (settings.cost_currency.as_str(), locale.tag()) {
("usd", "zh-Hans") => CommandCurrency::Cny,
_ => match codewhale_config::pricing::CostCurrency::from_setting(&settings.cost_currency) {
Some(codewhale_config::pricing::CostCurrency::Cny) => CommandCurrency::Cny,
_ => CommandCurrency::Usd,
},
};
let currency_amount =
|totals: &codewhale_engine::runtime_threads::manager::UsageTotals| match currency {
CommandCurrency::Cny => totals.cost_cny,
CommandCurrency::Usd => totals.cost_usd,
};
let parent_amount = currency_amount(parent);
let children_amount = currency_amount(children);
let total_amount = currency_amount(&combined);
let (priced_turns, unpriced_turns, unpriced_reasons) = match currency {
CommandCurrency::Cny => (
combined.cny_priced_turns,
combined.cny_unpriced_turns,
&combined.cny_unpriced_reasons,
),
CommandCurrency::Usd => (
combined.priced_turns,
combined.unpriced_turns,
&combined.unpriced_reasons,
),
};
let mode = CommandMode::parse(&session.mode).unwrap_or(CommandMode::Agent);
let mode_label = codewhale_config::AppMode::from_setting(&session.mode)
.label()
.to_string();
let system_prompt = match session.system_prompt {
None => DebugSystemPrompt::None,
Some(SystemPrompt::Text(text)) => DebugSystemPrompt::Text(text),
Some(SystemPrompt::Blocks(blocks)) => {
DebugSystemPrompt::Blocks(blocks.into_iter().map(|block| block.text).collect())
}
};
let cost = DebugCostProjection {
currency,
// The combined subtotal is the authoritative total, and it is by
// construction the parent and children amounts added up; the
// terminal's #244 reconciliation floor only exists because its
// display total and its recomputed components can drift apart. The
// remote aggregate cannot drift from itself, so there is no floor to
// report and none is invented.
total: total_amount,
parent_turns: parent_amount,
subagents: children_amount,
display_floor: 0.0,
priced_turns: priced_turns.clamp(0, u32::MAX as u64) as u32,
unpriced_turns: unpriced_turns.clamp(0, u32::MAX as u64) as u32,
legacy_coverage_unknown: !combined.cost_complete,
// Pricing-provenance bookkeeping (user-declared estimates, live
// defects, route receipts) is terminal-session state the daemon
// does not track; the honest encoding is "none recorded".
user_declared_estimates: false,
// Per-route attribution needs per-turn provider identities, which
// the remote snapshot does not assemble yet; the coverage counts
// above stay authoritative. With no route rows, no turn is itemized:
// claiming otherwise would make the renderer state a breakdown it
// cannot show.
itemized_turns: 0,
route_amounts: Vec::<DebugRouteCost>::new(),
turn_history_capacity: 0,
unpriced_reason_labels: unpriced_reasons.iter().cloned().collect(),
unpriced_classes: combined.unpriced_classes.iter().cloned().collect(),
pricing_provenances: Vec::new(),
live_pricing_defects: Vec::new(),
unusable_pricing_defects: Vec::new(),
route_receipts: Vec::new(),
};
Ok(BuiltinCommandHostSnapshot {
mode,
mode_label,
locale,
model: thread.model.clone(),
system_prompt,
tokens: DebugTokenProjection {
active_context_used: (budget.input_tokens as usize).min(budget.window_tokens as usize),
context_window: budget.window_tokens.clamp(0, u32::MAX as u64) as u32,
// The context budget carries no per-turn telemetry, so the
// last-turn columns take their honest absence encoding rather
// than a stale or fabricated value.
last_input: None,
last_output: None,
cache_hit: None,
cache_miss: None,
total_tokens: combined.input_tokens.saturating_add(combined.output_tokens),
cache_write_tokens: combined.cache_write_tokens,
api_message_count,
chat_message_count,
model: thread.model.clone(),
cost,
},
})
}
@@ -0,0 +1,72 @@
use std::path::PathBuf;
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::Deserialize;
use codewhale_engine::fleet::acp::AgentOptions;
use codewhale_engine::fleet::agents::ExecutorUnavailable;
use super::{ApiError, RuntimeApiState, external_agent_refusal};
const MAX_AGENT_ID_BYTES: usize = 64;
const MAX_CWD_BYTES: usize = 4096;
#[derive(Debug, Deserialize)]
pub(super) struct OptionsQuery {
cwd: Option<PathBuf>,
}
fn valid_agent_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_AGENT_ID_BYTES
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
}
pub(super) async fn get_external_agent_options(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
Query(query): Query<OptionsQuery>,
) -> Result<Json<AgentOptions>, ApiError> {
if !valid_agent_id(&id) {
return Err(ApiError::bad_request(
"the agent id must name an agent from GET /v1/agents/external",
));
}
let cwd = query
.cwd
.ok_or_else(|| ApiError::bad_request("cwd is required"))?;
let text = cwd.to_string_lossy();
if text.len() > MAX_CWD_BYTES || text.chars().any(char::is_control) || !cwd.is_absolute() {
return Err(ApiError::bad_request(
"cwd must be an absolute directory path",
));
}
let probe = cwd.clone();
let is_dir = tokio::task::spawn_blocking(move || probe.is_dir())
.await
.map_err(|_| ApiError::internal("cwd check failed"))?;
if !is_dir {
return Err(ApiError::not_found(
"cwd is not a directory on this computer",
));
}
match state
.runtime_threads
.external_agent_options(&id, &cwd)
.await
{
Ok(options) => Ok(Json(options)),
Err(error) => {
let mut refusal = external_agent_refusal(&error);
if let Some(refusal) = refusal.as_mut()
&& refusal.code == Some(ExecutorUnavailable::UNKNOWN_AGENT)
{
refusal.status = axum::http::StatusCode::NOT_FOUND;
}
Err(refusal.unwrap_or_else(|| ApiError::internal(error.to_string())))
}
}
}
@@ -14,10 +14,11 @@ const PROBE_REAP_TIMEOUT: Duration = Duration::from_secs(2);
const SILENT_HANDSHAKE: &str = "It did not answer the Agent Client Protocol handshake.";
use codewhale_engine::fleet::agents::{
AGENTS, ExecutorUnavailable, ExternalAgent, below_minimum, cli_version, foreign_command,
not_installed, resolve,
AGENTS, AgentQuota, ExecutorUnavailable, ExternalAgent, QuotaGate, QuotaSource, QuotaUnknown,
below_minimum, cli_version, foreign_command, local_signin_quota, not_installed, resolve,
};
use codewhale_runtime::process_tree::ProcessTree;
use codewhale_runtime::settings::{AgentSettings, Settings};
#[derive(Debug)]
enum Handshake {
@@ -274,6 +275,75 @@ pub(super) async fn list_external_agents() -> Json<Value> {
Json(json!({ "agents": found }))
}
fn quota_json(quota: &AgentQuota) -> Value {
match quota {
AgentQuota::Known { source, windows } => json!({
"status": "known",
"source": source.as_str(),
"windows": windows
.iter()
.map(|window| json!({
"name": window.name,
"used_percent": window.used_percent,
"resets_at": window.resets_at,
}))
.collect::<Vec<_>>(),
}),
AgentQuota::Unknown(reason) => json!({
"status": "unknown",
"reason": reason.as_str(),
}),
}
}
async fn agent_quota_report(agents: &AgentSettings) -> Value {
let mut entries = Vec::with_capacity(AGENTS.len());
for agent in AGENTS {
let choice = agents.quota(agent.id);
let gate = QuotaGate {
enabled: choice.enabled,
consented: choice.consented_at.is_some(),
};
let quota = local_signin_quota(agent, gate).await;
entries.push(json!({
"id": agent.id,
"name": agent.name,
"quota": quota_json(&quota),
}));
}
for id in agents
.quota
.keys()
.filter(|id| !AGENTS.iter().any(|agent| agent.id == id.as_str()))
{
entries.push(json!({
"id": id,
"name": null,
"quota": quota_json(&AgentQuota::Unknown(QuotaUnknown::UnknownAgent)),
}));
}
json!({
"default_source": QuotaSource::AgentReported.as_str(),
"agents": entries,
})
}
pub(super) async fn get_external_agent_quota() -> Json<Value> {
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
let agents = tokio::task::spawn_blocking(move || {
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
Settings::load_read_only()
})
.await
.ok()
.and_then(Result::ok)
.map(|settings| settings.agents)
.unwrap_or_default();
Json(agent_quota_report(&agents).await)
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
+373 -43
View File
@@ -3,12 +3,20 @@ use std::time::Duration;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use serde::Deserialize;
use serde_json::{Value, json};
use serde::{Deserialize, Serialize};
use codewhale_engine::runtime_threads::job_operations::{
JobOperationTransaction, MEMBER_ABANDONED, MEMBER_ACCEPTED, MEMBER_RESERVED, MEMBER_UNKNOWN,
MemberOperation, MemberOperationRecord, member_request_fingerprint, new_member_agent_id,
valid_member_key,
};
use codewhale_engine::tools::subagent::AgentWorkerRecord;
use super::jobs::operation_error;
use super::{ApiError, RuntimeApiState, map_thread_err};
const MEMBER_START_WAIT: Duration = Duration::from_secs(20);
pub(super) const MAX_MEMBER_REQUEST_BYTES: usize = 2 * 1024 * 1024;
const MAX_MEMBER_PROMPT_CHARS: usize = 256_000;
#[derive(Debug, Deserialize)]
@@ -19,28 +27,148 @@ pub(super) struct StartMemberRequest {
display_name: String,
prompt: String,
fleet: codewhale_engine::fleet::store::FleetFile,
#[serde(default)]
expected_store_id: Option<String>,
#[serde(default)]
request_fingerprint: Option<String>,
}
fn opaque(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value.as_bytes()[0].is_ascii_alphanumeric()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
#[derive(Debug, Clone, Serialize)]
pub(super) struct MemberReceipt {
thread_id: String,
member_id: String,
operation_key: String,
request_fingerprint: String,
runtime_store_id: String,
agent_id: String,
state: String,
}
pub(super) async fn start_thread_member(
State(state): State<RuntimeApiState>,
Path(thread_id): Path<String>,
Json(request): Json<StartMemberRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
let display_name = request.display_name.trim();
if !opaque(&request.operation_key) || !opaque(&request.member_id) {
impl MemberReceipt {
fn new(record: &MemberOperationRecord, state: &str) -> Self {
Self {
thread_id: record.operation.thread_id.clone(),
member_id: record.operation.member_id.clone(),
operation_key: record.operation.operation_key.clone(),
request_fingerprint: record.request_fingerprint.clone(),
runtime_store_id: record.operation.store_id.clone(),
agent_id: record.operation.agent_id.clone(),
state: state.to_string(),
}
}
fn status(&self) -> StatusCode {
if self.state == MEMBER_ACCEPTED {
StatusCode::OK
} else {
StatusCode::ACCEPTED
}
}
}
fn worker_proves(operation: &MemberOperation, worker: Option<&AgentWorkerRecord>) -> bool {
worker.is_some_and(|worker| {
worker.owner_session_id == operation.thread_id
&& worker.spec.worker_id == operation.agent_id
&& worker
.spec
.child_route
.as_ref()
.and_then(|route| route.requested_profile.as_deref())
== Some(operation.member_id.as_str())
})
}
fn project(
transaction: &JobOperationTransaction<'_>,
record: &mut MemberOperationRecord,
worker: Option<&AgentWorkerRecord>,
generation: &str,
) -> anyhow::Result<String> {
let proven = worker_proves(&record.operation, worker);
match record.operation.state.as_str() {
MEMBER_ACCEPTED if proven => Ok(MEMBER_ACCEPTED.to_string()),
MEMBER_ACCEPTED => Ok(MEMBER_UNKNOWN.to_string()),
MEMBER_RESERVED if proven => {
settle(transaction, record, MEMBER_ACCEPTED)?;
Ok(MEMBER_ACCEPTED.to_string())
}
MEMBER_RESERVED if record.owner_generation != generation => {
settle(transaction, record, MEMBER_ABANDONED)?;
Ok(MEMBER_ABANDONED.to_string())
}
MEMBER_RESERVED => Ok(MEMBER_UNKNOWN.to_string()),
state => Ok(state.to_string()),
}
}
fn settle(
transaction: &JobOperationTransaction<'_>,
record: &mut MemberOperationRecord,
state: &str,
) -> anyhow::Result<()> {
record.operation.state = state.to_string();
record.operation.updated_at_ms = chrono::Utc::now()
.timestamp_millis()
.max(record.operation.created_at_ms);
transaction.save_member(record)
}
async fn member_worker(
state: &RuntimeApiState,
thread_id: &str,
agent_id: &str,
) -> Option<AgentWorkerRecord> {
state
.sub_agent_manager
.read()
.await
.get_worker_record_for_session(thread_id, agent_id)
}
async fn settle_outcome(
state: RuntimeApiState,
operation: &MemberOperation,
outcome: Option<Result<String, String>>,
) -> Result<(), ApiError> {
let worker = match &outcome {
Some(Ok(agent_id)) => member_worker(&state, &operation.thread_id, agent_id).await,
_ => None,
};
let id = operation.id.clone();
let store = state.runtime_threads.job_operation_store();
tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let Some(mut record) = transaction.get_member(&id)? else {
return Ok(());
};
if record.operation.state != MEMBER_RESERVED {
return Ok(());
}
match outcome {
Some(Ok(agent_id))
if agent_id == record.operation.agent_id
&& worker_proves(&record.operation, worker.as_ref()) =>
{
settle(transaction, &mut record, MEMBER_ACCEPTED)
}
Some(Err(_)) => transaction.remove_member(&id),
_ => Ok(()),
}
})
})
.await
.map_err(|_| ApiError::internal("member operation settlement failed"))?
.map_err(operation_error)
}
fn validate(request: &StartMemberRequest) -> Result<(String, String), ApiError> {
if !valid_member_key(&request.operation_key) || !valid_member_key(&request.member_id) {
return Err(ApiError::bad_request(
"operation_key and member_id must be 1-128 URL-safe characters",
));
}
let display_name = request.display_name.trim();
if display_name.is_empty()
|| display_name.chars().count() > 80
|| display_name.chars().any(char::is_control)
@@ -55,47 +183,249 @@ pub(super) async fn start_thread_member(
"prompt must contain 1-256000 characters",
));
}
let engine = state
Ok((display_name.to_string(), prompt.to_string()))
}
pub(super) async fn start_thread_member(
State(state): State<RuntimeApiState>,
Path(thread_id): Path<String>,
Json(request): Json<StartMemberRequest>,
) -> Result<(StatusCode, Json<MemberReceipt>), ApiError> {
let (display_name, prompt) = validate(&request)?;
let store_id = state.runtime_threads.job_operation_store_id().to_string();
if request
.expected_store_id
.as_deref()
.is_some_and(|expected| expected != store_id)
{
return Err(ApiError::conflict("member operation store changed")
.with_code("member_operation_store_changed"));
}
let fingerprint = member_request_fingerprint(
&thread_id,
&request.member_id,
&display_name,
&prompt,
&request.fleet,
)
.map_err(|_| ApiError::internal("member request fingerprint failed"))?;
if request
.request_fingerprint
.as_deref()
.is_some_and(|claimed| claimed != fingerprint)
{
return Err(
ApiError::bad_request("request_fingerprint does not match this request")
.with_code("member_request_fingerprint_mismatch"),
);
}
state
.runtime_threads
.get_engine(&thread_id)
.get_thread(&thread_id)
.await
.map_err(map_thread_err)?;
let (tx, rx) = tokio::sync::oneshot::channel();
engine
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(operation_error)?;
let store = state.runtime_threads.job_operation_store();
let key = request.operation_key.clone();
let lookup_thread = thread_id.clone();
let existing = {
let store = store.clone();
tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let id = transaction.member_operation_id(&lookup_thread, &key)?;
transaction.get_member(&id)
})
})
.await
.map_err(|_| ApiError::internal("member operation lookup failed"))?
.map_err(operation_error)?
};
if let Some(existing) = existing {
if existing.request_fingerprint != fingerprint {
return Err(
ApiError::conflict("member operation key belongs to a different request")
.with_code("member_operation_mismatch"),
);
}
let receipt = replay(&state, existing, generation).await?;
if receipt.state == MEMBER_ABANDONED {
return Err(ApiError::conflict(
"member operation was abandoned before it started; use a new operation_key",
)
.with_code("member_operation_abandoned"));
}
return Ok((receipt.status(), Json(receipt)));
}
let reserve_thread = thread_id.clone();
let reserve_key = request.operation_key.clone();
let reserve_member = request.member_id.clone();
let reserve_fingerprint = fingerprint.clone();
let reserve_generation = generation.clone();
let reserved = {
let store = store.clone();
tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let id = transaction.member_operation_id(&reserve_thread, &reserve_key)?;
if let Some(existing) = transaction.get_member(&id)? {
return Ok(Err(existing));
}
let now = chrono::Utc::now().timestamp_millis();
let record = MemberOperationRecord {
schema_version: 1,
operation: MemberOperation {
id,
store_id,
thread_id: reserve_thread,
member_id: reserve_member,
operation_key: reserve_key,
agent_id: new_member_agent_id(),
state: MEMBER_RESERVED.to_string(),
created_at_ms: now,
updated_at_ms: now,
},
request_fingerprint: reserve_fingerprint,
owner_generation: reserve_generation,
};
transaction.save_member(&record)?;
Ok(Ok(record))
})
})
.await
.map_err(|_| ApiError::internal("member operation reservation failed"))?
.map_err(operation_error)?
};
let record = match reserved {
Ok(record) => record,
Err(existing) => {
if existing.request_fingerprint != fingerprint {
return Err(ApiError::conflict(
"member operation key belongs to a different request",
)
.with_code("member_operation_mismatch"));
}
let receipt = replay(&state, existing, generation).await?;
return Ok((receipt.status(), Json(receipt)));
}
};
let engine = match state.runtime_threads.get_engine(&thread_id).await {
Ok(engine) => engine,
Err(error) => {
settle_outcome(state.clone(), &record.operation, Some(Err(String::new()))).await?;
return Err(map_thread_err(error));
}
};
let (tx, mut rx) = tokio::sync::oneshot::channel();
if let Err(error) = engine
.send(codewhale_engine::core::ops::Op::StartFleetMember {
start: Box::new(codewhale_engine::tools::subagent::FleetMemberStart {
operation_key: request.operation_key.clone(),
fleet: request.fleet,
member_id: request.member_id.clone(),
display_name: display_name.to_string(),
prompt: prompt.to_string(),
display_name,
prompt,
reserved_agent_id: Some(record.operation.agent_id.clone()),
}),
tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))),
})
.await
.map_err(|err| ApiError::internal(format!("Failed to reach the thread's engine: {err}")))?;
match tokio::time::timeout(MEMBER_START_WAIT, rx).await {
Ok(Ok(Ok(agent_id))) => Ok((
StatusCode::OK,
Json(json!({
"thread_id": thread_id,
"member_id": request.member_id,
"agent_id": agent_id,
"state": "started",
})),
)),
Ok(Ok(Err(message))) => Err(ApiError::conflict(message)),
{
settle_outcome(state.clone(), &record.operation, Some(Err(String::new()))).await?;
return Err(ApiError::internal(format!(
"Failed to reach the thread's engine: {error}"
)));
}
match tokio::time::timeout(MEMBER_START_WAIT, &mut rx).await {
Ok(Ok(Ok(agent_id))) => {
let settled = settle_outcome(state.clone(), &record.operation, Some(Ok(agent_id)))
.await
.is_ok();
let receipt = if settled {
replay(&state, record.clone(), generation)
.await
.unwrap_or_else(|_| MemberReceipt::new(&record, MEMBER_UNKNOWN))
} else {
MemberReceipt::new(&record, MEMBER_UNKNOWN)
};
Ok((receipt.status(), Json(receipt)))
}
Ok(Ok(Err(message))) => {
settle_outcome(state.clone(), &record.operation, Some(Err(message.clone()))).await?;
Err(ApiError::conflict(message))
}
Ok(Err(_)) => Err(ApiError::internal(
"The engine closed before starting the member",
)),
Err(_) => Ok((
StatusCode::ACCEPTED,
Json(json!({
"thread_id": thread_id,
"member_id": request.member_id,
"operation_key": request.operation_key,
"state": "queued",
})),
)),
Err(_) => {
let operation = record.operation.clone();
let background = state.clone();
tokio::spawn(async move {
let outcome = rx.await.ok();
if let Err(error) = settle_outcome(background, &operation, outcome).await {
tracing::warn!(error = %error.message, "member operation settlement failed");
}
});
let receipt = MemberReceipt::new(&record, MEMBER_UNKNOWN);
Ok((receipt.status(), Json(receipt)))
}
}
}
async fn replay(
state: &RuntimeApiState,
record: MemberOperationRecord,
generation: String,
) -> Result<MemberReceipt, ApiError> {
let worker = member_worker(
state,
&record.operation.thread_id,
&record.operation.agent_id,
)
.await;
let store = state.runtime_threads.job_operation_store();
let id = record.operation.id.clone();
tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let mut record = transaction.get_member(&id)?.unwrap_or(record);
let state = project(transaction, &mut record, worker.as_ref(), &generation)?;
Ok(MemberReceipt::new(&record, &state))
})
})
.await
.map_err(|_| ApiError::internal("member operation lookup failed"))?
.map_err(operation_error)
}
pub(super) async fn get_member_operation(
State(state): State<RuntimeApiState>,
Path((thread_id, key)): Path<(String, String)>,
) -> Result<Json<MemberReceipt>, ApiError> {
if !valid_member_key(&key) {
return Err(ApiError::bad_request("invalid member operation key"));
}
state
.runtime_threads
.get_thread(&thread_id)
.await
.map_err(map_thread_err)?;
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(operation_error)?;
let store = state.runtime_threads.job_operation_store();
let record = tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let id = transaction.member_operation_id(&thread_id, &key)?;
transaction.get_member(&id)
})
})
.await
.map_err(|_| ApiError::internal("member operation lookup failed"))?
.map_err(operation_error)?
.ok_or_else(|| ApiError::not_found("member operation not found"))?;
Ok(Json(replay(&state, record, generation).await?))
}
+590 -39
View File
@@ -42,6 +42,102 @@ use super::{ApiError, RuntimeApiState};
const PLUGIN_UI_MAX_BYTES: u64 = 8 * 1024 * 1024;
const PLUGIN_UI_SLOT: &str = "customize.plugins.detail";
const PLUGIN_UI_SEATS: [&str; 6] = [
PLUGIN_UI_SLOT,
"conversation.composer.dock",
"conversation.chat.node",
"workpane.panel",
"sidebar.section",
"command.row",
];
const DSH_BOOTSTRAP_ENTRY: &str = "__codewhale_dsh_bootstrap.js";
const DSH_FRAME_MODULES: [&str; 4] = [
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
];
const DSH_ROUTE_METHODS: [&str; 5] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
enum DshSeat {
Seat(&'static str),
Unsupported(&'static str),
}
const DSH_SEAT_TABLE: [(&str, DshSeat); 30] = [
("plugins.bundle.config", DshSeat::Seat(PLUGIN_UI_SLOT)),
("plugins.row.config", DshSeat::Seat(PLUGIN_UI_SLOT)),
("plugins.tab", DshSeat::Seat(PLUGIN_UI_SLOT)),
("plugins.detail.section", DshSeat::Seat(PLUGIN_UI_SLOT)),
("settings.section", DshSeat::Seat(PLUGIN_UI_SLOT)),
("settings.plugin.item", DshSeat::Seat(PLUGIN_UI_SLOT)),
("settings.general.item", DshSeat::Seat(PLUGIN_UI_SLOT)),
("settings.plugins.tab", DshSeat::Seat(PLUGIN_UI_SLOT)),
(
"conversation.chat.node",
DshSeat::Seat("conversation.chat.node"),
),
(
"conversation.chat.turnTail",
DshSeat::Seat("conversation.chat.node"),
),
(
"conversation.chat.assistant-actions",
DshSeat::Seat("conversation.chat.node"),
),
(
"conversation.chat.text-interaction",
DshSeat::Seat("conversation.chat.node"),
),
(
"conversation.user.text.node",
DshSeat::Seat("conversation.chat.node"),
),
(
"tool.call.toolview",
DshSeat::Seat("conversation.chat.node"),
),
("conversation.view", DshSeat::Seat("conversation.chat.node")),
(
"conversation.input.dock",
DshSeat::Seat("conversation.composer.dock"),
),
(
"conversation.input.overlay",
DshSeat::Seat("conversation.composer.dock"),
),
(
"conversation.input.right",
DshSeat::Seat("conversation.composer.dock"),
),
(
"conversation.composer.dock",
DshSeat::Seat("conversation.composer.dock"),
),
("sidebar.right.pane.tab", DshSeat::Seat("workpane.panel")),
("main.conversation", DshSeat::Seat("workpane.panel")),
("main.page-menu", DshSeat::Seat("workpane.panel")),
("sidebar.panellist", DshSeat::Seat("sidebar.section")),
("sidebar.workspaces", DshSeat::Seat("sidebar.section")),
("sidebar.footer.action", DshSeat::Seat("sidebar.section")),
(
"sidebar.session.row.leading",
DshSeat::Seat("sidebar.section"),
),
(
"sidebar.workspaces.session.row.action",
DshSeat::Seat("sidebar.section"),
),
(
"conversation.session.header.actions",
DshSeat::Unsupported("no ADE seat yet"),
),
(
"conversation.session.header.utilities",
DshSeat::Unsupported("no ADE seat yet"),
),
("contextMenu.icon", DshSeat::Unsupported("no ADE seat yet")),
];
// ---------------------------------------------------------------------------
// Response shapes
@@ -176,9 +272,20 @@ pub(super) struct PluginActionResponse {
pub(super) note: Option<&'static str>,
}
#[derive(Debug, Serialize)]
pub(super) struct PluginUiBridge {
pub(super) kind: &'static str,
pub(super) dsh_version: String,
pub(super) compatibility_digest: String,
}
#[derive(Debug, Serialize)]
pub(super) struct PluginUiSlotResponse {
pub(super) slot: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) dsh_slot: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) bridge: Option<PluginUiBridge>,
pub(super) base: String,
pub(super) entry: String,
pub(super) revision: String,
@@ -209,6 +316,7 @@ pub(super) struct InstallPluginRequest {
#[derive(Debug, Deserialize)]
pub(super) struct PluginUiQuery {
pub(super) slot: Option<String>,
pub(super) dsh_slot: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -519,6 +627,18 @@ struct PluginUiGrant {
ui_prefix: PathBuf,
file_hashes: Arc<BTreeMap<PathBuf, String>>,
expires: std::time::Instant,
dsh: Option<Arc<DshServed>>,
}
struct DshClosure {
package: String,
entry: String,
files: BTreeMap<String, String>,
host_provided: Vec<String>,
}
struct DshServed {
closure: DshClosure,
}
#[derive(Default)]
@@ -548,6 +668,12 @@ impl PluginUiGrants {
}
}
fn revoke_selector(&self, selector: &str) {
self.entries
.lock()
.retain(|_, value| value.selector != selector);
}
fn get(&self, revision: &str) -> Option<PluginUiGrant> {
let mut entries = self.entries.lock();
let now = std::time::Instant::now();
@@ -561,7 +687,8 @@ fn ui_file_refusal(plugin: &LoadedPlugin, display: &str, error: UiFileError) ->
UiFileError::Missing => ApiError::not_found(format!(
"plugin '{}' has no staged file '{display}'",
plugin.name()
)),
))
.with_code("plugin_ui_file_not_reviewed"),
UiFileError::TooLarge => ApiError::payload_too_large(format!(
"plugin '{}' file '{display}' exceeds the 8 MiB plugin UI limit",
plugin.name()
@@ -656,6 +783,15 @@ fn admit_ui_grant(state: &RuntimeApiState, grant: &PluginUiGrant) -> Result<(),
Ok(())
}
fn valid_dsh_slot_key(key: &str) -> bool {
let mut chars = key.chars();
key.len() <= 128
&& chars
.next()
.is_some_and(|first| first.is_ascii_alphanumeric())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
fn ui_stale_revision() -> ApiError {
ApiError {
status: StatusCode::PRECONDITION_FAILED,
@@ -664,10 +800,300 @@ fn ui_stale_revision() -> ApiError {
}
}
struct PreparedPluginUi {
entry: String,
dsh_slot: Option<String>,
bridge: Option<PluginUiBridge>,
grant: PluginUiGrant,
}
fn seat_not_admitted(plugin: &LoadedPlugin, seat: &str, dsh_slot: Option<&str>) -> ApiError {
let key = dsh_slot
.map(|key| format!(" key '{key}'"))
.unwrap_or_default();
ApiError::not_found(format!(
"plugin '{}' has no admitted client for seat '{seat}'{key}",
plugin.name()
))
.with_code("plugin_ui_seat_not_admitted")
}
fn ui_unsupported(message: impl Into<String>) -> ApiError {
ApiError::bad_request(message).with_code("plugin_ui_unsupported")
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn dsh_client_closure(conversion: &Value) -> Option<DshClosure> {
let closure = conversion
.get("client_closures")?
.as_array()?
.iter()
.find(|closure| closure.get("root").and_then(Value::as_str) == Some(""))?;
let files = closure
.get("files")?
.as_object()?
.iter()
.map(|(path, hash)| Some((path.clone(), hash.as_str()?.to_string())))
.collect::<Option<BTreeMap<_, _>>>()?;
let entry = closure.get("entry")?.as_str()?.to_string();
if !files.contains_key(&entry) {
return None;
}
Some(DshClosure {
package: closure
.get("package")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
entry,
files,
host_provided: closure
.get("host_provided")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
})
}
fn verify_dsh_closure(
grant: &PluginUiGrant,
closure: &DshClosure,
) -> Result<Vec<(String, Vec<u8>)>, UiFileError> {
let mut verified = Vec::with_capacity(closure.files.len());
for (path, expected) in &closure.files {
let relative = ui_relative_path(path)
.map_err(|reason| UiFileError::Invalid(format!("closure file '{path}': {reason}")))?;
let bytes = verified_ui_file(grant, &relative)?;
if sha256_hex(&bytes) != *expected {
return Err(UiFileError::Invalid(format!(
"closure file '{path}' no longer matches its reviewed hash"
)));
}
verified.push((path.clone(), bytes));
}
Ok(verified)
}
fn has_esm_syntax(text: &str) -> bool {
text.contains("import.meta")
|| text.lines().any(|line| {
let line = line.trim_start();
[
"import ", "import{", "import\"", "import'", "export ", "export{", "export*",
]
.iter()
.any(|prefix| line.starts_with(prefix))
})
}
fn dsh_closure_unsupported(closure: &DshClosure, verified: &[(String, Vec<u8>)]) -> Option<String> {
let missing: Vec<&str> = closure
.host_provided
.iter()
.map(String::as_str)
.filter(|module| !DSH_FRAME_MODULES.contains(module))
.take(5)
.collect();
if !missing.is_empty() {
return Some(format!(
"this DSH client requires modules the plugin frame does not provide yet: {}",
missing.join(", ")
));
}
for (path, bytes) in verified {
let Ok(text) = std::str::from_utf8(bytes) else {
return Some(format!("closure file '{path}' is not UTF-8 text"));
};
if path.ends_with(".json") {
if serde_json::from_str::<Value>(text).is_err() {
return Some(format!("closure file '{path}' is not valid JSON"));
}
} else if has_esm_syntax(text) {
return Some(format!(
"closure file '{path}' uses ES module syntax; only CommonJS closures are served"
));
}
}
None
}
fn quoted_in(text: &str, key: &str) -> bool {
['"', '\'', '`']
.iter()
.any(|quote| text.contains(&format!("{quote}{key}{quote}")))
}
fn declared_dsh_keys(verified: &[(String, Vec<u8>)]) -> Vec<&'static (&'static str, DshSeat)> {
let texts: Vec<String> = verified
.iter()
.map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
.collect();
DSH_SEAT_TABLE
.iter()
.filter(|(key, _)| texts.iter().any(|text| quoted_in(text, key)))
.collect()
}
fn admit_dsh_seat(
plugin: &LoadedPlugin,
seat: &str,
requested: Option<&str>,
declared: &[&'static (&'static str, DshSeat)],
) -> Result<String, ApiError> {
if let Some(key) = requested {
let Some((_, mapped)) = declared.iter().find(|(name, _)| *name == key) else {
return Err(seat_not_admitted(plugin, seat, requested));
};
return match mapped {
DshSeat::Unsupported(reason) => Err(ApiError {
status: StatusCode::UNPROCESSABLE_ENTITY,
message: format!(
"plugin '{}' declares '{key}', which has no Codewhale seat: {reason}",
plugin.name()
),
code: Some("plugin_ui_seat_unsupported"),
}),
DshSeat::Seat(mapped_seat) if *mapped_seat == seat => Ok(key.to_string()),
DshSeat::Seat(_) => Err(seat_not_admitted(plugin, seat, requested)),
};
}
let mut on_seat = declared.iter().filter_map(|(name, mapped)| match mapped {
DshSeat::Seat(mapped_seat) if *mapped_seat == seat => Some(*name),
_ => None,
});
match (on_seat.next(), on_seat.next()) {
(None, _) => Err(seat_not_admitted(plugin, seat, None)),
(Some(key), None) => Ok(key.to_string()),
(Some(_), Some(_)) => Err(ApiError::bad_request(format!(
"plugin '{}' admits more than one client key on seat '{seat}'; pass dsh_slot",
plugin.name()
))
.with_code("plugin_ui_dsh_slot_required")),
}
}
fn dsh_bridge() -> Result<PluginUiBridge, ApiError> {
let receipt: Value = serde_json::from_slice(codewhale_plugins::activation::DSH_COMPATIBILITY)
.map_err(|_| {
ApiError::internal("the pinned DSH compatibility receipt is unreadable")
})?;
let version = receipt
.get("target_dsh_version")
.and_then(Value::as_str)
.ok_or_else(|| ApiError::internal("the pinned DSH compatibility receipt has no version"))?;
Ok(PluginUiBridge {
kind: "dsh-client",
dsh_version: version.to_string(),
compatibility_digest: sha256_hex(codewhale_plugins::activation::DSH_COMPATIBILITY),
})
}
const DSH_BOOTSTRAP_HEAD: &str = r#""use strict";
(function () {
var files = {
"#;
const DSH_BOOTSTRAP_TAIL: &str = r#" var cache = {};
var probes = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
var hostRequire = null;
function normalize(path) {
var out = [];
path.split("/").forEach(function (part) {
if (part === "" || part === ".") return;
if (part === "..") out.pop();
else out.push(part);
});
return out.join("/");
}
function resolve(from, id) {
var base = normalize(from.slice(0, from.lastIndexOf("/")) + "/" + id);
for (var i = 0; i < probes.length; i++) {
if (Object.prototype.hasOwnProperty.call(files, base + probes[i])) return base + probes[i];
}
return null;
}
function load(path) {
if (Object.prototype.hasOwnProperty.call(cache, path)) return cache[path].exports;
var module = { exports: {} };
cache[path] = module;
files[path].call(module.exports, module, module.exports, makeRequire(path));
return module.exports;
}
function makeRequire(from) {
return function (id) {
if (id.charAt(0) === ".") {
var resolved = resolve(from, id);
if (resolved === null) throw new Error("the plugin client requires a file outside its reviewed closure: " + id);
return load(resolved);
}
return hostRequire(id);
};
}
window.__ModuleLoader__.load({
id: PLUGIN_ID,
factory: function (require) {
hostRequire = require;
return load(ENTRY);
}
});
})();
"#;
fn dsh_bootstrap(
selector: &str,
closure: &DshClosure,
verified: &[(String, Vec<u8>)],
) -> Result<Vec<u8>, UiFileError> {
let quote = |text: &str| {
serde_json::to_string(text).map_err(|error| UiFileError::Internal(error.to_string()))
};
let mut out = String::from(DSH_BOOTSTRAP_HEAD);
for (path, bytes) in verified {
let text = std::str::from_utf8(bytes)
.map_err(|_| UiFileError::Invalid(format!("closure file '{path}' is not UTF-8")))?;
out.push_str(" ");
out.push_str(&quote(path)?);
out.push_str(": function (module, exports, require) {\n");
if path.ends_with(".json") {
out.push_str("module.exports = ");
out.push_str(text);
out.push(';');
} else {
out.push_str(text);
}
out.push_str("\n},\n");
}
out.push_str(" };\n");
out.push_str(&format!(
" var PLUGIN_ID = {};\n var ENTRY = {};\n",
quote(if closure.package.is_empty() {
selector
} else {
&closure.package
})?,
quote(&closure.entry)?
));
out.push_str(DSH_BOOTSTRAP_TAIL);
Ok(out.into_bytes())
}
fn prepare_plugin_ui(
state: &RuntimeApiState,
selector: &str,
) -> Result<(String, PluginUiGrant), ApiError> {
seat: &str,
dsh_slot: Option<&str>,
) -> Result<PreparedPluginUi, ApiError> {
let registry = registry_for_state(state);
let plugin = registry
.get(selector)
@@ -718,6 +1144,7 @@ fn prepare_plugin_ui(
ui_prefix: PathBuf::new(),
file_hashes: Arc::new(validated.file_hashes),
expires: std::time::Instant::now() + std::time::Duration::from_secs(3600),
dsh: None,
};
let mut found = None;
for candidate in ["package.json", "source/package.json"] {
@@ -745,6 +1172,7 @@ fn prepare_plugin_ui(
return Err(ApiError::not_found(format!("plugin '{}' declares no dsh.client UI in {}; add dsh.client and exports[\"./client\"] to expose client assets", plugin.name(), ui_relative_display(&manifest_path))).with_code("plugin_ui_not_declared"));
}
let conversion_path = FsPath::new("CONVERSION.json");
let mut closure = None;
if grant.file_hashes.contains_key(conversion_path) {
let bytes = verified_ui_file(&grant, conversion_path)
.map_err(|error| ui_file_refusal(&plugin, "CONVERSION.json", error))?;
@@ -759,16 +1187,39 @@ fn prepare_plugin_ui(
.and_then(Value::as_str)
== Some("dsh")
{
return Err(ApiError::bad_request(
"this DSH client UI was not converted and is not supported by Codewhale yet",
)
.with_code("plugin_ui_unsupported"));
closure = Some(dsh_client_closure(&conversion).ok_or_else(|| {
ui_unsupported(
"this DSH client UI was not converted and is not supported by Codewhale yet",
)
})?);
}
}
grant.ui_prefix = manifest_path
.parent()
.unwrap_or(FsPath::new(""))
.to_path_buf();
if let Some(closure) = closure {
let verified = verify_dsh_closure(&grant, &closure)
.map_err(|error| ui_file_refusal(&plugin, "client closure", error))?;
if let Some(reason) = dsh_closure_unsupported(&closure, &verified) {
return Err(ui_unsupported(format!(
"plugin '{}': {reason}",
plugin.name()
)));
}
let key = admit_dsh_seat(&plugin, seat, dsh_slot, &declared_dsh_keys(&verified))?;
grant.dsh = Some(Arc::new(DshServed { closure }));
admit_ui_grant(state, &grant)?;
return Ok(PreparedPluginUi {
entry: DSH_BOOTSTRAP_ENTRY.to_string(),
dsh_slot: Some(key),
bridge: Some(dsh_bridge()?),
grant,
});
}
if seat != PLUGIN_UI_SLOT || dsh_slot.is_some() {
return Err(seat_not_admitted(&plugin, seat, dsh_slot));
}
let raw = ui_client_entry(&manifest).unwrap_or_else(|| "client.js".into());
let relative = ui_relative_path(&raw).map_err(|reason| {
ApiError::bad_request(format!(
@@ -779,7 +1230,12 @@ fn prepare_plugin_ui(
verified_ui_file(&grant, &grant.ui_prefix.join(&relative))
.map_err(|error| ui_file_refusal(&plugin, &ui_relative_display(&relative), error))?;
admit_ui_grant(state, &grant)?;
Ok((ui_relative_display(&relative), grant))
Ok(PreparedPluginUi {
entry: ui_relative_display(&relative),
dsh_slot: None,
bridge: None,
grant,
})
}
fn plugin_ui_base(
@@ -909,6 +1365,8 @@ async fn run_plugin_mutation(
PluginMutationOutcome::Uninstalled => "uninstalled",
};
state.plugin_ui_grants.revoke_selector(&receipt.name);
// Mutations can change merged plugin MCP servers. Advance the existing
// shared generation so each captured workspace pool reloads on next use.
state
@@ -984,6 +1442,8 @@ async fn run_registry_mutation(
ApiError::conflict(format!("{action} failed for '{selector}': {error}"))
})?;
state.plugin_ui_grants.revoke_selector(selector);
state
.workspace_scopes
.mcp_generation
@@ -1265,43 +1725,58 @@ pub(super) async fn get_plugin_ui(
));
}
let slot = query.slot.ok_or_else(|| {
ApiError::bad_request(
"the 'slot' query parameter is required; supported slot: 'customize.plugins.detail'",
)
ApiError::bad_request(format!(
"the 'slot' query parameter is required; seats: {}",
PLUGIN_UI_SEATS.join(", ")
))
.with_code("plugin_ui_seat_unknown")
})?;
if slot.as_str() != PLUGIN_UI_SLOT {
if !PLUGIN_UI_SEATS.contains(&slot.as_str()) {
return Err(ApiError::bad_request(format!(
"slot '{slot}' is not supported; supported slot: 'customize.plugins.detail'"
)));
"slot '{slot}' is not a plugin UI seat; seats: {}",
PLUGIN_UI_SEATS.join(", ")
))
.with_code("plugin_ui_seat_unknown"));
}
if let Some(key) = &query.dsh_slot
&& !valid_dsh_slot_key(key)
{
return Err(ApiError::bad_request(
"dsh_slot must be 1 to 128 characters of letters, digits, '.', '_' or '-', starting with a letter or digit",
)
.with_code("plugin_ui_dsh_slot_invalid"));
}
let state_for_prepare = state.clone();
let selector_for_prepare = selector.clone();
let (entry, grant) = tokio::task::spawn_blocking(move || {
prepare_plugin_ui(&state_for_prepare, &selector_for_prepare)
let seat = slot.clone();
let requested_key = query.dsh_slot.clone();
let prepared = tokio::task::spawn_blocking(move || {
prepare_plugin_ui(
&state_for_prepare,
&selector_for_prepare,
&seat,
requested_key.as_deref(),
)
})
.await
.map_err(|_| ApiError::internal("plugin UI preparation failed"))??;
let base = plugin_ui_base(&state, &selector, &headers, &uri)?;
let revision = state.plugin_ui_grants.insert(grant);
let revision = state.plugin_ui_grants.insert(prepared.grant);
Ok(Json(PluginUiSlotResponse {
slot,
dsh_slot: prepared.dsh_slot,
bridge: prepared.bridge,
base,
entry,
entry: prepared.entry,
revision,
}))
}
pub(super) async fn get_plugin_ui_asset(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin UI assets are available only from loopback",
));
}
fn grant_for_request(
state: &RuntimeApiState,
selector: &str,
headers: &HeaderMap,
) -> Result<(PluginUiGrant, String), ApiError> {
let mut values = headers.get_all(header::IF_MATCH).iter();
let first = values.next().ok_or_else(|| ApiError {
status: StatusCode::PRECONDITION_REQUIRED,
@@ -1319,25 +1794,101 @@ pub(super) async fn get_plugin_ui_asset(
if grant.selector != selector || grant.authority.workspace != state.workspace {
return Err(ui_stale_revision());
}
Ok((grant, revision))
}
pub(super) async fn plugin_route_bridge(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
method: axum::http::Method,
headers: HeaderMap,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin routes are available only from loopback",
));
}
let (grant, revision) = grant_for_request(&state, &selector, &headers)?;
let state_for_route = state.clone();
tokio::task::spawn_blocking(move || {
codewhale_plugins::registry::verify_plugin_authority(&grant.authority)
.map_err(|_| ui_stale_revision())?;
admit_ui_grant(&state_for_route, &grant)?;
if state_for_route.plugin_ui_grants.get(&revision).is_none() {
return Err(ui_stale_revision());
}
if grant.dsh.is_none() {
return Err(ApiError::forbidden(
"this plugin UI grant has no DSH client and no plugin routes",
)
.with_code("plugin_route_not_admitted"));
}
Ok(())
})
.await
.map_err(|_| ApiError::internal("plugin route check failed"))??;
ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!("plugin route path '{path}' is invalid: {reason}"))
})?;
if !DSH_ROUTE_METHODS.contains(&method.as_str()) {
return Err(ApiError {
status: StatusCode::METHOD_NOT_ALLOWED,
message: format!("plugin routes do not accept {method}"),
code: None,
});
}
Err(ApiError::not_implemented(
"this Engine has no plugin web server to forward the route to yet",
)
.with_code("plugin_route_not_served"))
}
pub(super) async fn get_plugin_ui_asset(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin UI assets are available only from loopback",
));
}
let (grant, revision) = grant_for_request(&state, &selector, &headers)?;
let relative = ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!("plugin UI path '{path}' is invalid: {reason}"))
})?;
let content_type = ui_content_type(&relative);
if grant.dsh.is_some() && relative != FsPath::new(DSH_BOOTSTRAP_ENTRY) {
return Err(
ApiError::not_found("plugin UI file is not in the reviewed client closure")
.with_code("plugin_ui_file_not_reviewed"),
);
}
let state_for_asset = state.clone();
let bytes = tokio::task::spawn_blocking(move || {
codewhale_plugins::registry::verify_plugin_authority(&grant.authority)
.map_err(|_| ui_stale_revision())?;
let bytes = verified_ui_file(&grant, &grant.ui_prefix.join(&relative)).map_err(
|error| match error {
UiFileError::Missing => {
ApiError::not_found("plugin UI file is not in the reviewed bundle")
}
UiFileError::TooLarge => {
ApiError::payload_too_large("plugin UI file exceeds the 8 MiB limit")
}
_ => ui_stale_revision(),
},
)?;
let bytes = if let Some(dsh) = &grant.dsh {
let verified =
verify_dsh_closure(&grant, &dsh.closure).map_err(|_| ui_stale_revision())?;
dsh_bootstrap(&grant.selector, &dsh.closure, &verified)
.map_err(|_| ui_stale_revision())?
} else {
verified_ui_file(&grant, &grant.ui_prefix.join(&relative)).map_err(
|error| match error {
UiFileError::Missing => {
ApiError::not_found("plugin UI file is not in the reviewed bundle")
.with_code("plugin_ui_file_not_reviewed")
}
UiFileError::TooLarge => {
ApiError::payload_too_large("plugin UI file exceeds the 8 MiB limit")
}
_ => ui_stale_revision(),
},
)?
};
admit_ui_grant(&state_for_asset, &grant)?;
if state_for_asset.plugin_ui_grants.get(&revision).is_none() {
return Err(ui_stale_revision());
+52 -108
View File
@@ -18,9 +18,16 @@ use uuid::Uuid;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
#[cfg(unix)]
mod external_agent_options;
#[cfg(unix)]
mod external_agent_quota;
mod headless_catalog;
#[cfg(any(unix, windows))]
mod member_operations;
#[cfg(unix)]
mod owner_bootstrap;
mod plugin_ui;
mod profile_constitution;
#[cfg(any(unix, windows))]
mod runtime_store_convergence;
@@ -8192,18 +8199,13 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
);
let snapshot_count = repo.list(usize::MAX)?.len();
let (preview, paths) =
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), true, true, None)
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), true, None)
.expect("a trusted preview should succeed");
assert!(!preview.files_restored);
assert_eq!(paths, vec!["a.txt", "made/new.txt"]);
let refused = patch_undo_workspace_files_impl(
&workspace,
std::slice::from_ref(&turn),
true,
false,
Some(&[]),
)
.expect_err("an unreviewed file plan must refuse before restoring");
let refused =
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), false, Some(&[]))
.expect_err("an unreviewed file plan must refuse before restoring");
assert_eq!(refused.status, StatusCode::CONFLICT);
assert_eq!(refused.code, Some("undo_selection_changed"));
@@ -8212,7 +8214,7 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
assert_eq!(fs::read_to_string(&other)?, "later work");
assert_eq!(repo.list(usize::MAX)?.len(), snapshot_count);
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect("a trusted rollback should succeed");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&a)?, "before", "both writes are undone");
@@ -8232,7 +8234,7 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
);
// Undoing again finds the files already at their pre-turn state.
let again = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
let again = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect("already restored is provably nothing to do");
assert!(!again.files_restored);
Ok(())
@@ -8266,7 +8268,7 @@ fn patch_undo_helper_refuses_to_overwrite_later_edits() -> Result<()> {
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
);
let err = patch_undo_workspace_files(&workspace, &[turn], true)
let err = patch_undo_workspace_files(&workspace, &[turn])
.expect_err("a later edit must not be overwritten");
assert_eq!(err.status, StatusCode::CONFLICT);
assert_eq!(err.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
@@ -8322,7 +8324,7 @@ fn patch_undo_helper_fails_honestly_without_an_owned_restore_point() -> Result<(
),
];
for (turn, code) in cases {
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect_err(&turn.turn_id);
assert_eq!(err.status, StatusCode::CONFLICT, "{}", turn.turn_id);
assert_eq!(err.code, Some(code), "{}: {}", turn.turn_id, err.message);
@@ -8330,20 +8332,16 @@ fn patch_undo_helper_fails_honestly_without_an_owned_restore_point() -> Result<(
assert_eq!(fs::read_to_string(&a)?, "after");
}
let chat_only = patch_undo_workspace_files(
&workspace,
&[dropped_turn("chat", false, Vec::new())],
false,
)
.expect("a turn that ran no tools changed no files");
let chat_only =
patch_undo_workspace_files(&workspace, &[dropped_turn("chat", false, Vec::new())])
.expect("a turn that ran no tools changed no files");
assert!(!chat_only.files_restored);
// A prune that drops the restore point's tree fails closed too.
let receipts = shell_turn_receipts(&repo, &pre, &post, "thr_a");
repo.prune_older_than(Duration::ZERO)?;
let err =
patch_undo_workspace_files(&workspace, &[dropped_turn("pruned", true, receipts)], true)
.expect_err("a pruned restore point cannot be restored");
let err = patch_undo_workspace_files(&workspace, &[dropped_turn("pruned", true, receipts)])
.expect_err("a pruned restore point cannot be restored");
assert_eq!(err.code, Some(PATCH_UNDO_RESTORE_POINT_PRUNED));
assert_eq!(fs::read_to_string(&a)?, "after");
Ok(())
@@ -8387,7 +8385,6 @@ fn patch_undo_helper_survives_a_prune_that_rewrites_commit_ids() -> Result<()> {
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
)],
true,
)
.expect("the tree still resolves the restore point");
assert!(restored.files_restored);
@@ -8481,7 +8478,6 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
let err = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, receipts(gap_writer))],
true,
)
.expect_err("another writer's change must not be reverted");
assert_eq!(err.status, StatusCode::CONFLICT);
@@ -8508,12 +8504,9 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
None,
Some(changed_between(&repo, &post_tool, &clean_post)),
);
let restored = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, clean.clone())],
true,
)
.expect("the turn's own write restores");
let restored =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, clean.clone())])
.expect("the turn's own write restores");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&own)?, "before");
@@ -8521,9 +8514,8 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
fs::write(&own, "turn")?;
let mut shell = clean.clone();
shell[1].write_paths = None;
let restored =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, shell)], true)
.expect("an undeclared span owns its changes");
let restored = patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, shell)])
.expect("an undeclared span owns its changes");
assert!(restored.files_restored);
assert_eq!(fs::read_to_string(&own)?, "before");
@@ -8531,9 +8523,8 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
fs::write(&own, "turn")?;
let mut unknown = clean;
unknown[2].changed_paths = None;
let err =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, unknown)], true)
.expect_err("an unaccounted span fails closed");
let err = patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, unknown)])
.expect_err("an unaccounted span fails closed");
assert_eq!(
err.code,
Some(PATCH_UNDO_NO_RESTORE_POINT),
@@ -8594,7 +8585,6 @@ fn patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold() -> Result<(
let err = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, receipts(&[declared]))],
true,
)
.expect_err(declared);
assert_eq!(err.status, StatusCode::CONFLICT, "{declared}");
@@ -8609,24 +8599,24 @@ fn patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold() -> Result<(
// The same declared write from a call recorded as failed wrote nothing.
let mut failed = dropped_turn("turn_1", true, receipts(&[".env.local"]));
failed.unrun_tool_calls.insert("call-1".to_string());
let result = patch_undo_workspace_files(&workspace, &[failed], true)
.expect("a failed call wrote nothing");
let result =
patch_undo_workspace_files(&workspace, &[failed]).expect("a failed call wrote nothing");
assert!(!result.files_restored);
// Declared through the turn's items rather than a receipt.
let mut from_items = dropped_turn("turn_1", true, receipts(&[]));
from_items.declared_writes.push(".env.local".to_string());
let err = patch_undo_workspace_files(&workspace, &[from_items], true)
let err = patch_undo_workspace_files(&workspace, &[from_items])
.expect_err("an item-declared ignored write refuses too");
assert_eq!(err.code, Some(PATCH_UNDO_PATH_NOT_SNAPSHOTTED));
Ok(())
}
/// The gate the TUI's `/undo` applies, stated as a contract: an untrusted
/// thread may not roll files back. When there *is* something to roll back the
/// whole undo aborts — nothing is changed, and the caller must not fork the
/// conversation either.
/// Default posture (no trust mode, no Full Access): the Runtime does not
/// gate on trust at all, because the ownership and later-edit proofs already
/// decide what may be reverted. A later edit in the same workspace still
/// refuses and leaves every byte alone.
#[test]
fn patch_undo_helper_refuses_an_untrusted_rollback_and_touches_nothing() -> Result<()> {
fn patch_undo_helper_restores_in_the_default_posture_and_still_refuses_later_edits() -> Result<()> {
if git_missing() {
return Ok(());
}
@@ -8651,69 +8641,20 @@ fn patch_undo_helper_refuses_an_untrusted_rollback_and_touches_nothing() -> Resu
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
);
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), false)
.expect_err("an untrusted rollback must be refused");
assert_eq!(err.status, StatusCode::CONFLICT);
assert_eq!(err.code, Some(PATCH_UNDO_UNTRUSTED));
assert!(
err.message.contains("outside trusted mode"),
"got: {}",
err.message
);
assert_eq!(
fs::read_to_string(&file)?,
"after",
"a refusal must leave the workspace exactly as it was"
);
fs::write(&file, "edited later by the person")?;
let refused = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect_err("a later edit must not be overwritten");
assert_eq!(refused.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
assert_eq!(fs::read_to_string(&file)?, "edited later by the person");
// The identical call once trusted performs the rollback.
let restored = patch_undo_workspace_files(&workspace, &[turn], true)
.expect("a trusted rollback should succeed");
fs::write(&file, "after")?;
let restored = patch_undo_workspace_files(&workspace, &[turn])
.expect("the turn's own change restores without trust");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&file)?, "before");
Ok(())
}
/// ...but with provably nothing to roll back, an untrusted undo is still
/// allowed: the conversation half needs no trust, and skipping it would make
/// Undo useless in Ask / Auto-Review for no safety gain.
#[test]
fn patch_undo_helper_allows_an_untrusted_undo_with_nothing_to_roll_back() -> Result<()> {
if git_missing() {
return Ok(());
}
let _lock = lock_test_env();
let root = tempfile::tempdir()?;
let home = root.path().join("home");
fs::create_dir_all(&home)?;
let _home = EnvVarGuard::set("HOME", &home);
let workspace = root.path().join("workspace");
fs::create_dir_all(&workspace)?;
let repo = codewhale_snapshot::SnapshotRepo::open_or_init(&workspace)?;
let file = workspace.join("a.txt");
fs::write(&file, "only-state")?;
let pre = repo.take_snapshot("pre-turn:1", Some("thr_a"))?;
// The turn ran a tool but changed nothing.
let post = repo.take_snapshot("post-turn:1", Some("thr_a"))?;
let result = patch_undo_workspace_files(
&workspace,
&[dropped_turn(
"turn_1",
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
)],
false,
)
.expect("nothing to roll back is not a refusal");
assert!(!result.files_restored);
assert!(result.summary.is_some());
assert_eq!(fs::read_to_string(&file)?, "only-state");
Ok(())
}
fn sha256_of(path: &FsPath) -> String {
format!(
"sha256:{}",
@@ -11142,6 +11083,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(())
@@ -26506,8 +26449,9 @@ mod thread_snapshot_ownership {
/// An undo that cannot restore files fails honestly: snapshots disabled,
/// a legacy seeded turn with tool calls and no receipts, a restore point
/// pruned from the store, and an untrusted thread. Each is a 409 with a
/// code, no fork, and unchanged files.
/// pruned from the store. Each is a 409 with a code, no fork, and
/// unchanged files. A default-posture thread (no trust, no Full Access)
/// with an intact restore point is not among them: it restores.
#[tokio::test]
async fn undo_that_cannot_restore_files_is_refused() -> Result<()> {
let _env = lock_test_env();
@@ -26590,7 +26534,7 @@ mod thread_snapshot_ownership {
assert_eq!(fx.thread_count().await?, before + 1);
drop(fx);
// Snapshots on again: a pruned restore point and an untrusted thread.
// Snapshots on again: a pruned restore point, then a default-posture thread.
let root = test_root("honest-on");
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.join("home"));
let Some(fx) = fixture(&root, TestServerOverrides::default()).await? else {
@@ -26612,9 +26556,9 @@ mod thread_snapshot_ownership {
.await?
.error_for_status()?;
let before = fx.thread_count().await?;
refused(&fx, untrusted, "restore_requires_trust").await?;
assert_eq!(fx.read("u.txt").as_deref(), Some("u"));
assert_eq!(fx.thread_count().await?, before);
fx.undo_restoring(&untrusted, 0).await?;
assert_eq!(fx.read("u.txt"), None);
assert_eq!(fx.thread_count().await?, before + 1);
let _ = &fx.root;
Ok(())
}
@@ -828,3 +828,149 @@ async fn turn_detail(
.and_then(|turns| turns.iter().find(|turn| turn["id"] == turn_id))
.map_or_else(|| "turn absent".to_string(), Value::to_string))
}
// ── Remote built-in execution ──
/// A catalog that serves exactly one built-in remotely, so the route's name
/// gate and snapshot hand-off can be observed without the terminal host.
struct RemoteBuiltinCatalog;
impl CommandCatalog for RemoteBuiltinCatalog {
fn answers_to(&self, name: &str) -> bool {
name == "system"
}
fn builtins(&self) -> Vec<BuiltinCommand> {
vec![builtin("system", vec!["xitong".to_string()])]
}
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
Vec::new()
}
fn serves_builtin(&self, name: &str) -> bool {
matches!(name.trim_start_matches('/'), "system" | "xitong")
}
fn execute_builtin(
&self,
name: &str,
_args: Option<&str>,
host: &codewhale_engine::command_catalog::BuiltinCommandHostSnapshot,
) -> Option<codewhale_engine::command_catalog::BuiltinCommandExecution> {
if !self.serves_builtin(name) {
return None;
}
Some(codewhale_engine::command_catalog::BuiltinCommandExecution {
name: "system".to_string(),
text: format!(
"{}|{}|{}",
host.mode_label,
host.model,
match &host.system_prompt {
codewhale_command_contract::facets::DebugSystemPrompt::None => 0,
codewhale_command_contract::facets::DebugSystemPrompt::Text(text) => text.len(),
codewhale_command_contract::facets::DebugSystemPrompt::Blocks(blocks) =>
blocks.iter().map(String::len).sum(),
}
),
is_error: false,
text_only: true,
})
}
}
/// B5: `POST /v1/threads/{id}/commands/builtin` executes only the names the
/// installed host serves, and answers everything else like an unknown name.
#[tokio::test]
async fn post_thread_builtin_command_serves_only_the_host_allowlist() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let token = "builtin-route-fixture-token";
let root = temp.path().join("builtin-commands-route");
let sessions_dir = root.join("sessions");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let _catalog =
codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(RemoteBuiltinCatalog));
let Some((addr, runtime_threads, handle)) = spawn_test_server_with_root_token_mobile_workspace(
root,
sessions_dir,
Some(token.to_string()),
false,
workspace,
)
.await?
else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let thread = runtime_threads
.create_thread(CreateThreadRequest::default())
.await?;
let url = format!("http://{addr}/v1/threads/{}/commands/builtin", thread.id);
let unauthorized = client
.post(&url)
.json(&json!({ "name": "system" }))
.send()
.await?;
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let cold = client
.post(&url)
.bearer_auth(token)
.json(&json!({ "name": "system" }))
.send()
.await?;
assert_eq!(cold.status(), StatusCode::CONFLICT, "cold engine");
runtime_threads.get_engine(&thread.id).await?;
// Not on the host's remote surface: the same answer an unknown name gets.
for name in ["balance", "cache", "not-a-command"] {
let response = client
.post(&url)
.bearer_auth(token)
.json(&json!({ "name": name }))
.send()
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{name}");
}
// A served name (alias spelling included) reaches the host with the
// thread's own state, and its text comes back unchanged.
for name in ["system", "/system", "xitong"] {
let body: Value = client
.post(&url)
.bearer_auth(token)
.json(&json!({ "name": name, "args": "ignored" }))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(body["name"], "system");
assert_eq!(body["is_error"], false);
assert_eq!(body["text_only"], true);
// One snapshot field per segment, so a route that assembled the
// wrong thread's state cannot pass.
let text = body["text"].as_str().context("builtin text")?;
let fields: Vec<&str> = text.split('|').collect();
assert_eq!(fields.len(), 3, "{text}");
assert!(!fields[0].is_empty(), "mode label: {text}");
assert_eq!(fields[1], thread.model, "model: {text}");
let prompt_len: usize = fields[2].parse().context("system prompt length")?;
assert!(prompt_len > 0, "effective system prompt reported: {text}");
}
// The thread id is still the address: a missing thread stays 404.
let missing = client
.post(format!(
"http://{addr}/v1/threads/thr_missing/commands/builtin"
))
.bearer_auth(token)
.json(&json!({ "name": "system" }))
.send()
.await?;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
handle.abort();
Ok(())
}
@@ -0,0 +1,184 @@
use super::*;
use reqwest::StatusCode as HttpStatus;
fn options_fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../engine/tests/fixtures/acp-options-agent.mjs")
.canonicalize()
.expect("the options fixture agent")
}
fn with_query(base: &str, pairs: &[(&str, &str)]) -> reqwest::Url {
let mut url = reqwest::Url::parse(base).expect("a url");
for (key, value) in pairs {
url.query_pairs_mut().append_pair(key, value);
}
url
}
fn received(workspace: &Path) -> Vec<Value> {
fs::read_to_string(workspace.join("options-received.jsonl"))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
async fn wait_for_usage_total(
manager: &SharedRuntimeThreadManager,
thread_id: &str,
) -> Result<RuntimeEventRecord> {
for _ in 0..600 {
let events = manager.events_since_async(thread_id, None).await?;
if let Some(event) = events.into_iter().find(|event| {
event.event == "turn.agent_usage" && event.payload["kind"] == "turn_total"
}) {
return Ok(event);
}
sleep(Duration::from_millis(50)).await;
}
bail!("the agent's usage was never recorded")
}
#[tokio::test]
async fn the_options_route_offers_the_agents_choices_and_a_chosen_one_reaches_it() -> Result<()> {
let _env = lock_test_env();
let _fixture = EnvVarGuard::set("CODEWHALE_ACP_FIXTURE_AGENT", options_fixture());
let Some((addr, manager, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let workspace = tempfile::tempdir()?;
let cwd = workspace.path().canonicalize()?;
let url = format!("http://{addr}/v1/agents/external/fixture/options");
let offered: Value = client
.get(with_query(&url, &[("cwd", cwd.to_str().unwrap())]))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(offered["agent"]["id"], "fixture");
let controls = offered["controls"].as_array().unwrap();
let model = controls.iter().find(|c| c["id"] == "model").unwrap();
let values: Vec<&str> = model["choices"]
.as_array()
.unwrap()
.iter()
.map(|c| c["value"].as_str().unwrap())
.collect();
assert!(values.len() >= 2, "{values:?}");
assert!(controls.iter().any(|c| c["id"] == "mode"));
assert!(
controls.iter().any(|c| c["id"] == "config:verbose"
&& c["unavailable"]["code"] == "control_type_unsupported")
);
assert!(
offered["actions"]
.as_array()
.unwrap()
.iter()
.any(|a| a["action"] == "steering" && a["supported"] == false)
);
let leaked = fs::read_to_string(cwd.join("options-env.json"))?;
assert!(!leaked.contains("CODEWHALE_ACP_FIXTURE_AGENT"));
for (query, status, code) in [
(vec![], HttpStatus::BAD_REQUEST, None),
(vec![("cwd", "relative/dir")], HttpStatus::BAD_REQUEST, None),
(
vec![("cwd", "/definitely/not/here")],
HttpStatus::NOT_FOUND,
None,
),
] {
let response = client.get(with_query(&url, &query)).send().await?;
assert_eq!(response.status(), status, "{query:?}");
let body: Value = response.json().await?;
assert_eq!(body["error"]["code"].as_str(), code, "{body}");
}
let unknown = client
.get(with_query(
&format!("http://{addr}/v1/agents/external/nope/options"),
&[("cwd", cwd.to_str().unwrap())],
))
.send()
.await?;
assert_eq!(unknown.status(), HttpStatus::NOT_FOUND);
let body: Value = unknown.json().await?;
assert_eq!(body["error"]["code"], "unknown_agent");
let bad_id = client
.get(with_query(
&format!("http://{addr}/v1/agents/external/..%2Fsh/options"),
&[("cwd", cwd.to_str().unwrap())],
))
.send()
.await?;
assert!(bad_id.status().is_client_error());
let chosen = values[1];
let created: Value = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"workspace": cwd,
"permission_posture": "ask",
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": chosen}}
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let thread_id = created["id"].as_str().unwrap().to_string();
let refused = client
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
.json(&json!({"prompt": "x", "model": "other"}))
.send()
.await?;
assert_eq!(refused.status(), HttpStatus::FAILED_DEPENDENCY);
let body: Value = refused.json().await?;
assert_eq!(body["error"]["code"], "unsupported_control");
client
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
.json(&json!({"prompt": "hello"}))
.send()
.await?
.error_for_status()?;
let total = wait_for_usage_total(&manager, &thread_id).await?;
assert_eq!(total.payload["source"], "agent reported");
assert_eq!(total.payload["cost"], "unknown");
assert_eq!(total.payload["tokens"]["total_tokens"], 150);
let log = received(&cwd);
let prompt = log
.iter()
.find(|e| e["method"] == "session/prompt")
.unwrap();
assert_eq!(prompt["state"]["model"], chosen);
let stale = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"workspace": cwd,
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": "model-z"}}
}))
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
let stale_id = stale["id"].as_str().unwrap();
let response = client
.post(format!("http://{addr}/v1/threads/{stale_id}/turns"))
.json(&json!({"prompt": "x"}))
.send()
.await?;
assert_eq!(response.status(), HttpStatus::FAILED_DEPENDENCY);
let body: Value = response.json().await?;
assert_eq!(body["error"]["code"], "choice_not_offered");
handle.abort();
Ok(())
}
@@ -0,0 +1,202 @@
use super::*;
use reqwest::StatusCode as HttpStatus;
use std::collections::BTreeMap;
const FIXTURE_TOKEN: &str = "cw-fixture-token-7993-do-not-echo";
fn reasons(body: &Value) -> BTreeMap<String, String> {
body["agents"]
.as_array()
.expect("agents array")
.iter()
.map(|entry| {
assert!(
entry["quota"].get("source").is_none(),
"no source without a reader: {entry}"
);
assert_eq!(entry["quota"]["status"], "unknown", "{entry}");
(
entry["id"].as_str().expect("id").to_string(),
entry["quota"]["reason"]
.as_str()
.expect("reason")
.to_string(),
)
})
.collect()
}
fn snapshot(paths: &[PathBuf]) -> Vec<(Vec<u8>, std::time::SystemTime)> {
paths
.iter()
.map(|path| {
(
fs::read(path).expect("fixture credential"),
fs::metadata(path)
.and_then(|meta| meta.modified())
.expect("mtime"),
)
})
.collect()
}
#[tokio::test]
async fn agent_quota_reads_nothing_until_flag_and_consent_and_never_echoes_a_token() -> Result<()> {
let _env = lock_test_env();
let root = tempfile::tempdir()?;
let home = root.path().join("home");
let mut credentials = Vec::new();
for (dir, file) in [
(".claude", ".credentials.json"),
(".codex", "auth.json"),
(".gemini", "oauth_creds.json"),
(".kimi", "credentials.json"),
(".grok", "auth.json"),
] {
let dir = home.join(dir);
fs::create_dir_all(&dir)?;
let path = dir.join(file);
fs::write(
&path,
format!(r#"{{"access_token":"{FIXTURE_TOKEN}","refresh_token":"{FIXTURE_TOKEN}"}}"#),
)?;
credentials.push(path);
}
let before = snapshot(&credentials);
let config_file = root.path().join("config").join("config.toml");
fs::create_dir_all(config_file.parent().unwrap())?;
fs::write(
&config_file,
"provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\n",
)?;
let _home = EnvVarGuard::set("HOME", &home);
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file);
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let mut seen = String::new();
let read = || {
let client = client.clone();
let url = format!("http://{addr}/v1/agents/external/quota");
async move {
let text = client
.get(url)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok::<String, anyhow::Error>(text)
}
};
let set = |key: &'static str, value: &'static str| {
let client = client.clone();
let url = format!("http://{addr}/v1/config");
async move {
let response = client
.post(url)
.json(&json!({"key": key, "value": value, "persist": true}))
.send()
.await?;
let status = response.status();
Ok::<(HttpStatus, String), anyhow::Error>((status, response.text().await?))
}
};
let text = read().await?;
seen.push_str(&text);
let body: Value = serde_json::from_str(&text)?;
assert_eq!(body["default_source"], "agent_reported");
let initial = reasons(&body);
assert_eq!(initial.len(), codewhale_engine::fleet::agents::AGENTS.len());
assert!(
initial.values().all(|reason| reason == "flag_off"),
"{initial:?}"
);
for (key, value) in [
("agents.quota.claude", "on"),
("agents.quota.codex.consent", "granted"),
] {
let (status, text) = set(key, value).await?;
seen.push_str(&text);
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
}
let text = read().await?;
seen.push_str(&text);
let gated = reasons(&serde_json::from_str(&text)?);
assert_eq!(gated["claude"], "consent_missing");
assert_eq!(gated["codex"], "flag_off");
assert_eq!(gated["grok"], "flag_off");
for (key, value) in [
("agents.quota.claude.consent", "granted"),
("agents.quota.codex", "true"),
] {
let (status, text) = set(key, value).await?;
seen.push_str(&text);
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
}
let saved = codewhale_runtime::settings::Settings::load_persisted()?;
assert!(saved.agents.quota("claude").enabled);
assert!(saved.agents.quota("claude").consented_at.is_some());
let text = read().await?;
seen.push_str(&text);
let consented = reasons(&serde_json::from_str(&text)?);
assert_eq!(consented["claude"], "not_implemented");
assert_eq!(consented["codex"], "not_implemented");
assert_eq!(consented["opencode"], "flag_off");
for (key, value) in [
("agents.quota.claude.consent", "revoked"),
("agents.quota.codex", "off"),
] {
let (status, text) = set(key, value).await?;
seen.push_str(&text);
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
}
let text = read().await?;
seen.push_str(&text);
let revoked = reasons(&serde_json::from_str(&text)?);
assert_eq!(revoked["claude"], "consent_missing");
assert_eq!(revoked["codex"], "flag_off");
for (key, value) in [
("agents.quota.claude.consent", FIXTURE_TOKEN),
("agents.quota.Claude/../x", "on"),
("agents.quota.claude", FIXTURE_TOKEN),
] {
let (status, text) = set(key, value).await?;
assert_eq!(status, HttpStatus::BAD_REQUEST, "{key}: {text}");
seen.push_str(&text);
}
let (status, text) = set("agents.quota.not-an-agent", "on").await?;
seen.push_str(&text);
assert_eq!(status, HttpStatus::OK, "{text}");
let text = read().await?;
seen.push_str(&text);
assert_eq!(
reasons(&serde_json::from_str(&text)?)["not-an-agent"],
"unknown_agent"
);
let settings_file = fs::read_to_string(config_file.with_file_name("settings.toml"))?;
assert!(
settings_file.contains("[agents.quota.claude]"),
"{settings_file}"
);
assert!(!settings_file.contains(FIXTURE_TOKEN));
assert!(
!seen.contains(FIXTURE_TOKEN),
"a response echoed the fixture token"
);
assert_eq!(snapshot(&credentials), before, "a credential file changed");
handle.abort();
Ok(())
}
@@ -0,0 +1,285 @@
use super::*;
use codewhale_engine::llm_client::mock::MockLlmClient;
use codewhale_engine::runtime_threads::job_operations::{
MEMBER_RESERVED, MemberOperation, MemberOperationRecord,
};
struct AbortOnDrop(tokio::task::AbortHandle);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
fn fleet() -> Value {
json!({
"schema": "fleet",
"schema_revision": 2,
"name": "Review team",
"members": [
{ "id": "reviewer", "role": "reviewer", "provider": "deepseek", "model": "deepseek-v4-pro" }
]
})
}
fn start_body(key: &str, prompt: &str) -> Value {
json!({
"operation_key": key,
"member_id": "reviewer",
"display_name": "Reviewer",
"prompt": prompt,
"fleet": fleet(),
})
}
fn reserve(
threads: &SharedRuntimeThreadManager,
thread_id: &str,
key: &str,
generation: &str,
) -> Result<()> {
threads
.job_operation_store()
.with_job_operations(|transaction| {
let now = chrono::Utc::now().timestamp_millis();
transaction.save_member(&MemberOperationRecord {
schema_version: 1,
operation: MemberOperation {
id: transaction.member_operation_id(thread_id, key)?,
store_id: threads.job_operation_store_id().to_string(),
thread_id: thread_id.to_string(),
member_id: "reviewer".to_string(),
operation_key: key.to_string(),
agent_id: "agent_reservedfixture".to_string(),
state: MEMBER_RESERVED.to_string(),
created_at_ms: now,
updated_at_ms: now,
},
request_fingerprint: "a".repeat(64),
owner_generation: generation.to_string(),
})
})
}
#[tokio::test]
async fn member_starts_are_durable_replayable_and_refuse_foreign_or_mismatched_replay() -> Result<()>
{
let _env = lock_test_env();
let temporary = tempfile::tempdir()?;
let root = temporary.path().canonicalize()?;
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.join("home"));
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let config = Config::default().with_legacy_root(
Some("member-fixture-key".into()),
Some("http://127.0.0.1:1/v1".into()),
);
let (addr, threads, server) = spawn_test_server_with_root_token_mobile_workspace_and_overrides(
root.clone(),
root.join("sessions"),
None,
false,
workspace.clone(),
TestServerOverrides {
config: Some(config),
..TestServerOverrides::default()
},
)
.await?
.context("the member fixture needs a loopback listener")?;
let _server = AbortOnDrop(server.abort_handle());
threads.set_test_model_client(Arc::new(MockLlmClient::new(Vec::new())));
let client = codewhale_release::tls::reqwest_client();
let url = |path: &str| format!("http://{addr}{path}");
let create_thread = || async {
let created: Value = client
.post(url("/v1/threads"))
.json(&json!({ "model": "deepseek-v4-pro", "mode": "agent", "workspace": workspace }))
.send()
.await?
.error_for_status()?
.json()
.await?;
anyhow::Ok(created["id"].as_str().context("thread id")?.to_string())
};
let thread = create_thread().await?;
let other = create_thread().await?;
let info: Value = client
.get(url("/v1/runtime/info"))
.send()
.await?
.json()
.await?;
assert_eq!(
info["capabilities"]["thread_fleet_member_durable_operations_v1"],
true
);
let store_id = threads.job_operation_store_id().to_string();
let members = url(&format!("/v1/threads/{thread}/members"));
let first = client
.post(&members)
.json(&start_body("team-start-1", "Review the diff"))
.send()
.await?;
let status = first.status();
let receipt: Value = first.json().await?;
assert_eq!(status, StatusCode::OK, "{receipt}");
assert_eq!(receipt["state"], "accepted");
assert_eq!(receipt["runtime_store_id"], store_id.as_str());
assert_eq!(receipt["operation_key"], "team-start-1");
let agent_id = receipt["agent_id"]
.as_str()
.context("agent id")?
.to_string();
let runs = member_runs(&client, addr, &thread).await?;
assert_eq!(runs.len(), 1, "{runs:?}");
assert_eq!(
runs[0]["spec"]["worker_id"],
agent_id.as_str(),
"the receipt names the worker it started"
);
let replayed: Value = client
.post(&members)
.json(&start_body("team-start-1", "Review the diff"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(
replayed, receipt,
"exact replay returns the original receipt"
);
let read: Value = client
.get(url(&format!(
"/v1/threads/{thread}/members/operations/team-start-1"
)))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(read, receipt);
let mismatched = client
.post(&members)
.json(&start_body("team-start-1", "Review something else"))
.send()
.await?;
assert_eq!(mismatched.status(), StatusCode::CONFLICT);
let body: Value = mismatched.json().await?;
assert_eq!(body["error"]["code"], "member_operation_mismatch");
let foreign = client
.get(url(&format!(
"/v1/threads/{other}/members/operations/team-start-1"
)))
.send()
.await?;
assert_eq!(
foreign.status(),
StatusCode::NOT_FOUND,
"a key never crosses threads"
);
let mut ghost = start_body("team-ghost", "Review the diff");
ghost["member_id"] = json!("ghost");
let refused = client.post(&members).json(&ghost).send().await?;
assert_eq!(refused.status(), StatusCode::CONFLICT);
let withdrawn = client
.get(url(&format!(
"/v1/threads/{thread}/members/operations/team-ghost"
)))
.send()
.await?;
assert_eq!(
withdrawn.status(),
StatusCode::NOT_FOUND,
"a start the Engine refused withdraws its reservation"
);
let mut stale = start_body("team-start-2", "Review the diff");
stale["expected_store_id"] = json!("another-store");
let stale = client.post(&members).json(&stale).send().await?;
assert_eq!(stale.status(), StatusCode::CONFLICT);
let body: Value = stale.json().await?;
assert_eq!(body["error"]["code"], "member_operation_store_changed");
let mut forged = start_body("team-start-3", "Review the diff");
forged["request_fingerprint"] = json!("b".repeat(64));
let forged = client.post(&members).json(&forged).send().await?;
assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
let generation = threads.job_operation_generation()?;
reserve(&threads, &other, "in-flight", &generation)?;
let deleted = threads.delete_thread(&other).await;
assert!(
deleted
.as_ref()
.is_err_and(|error| error.to_string().contains("pending member operation")),
"a thread with a start in flight is not deleted: {deleted:?}"
);
reserve(
&threads,
&other,
"before-restart",
&Uuid::new_v4().to_string(),
)?;
let abandoned: Value = client
.get(url(&format!(
"/v1/threads/{other}/members/operations/before-restart"
)))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(
abandoned["state"], "abandoned",
"a reservation an earlier Engine never started is settled, not left unknown"
);
let cancelled = client
.post(url(&format!("/v1/agent-runs/{agent_id}/cancel")))
.send()
.await?;
assert!(cancelled.status().is_success(), "{}", cancelled.status());
let after_cancel: Value = client
.post(&members)
.json(&start_body("team-start-1", "Review the diff"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(
after_cancel["agent_id"], agent_id,
"owner cancellation never makes a replay start a second worker"
);
assert_eq!(member_runs(&client, addr, &thread).await?.len(), 1);
Ok(())
}
async fn member_runs(
client: &reqwest::Client,
addr: SocketAddr,
thread: &str,
) -> Result<Vec<Value>> {
let listed: Value = client
.get(format!("http://{addr}/v1/agent-runs"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(listed["runs"]
.as_array()
.context("runs")?
.iter()
.filter(|run| run["owner_session_id"] == thread)
.cloned()
.collect())
}
@@ -0,0 +1,528 @@
use super::*;
const SEATS: [&str; 6] = [
"customize.plugins.detail",
"conversation.composer.dock",
"conversation.chat.node",
"workpane.panel",
"sidebar.section",
"command.row",
];
const CONSUMER_BRIDGE_KEYS: [&str; 7] = [
"kind",
"dsh_version",
"cordis_version",
"compatibility_digest",
"declared_range",
"matched_version",
"admission",
];
const CONSUMER_REFUSALS: [(u16, &str); 6] = [
(404, "plugin_ui_not_declared"),
(404, "plugin_ui_seat_not_admitted"),
(400, "plugin_ui_unsupported"),
(422, "plugin_ui_seat_unsupported"),
(400, "plugin_ui_seat_unknown"),
(404, "plugin_ui_file_not_reviewed"),
];
const DSH_CLIENT: &str = "const react = require(\"react\");\n\
const util = require(\"./util\");\n\
const keys = [\"sidebar.right.pane.tab\", \"settings.section\", \"conversation.session.header.utilities\"];\n\
exports.apply = function apply(ctx) { return [react, util.v, keys, ctx]; };\n";
fn write_dsh_package(dir: &Path, name: &str, client: &str) -> Result<PathBuf> {
let package = dir.join(name);
fs::create_dir_all(package.join("lib"))?;
fs::write(
package.join("package.json"),
serde_json::json!({
"name": format!("@demo/{name}"),
"version": "1.0.0",
"exports": { "./client": { "default": "./lib/client.js" } },
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web" }
}
})
.to_string(),
)?;
fs::write(
package.join("cordis.patch.yml"),
"- insert:\n - id: skills\n name: \"@deepseek-ai/dsh-skill-filesystem\"\n config:\n customSkillDirs:\n - ./skills\n",
)?;
fs::create_dir_all(package.join("skills/greet"))?;
fs::write(
package.join("skills/greet/SKILL.md"),
"---\nname: greet\ndescription: Greets a person\n---\nSay hi.\n",
)?;
fs::write(package.join("lib/client.js"), client)?;
fs::write(package.join("lib/util.js"), "exports.v = 1;\n")?;
Ok(package)
}
fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
let bundle = write_plugin_source_bundle(dir, name)?;
fs::write(
bundle.join("package.json"),
serde_json::json!({
"name": name,
"version": "1.0.0",
"exports": { "./client": "./client.js" },
"dsh": { "client": { "platform": "web" } }
})
.to_string(),
)?;
fs::write(bundle.join("client.js"), "window.__native = true;\n")?;
Ok(bundle)
}
async fn install_active(
client: &reqwest::Client,
base: &str,
source: String,
) -> Result<serde_json::Value> {
let install: serde_json::Value = client
.post(format!("{base}/install"))
.json(&serde_json::json!({ "source": source }))
.send()
.await?
.error_for_status()?
.json()
.await?;
let name = install["name"]
.as_str()
.expect("installed name")
.to_string();
let detail: serde_json::Value = client
.get(format!("{base}/{name}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = detail["review"]["token"].as_str().unwrap().to_string();
client
.post(format!("{base}/{name}/trust"))
.json(&serde_json::json!({ "token": token }))
.send()
.await?
.error_for_status()?;
let enabled: serde_json::Value = client
.post(format!("{base}/{name}/enable"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(enabled["state"], "active", "{enabled}");
Ok(serde_json::json!({ "name": name, "path": detail["path"] }))
}
async fn ui(
client: &reqwest::Client,
base: &str,
name: &str,
query: &str,
) -> Result<(u16, serde_json::Value)> {
let response = client
.get(format!("{base}/{name}/ui?{query}"))
.send()
.await?;
let status = response.status().as_u16();
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,
body["error"]["code"].as_str().unwrap_or("").to_string(),
)
}
fn assert_consumer_contract(name: &str, minted: &serde_json::Value, seat: &str, key: &str) {
assert_eq!(minted["slot"], seat);
assert_eq!(minted["dsh_slot"], key);
assert!(SEATS.contains(&minted["slot"].as_str().unwrap()));
let key = minted["dsh_slot"].as_str().unwrap();
assert!(key.len() <= 128 && key.chars().next().unwrap().is_ascii_alphanumeric());
assert!(
key.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
);
let bridge = minted["bridge"].as_object().expect("bridge object");
assert_eq!(bridge["kind"], "dsh-client");
for (field, value) in bridge {
assert!(CONSUMER_BRIDGE_KEYS.contains(&field.as_str()), "{field}");
assert!(
value
.as_str()
.is_some_and(|v| !v.is_empty() && v.len() <= 128)
);
}
assert!(bridge.contains_key("dsh_version"));
let base = minted["base"].as_str().unwrap();
assert!(base.starts_with("http://127.0.0.1:"));
assert!(base.ends_with(&format!(
"/v1/apps/plugins/{}/ui/",
urlencoding::encode(name)
)));
let entry = minted["entry"].as_str().unwrap();
assert!(!entry.is_empty() && entry.len() <= 512 && !entry.contains(".."));
assert!(
minted["revision"]
.as_str()
.is_some_and(|r| r.starts_with('"'))
);
}
fn bootstrap_loads_in_node(source: &str) -> Option<String> {
let dir = tempfile::tempdir().ok()?;
let script = dir.path().join("run.js");
let harness = format!(
"var captured;\nvar window = {{ __ModuleLoader__: {{ load: function (r) {{ captured = r; }} }} }};\n{source}\nvar mod = captured.factory(function (id) {{ return {{ id: id }}; }});\nprocess.stdout.write(JSON.stringify({{ id: captured.id, apply: typeof mod.apply, out: mod.apply('ctx') }}));\n"
);
fs::write(&script, harness).ok()?;
let output = std::process::Command::new("node")
.arg(&script)
.output()
.ok()?;
assert!(
output.status.success(),
"the served bootstrap failed under node: {}",
String::from_utf8_lossy(&output.stderr)
);
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[tokio::test]
async fn plugin_ui_dsh_client_serves_admitted_seats_and_denies_and_revokes() -> Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("runtime");
let workspace = tmp.path().join("ws");
fs::create_dir_all(&root)?;
let package = write_dsh_package(tmp.path(), "panel", DSH_CLIENT)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).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!("dsh:{}", package.display())).await?;
let name = installed["name"].as_str().unwrap().to_string();
let (status, panel) = ui(&client, &base, &name, "slot=workpane.panel").await?;
assert_eq!(status, 200, "{panel}");
assert_consumer_contract(&name, &panel, "workpane.panel", "sidebar.right.pane.tab");
assert_eq!(panel["entry"], "__codewhale_dsh_bootstrap.js");
let (status, detail) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
assert_eq!(status, 200, "{detail}");
assert_consumer_contract(
&name,
&detail,
"customize.plugins.detail",
"settings.section",
);
let (status, explicit) = ui(
&client,
&base,
&name,
"slot=workpane.panel&dsh_slot=sidebar.right.pane.tab",
)
.await?;
assert_eq!(status, 200, "{explicit}");
for (query, expected) in [
("slot=sidebar.section", (404, "plugin_ui_seat_not_admitted")),
("slot=command.row", (404, "plugin_ui_seat_not_admitted")),
(
"slot=workpane.panel&dsh_slot=main.page-menu",
(404, "plugin_ui_seat_not_admitted"),
),
(
"slot=workpane.panel&dsh_slot=settings.section",
(404, "plugin_ui_seat_not_admitted"),
),
(
"slot=workpane.panel&dsh_slot=conversation.session.header.utilities",
(422, "plugin_ui_seat_unsupported"),
),
("slot=chat.node", (400, "plugin_ui_seat_unknown")),
("dsh_slot=settings.section", (400, "plugin_ui_seat_unknown")),
(
"slot=workpane.panel&dsh_slot=..%2Fx",
(400, "plugin_ui_dsh_slot_invalid"),
),
] {
let (status, body) = ui(&client, &base, &name, query).await?;
let (status, code) = refusal(status, &body);
assert_eq!((status, code.as_str()), expected, "{query}: {body}");
assert!(
CONSUMER_REFUSALS.contains(&(status, code.as_str()))
|| code == "plugin_ui_dsh_slot_invalid",
"{query}"
);
}
let revision = panel["revision"].as_str().unwrap().to_string();
let asset_url = format!(
"{}{}",
panel["base"].as_str().unwrap(),
panel["entry"].as_str().unwrap()
);
let served = client
.get(&asset_url)
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(served.status(), StatusCode::OK);
assert!(
served
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/javascript"))
);
let bootstrap = served.text().await?;
assert!(bootstrap.contains("\"source/lib/util.js\""));
assert!(!bootstrap.contains("Bearer"));
match bootstrap_loads_in_node(&bootstrap) {
Some(out) => {
let out: serde_json::Value = serde_json::from_str(&out)?;
assert_eq!(out["apply"], "function");
assert_eq!(out["out"][0]["id"], "react");
assert_eq!(out["out"][1], 1);
}
None => eprintln!("node is unavailable; the bootstrap execution check was skipped"),
}
let asset_base = panel["base"].as_str().unwrap().to_string();
let missing = client.get(&asset_url).send().await?;
assert_eq!(missing.status(), StatusCode::PRECONDITION_REQUIRED);
let wrong = client
.get(&asset_url)
.header("If-Match", "\"not-a-revision\"")
.send()
.await?;
assert_eq!(wrong.status(), StatusCode::PRECONDITION_FAILED);
for outside in [
"package.json",
"source/package.json",
"source/lib/index.cjs",
"source/lib/util.js",
] {
let response = client
.get(format!("{asset_base}{outside}"))
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{outside}");
let body: serde_json::Value = response.json().await?;
assert_eq!(body["error"]["code"], "plugin_ui_file_not_reviewed");
}
let traversal = client
.get(format!("{asset_base}%2e%2e/%2e%2e/etc/passwd"))
.header("If-Match", &revision)
.send()
.await?;
assert!(traversal.status().is_client_error());
let route = format!("{base}/{name}/routes/api/state");
let routed = client
.post(&route)
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(routed.status(), StatusCode::NOT_IMPLEMENTED);
let body: serde_json::Value = routed.json().await?;
assert_eq!(body["error"]["code"], "plugin_route_not_served");
assert_eq!(
client.post(&route).send().await?.status(),
StatusCode::PRECONDITION_REQUIRED
);
assert_eq!(
client
.post(&route)
.header("If-Match", "\"not-a-revision\"")
.send()
.await?
.status(),
StatusCode::PRECONDITION_FAILED
);
assert_eq!(
client
.post(format!("{base}/other/routes/api/state"))
.header("If-Match", &revision)
.send()
.await?
.status(),
StatusCode::PRECONDITION_FAILED
);
assert!(
client
.post(format!("{base}/{name}/routes/%2e%2e/x"))
.header("If-Match", &revision)
.send()
.await?
.status()
.is_client_error()
);
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 (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)
.send()
.await?;
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
let (status, again) = ui(&client, &base, &name, "slot=workpane.panel").await?;
assert_eq!(status, 200, "{again}");
let again_revision = again["revision"].as_str().unwrap().to_string();
assert_ne!(again_revision, revision);
let staged = PathBuf::from(installed["path"].as_str().unwrap()).join("source/lib/util.js");
let live = client
.get(&asset_url)
.header("If-Match", &again_revision)
.send()
.await?;
assert_eq!(live.status(), StatusCode::OK);
fs::write(&staged, "exports.v = 2;\n")?;
let tampered = client
.get(&asset_url)
.header("If-Match", &again_revision)
.send()
.await?;
assert_eq!(tampered.status(), StatusCode::PRECONDITION_FAILED);
handle.abort();
Ok(())
}
#[tokio::test]
async fn plugin_ui_dsh_client_with_unprovided_modules_or_ambient_dependencies_is_unsupported()
-> Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("runtime");
let workspace = tmp.path().join("ws");
fs::create_dir_all(&root)?;
let host_module = write_dsh_package(
tmp.path(),
"hostmod",
"require(\"@deepseek-ai/dsh-client-ui-primitives\");\nconst k = \"sidebar.right.pane.tab\";\n",
)?;
let esm = write_dsh_package(
tmp.path(),
"esm",
"const k = \"sidebar.right.pane.tab\";\nexport const apply = () => k;\n",
)?;
let ambient = write_dsh_package(
tmp.path(),
"ambient",
"require(\"left-pad\");\nconst k = \"sidebar.right.pane.tab\";\n",
)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}/v1/apps/plugins");
for package in [host_module, esm, ambient] {
let installed =
install_active(&client, &base, format!("dsh:{}", package.display())).await?;
let name = installed["name"].as_str().unwrap();
let (status, body) = ui(&client, &base, name, "slot=workpane.panel").await?;
assert_eq!(
refusal(status, &body),
(400, "plugin_ui_unsupported".to_string()),
"{name}: {body}"
);
}
handle.abort();
Ok(())
}
#[tokio::test]
async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() -> Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("runtime");
let workspace = tmp.path().join("ws");
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 {
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 (status, minted) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
assert_eq!(status, 200, "{minted}");
assert!(minted.get("dsh_slot").is_none() && minted.get("bridge").is_none());
assert_eq!(minted["entry"], "client.js");
for query in [
"slot=workpane.panel",
"slot=sidebar.section",
"slot=customize.plugins.detail&dsh_slot=settings.section",
] {
let (status, body) = ui(&client, &base, &name, query).await?;
assert_eq!(
refusal(status, &body),
(404, "plugin_ui_seat_not_admitted".to_string()),
"{query}"
);
}
let revision = minted["revision"].as_str().unwrap().to_string();
let asset = format!("{}client.js", minted["base"].as_str().unwrap());
let served = client
.get(&asset)
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(served.status(), StatusCode::OK);
let unlisted = client
.get(format!("{}nothing.js", minted["base"].as_str().unwrap()))
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(unlisted.status(), StatusCode::NOT_FOUND);
let body: serde_json::Value = unlisted.json().await?;
assert_eq!(body["error"]["code"], "plugin_ui_file_not_reviewed");
let routed = client
.post(format!("{base}/{name}/routes/x"))
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(routed.status(), StatusCode::FORBIDDEN);
let body: serde_json::Value = routed.json().await?;
assert_eq!(body["error"]["code"], "plugin_route_not_admitted");
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)
.send()
.await?;
assert_eq!(after.status(), StatusCode::PRECONDITION_FAILED);
handle.abort();
Ok(())
}
@@ -635,7 +635,7 @@ async fn snapshot_in_owner(
snapshot_in_runtime(&state.runtime_threads, &state.sessions_dir, id).await
}
async fn snapshot_in_runtime(
pub(super) async fn snapshot_in_runtime(
runtime: &Arc<RuntimeThreadManager>,
sessions_dir: &Path,
id: String,
+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"[..]));
+344
View File
@@ -0,0 +1,344 @@
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: "initialize response",
problem: "field `.agentCapabilities.modelSelection` is not in the official type",
reason: "Codewhale extension flag; official AgentCapabilities has no modelSelection",
},
Allowed {
side: Side::Agent,
frame: "session/new response",
problem: "field `.models` is not in the official type",
reason: "pre-configOptions model selector; official NewSessionResponse has no models",
},
Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.models` is not in the official type",
reason: "pre-configOptions model selector; official LoadSessionResponse has no models",
},
Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.sessionId` is not in the official type",
reason: "session/load reuses the session/new body; official LoadSessionResponse has no sessionId",
},
Allowed {
side: Side::Agent,
frame: "session/list response",
problem: "field `.sessions[].createdAt` is not in the official type",
reason: "official SessionInfo carries updatedAt only",
},
Allowed {
side: Side::Agent,
frame: "session/list response",
problem: "field `.sessions[].messageCount` is not in the official type",
reason: "official SessionInfo has no message count",
},
Allowed {
side: Side::Agent,
frame: "response",
problem: "answers no outstanding request (id null)",
reason: "an unknown notification is answered with an id-null -32601 error; JSON-RPC forbids replying to a notification",
},
];
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(())
}
+499
View File
@@ -0,0 +1,499 @@
mod acp_support;
use acp_support::{Client, fixture_config, text_of, updates};
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use serde_json::{Value, json};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
#[test]
fn editor_drives_a_session_through_permission_cancel_and_load() -> 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!("journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("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(
"provider-id-not-authority",
"write",
r#"{"path":"allowed.txt","content":"approved"}"#,
),
canned::simple_text_turn("settled after approval"),
canned::tool_call_turn(
"provider-id-two",
"write",
r#"{"path":"cancelled.txt","content":"never"}"#,
),
canned::simple_text_turn("resumed in the real session"),
]));
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 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 {
let result = async {
let (initialized, _) = client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{"terminal":false}}),
)
.await?;
let capabilities = &initialized["result"]["agentCapabilities"];
assert_eq!(initialized["result"]["protocolVersion"], 1);
assert_eq!(capabilities["loadSession"], true);
assert_eq!(capabilities["promptCapabilities"]["image"], false);
assert_eq!(capabilities["promptCapabilities"]["audio"], false);
assert_eq!(capabilities["mcpCapabilities"]["http"], false);
assert_eq!(capabilities["mcpCapabilities"]["sse"], false);
assert!(capabilities["sessionCapabilities"]["list"].is_object());
assert!(capabilities["sessionCapabilities"].get("resume").is_none());
let (relative, _) = client
.request("session/new", json!({"cwd":"relative/dir"}))
.await?;
assert_eq!(relative["error"]["code"], -32602);
let (mcp, _) = client
.request(
"session/new",
json!({"cwd":workspace,"mcpServers":[{"name":"x","command":"x","args":[],"env":[]}]}),
)
.await?;
assert_eq!(mcp["error"]["code"], -32602);
let (resume, _) = client
.request("session/resume", json!({"sessionId":"x","cwd":workspace}))
.await?;
assert_eq!(resume["error"]["code"], -32601);
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();
let (image, _) = client
.request(
"session/prompt",
json!({"sessionId":session,"prompt":[
{"type":"text","text":"look"},
{"type":"image","data":"AAAA","mimeType":"image/png"}
]}),
)
.await?;
assert_eq!(image["error"]["code"], -32602);
assert_eq!(
provider.call_count(),
0,
"a refused prompt never reaches the provider"
);
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":"write under Ask"}]}}),
)
.await?;
let (permission, before) = client.until_permission().await?;
assert_eq!(permission["params"]["sessionId"], session.as_str());
assert_eq!(permission["params"]["toolCall"]["kind"], "edit");
let kinds: Vec<_> = permission["params"]["options"]
.as_array()
.unwrap()
.iter()
.map(|option| option["kind"].as_str().unwrap().to_string())
.collect();
assert_eq!(kinds, ["allow_once", "reject_once"]);
assert!(
!updates(&before, "tool_call").is_empty(),
"the tool call is announced before approval is requested"
);
assert!(
updates(&before, "tool_call_update")
.iter()
.all(|frame| frame.pointer("/params/update/status") != Some(&json!("in_progress"))),
"nothing dispatches before the person decides"
);
assert!(!workspace.join("allowed.txt").exists());
client
.send(json!({"jsonrpc":"2.0","id":permission["id"],
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}))
.await?;
let (done, after) = client.until_response(prompt_id).await?;
assert_eq!(done["result"]["stopReason"], "end_turn");
assert_eq!(
std::fs::read_to_string(workspace.join("allowed.txt"))?,
"approved"
);
assert!(
updates(&after, "tool_call_update")
.iter()
.any(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
);
assert_eq!(
text_of(&after, "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":{
"sessionId":session,"prompt":"write then cancel"}}),
)
.await?;
let (second, _) = client.until_permission().await?;
client
.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":session}}))
.await?;
client
.send(json!({"jsonrpc":"2.0","id":second["id"],
"result":{"outcome":{"outcome":"cancelled"}}}))
.await?;
let (cancelled, _) = client.until_response(cancel_id).await?;
assert_eq!(cancelled["result"]["stopReason"], "cancelled");
assert!(!workspace.join("cancelled.txt").exists());
let (listed, _) = client
.request("session/list", json!({"cwd":workspace}))
.await?;
assert!(
listed["result"]["sessions"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["sessionId"] == session.as_str())
);
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(session)
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, session) = tokio::join!(first_connection, first_script);
served?;
let session = session?;
let thread = manager
.list_threads(Default::default(), None)
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow!("no runtime thread was created"))?;
let detail = manager.get_thread_detail(&thread.id).await?;
assert!(
detail.pending_approvals.is_empty(),
"cancel settles the approval"
);
assert_eq!(detail.turns.len(), 2);
assert_eq!(detail.turns[0].status, RuntimeTurnStatus::Completed);
assert_eq!(detail.turns[1].status, RuntimeTurnStatus::Interrupted);
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 {
client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let (missing, _) = client
.request(
"session/load",
json!({"sessionId":"no-such-session","cwd":workspace,"mcpServers":[]}),
)
.await?;
assert_eq!(missing["error"]["code"], -32602);
let (loaded, replay) = client
.request(
"session/load",
json!({"sessionId":session,"cwd":workspace,"mcpServers":[]}),
)
.await?;
assert_eq!(loaded["result"]["sessionId"], session.as_str());
assert!(text_of(&replay, "user_message_chunk").contains("write under Ask"));
assert!(text_of(&replay, "agent_message_chunk").contains("settled after approval"));
assert!(!updates(&replay, "tool_call").is_empty());
let (resumed, frames) = client
.request(
"session/prompt",
json!({"sessionId":session,"prompt":"continue"}),
)
.await?;
assert_eq!(resumed["result"]["stopReason"], "end_turn");
assert_eq!(
text_of(&frames, "agent_message_chunk"),
"resumed in the real session"
);
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
};
let (served, script) = tokio::join!(second_connection, second_script);
served?;
script?;
let requests = provider.captured_requests();
let last = serde_json::to_string(requests.last().ok_or_else(|| anyhow!("no model request"))?)?;
assert!(
last.contains("write under Ask") && last.contains("settled after approval"),
"the loaded session continues from its real history"
);
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] {
+7 -8
View File
@@ -265,17 +265,16 @@ Common forwarded flags:
--output-format <FORMAT> Output format: text or stream-json
--hooks Opt in to configured hooks (tool_call_before, shell_env)
Plain `codewhale exec` is a one-shot model response: one Engine turn with the
same system prompt as every other run, and no tools. Use `--auto` for
non-interactive agent-with-tools execution. Tools are offered only with
`--auto`, `--yolo`, `--allowed-tools`, or when resuming a session; limits such
as `--max-turns`, `--disallowed-tools` or `--sandbox`, and the output format,
never add tools. A reply cut off at the provider's output limit is continued
in the same turn, at most 8 model steps unless `--max-turns` says otherwise.
Plain `codewhale exec` runs the agent with the normal tool surface under the
configured sandbox and approval posture. Use `--no-tools` for one answer with
no tools. `--auto` selects automatic approvals and Full Access by default. A
reply cut off at the provider's output limit is continued in the same turn, at
most 8 model steps unless `--max-turns` says otherwise.
--auto defaults to Full Access: no shell sandbox and trust mode on. Use --sandbox workspace-write to keep the shell sandbox and file-tool workspace restrictions; stricter configured policies also apply. A denied sandbox retry requires --allow-sandbox-elevation or --sandbox danger-full-access.
Any --sandbox other than danger-full-access (or an equivalent sandbox_mode in config) disables trust mode.
Runs that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Plain answer-only text/--json runs are not saved unless compaction needs recovery.
Runs that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Answer-only (--no-tools) text/--json runs are not saved unless compaction needs recovery.
Put flags before the prompt. Put -- before a prompt starting with - or containing literal exec flags.
Piped stdin is the prompt when no prompt argument is given, or is appended to it; with a prompt argument, stdin that sends nothing for 2 seconds is ignored with a note on stderr.
")]
Exec(TuiPassthroughArgs),
/// Manage durable Agent fleet runs.
+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!(
+70 -49
View File
@@ -354,6 +354,18 @@
"cache_read_per_m": 0.145
},
"reason": "Fireworks hosts DeepSeek V4 Pro at its own published rates, as in the reviewed table in crates/engine/src/pricing.rs; Models.dev lists a flat 1.2/1.2 rate with a 0.6 cache-read rate for this deployment."
},
{
"provider": "google",
"id": "gemini-4-argon",
"max_output": 65536,
"reason": "Google publishes 65,536 output tokens by default for synchronous requests (https://aistudio.google.com/docs/models/gemini-4-argon, read 2026-11-10); Models.dev lists 32768."
},
{
"provider": "google",
"id": "gemini-4-carbon",
"max_output": 65536,
"reason": "Google publishes 65,536 output tokens by default for synchronous requests (https://aistudio.google.com/docs/models/gemini-4-carbon, read 2026-11-10); Models.dev lists 32768."
}
],
"reviewed": {
@@ -395,7 +407,7 @@
"reasoning"
],
"reasoning": true,
"source": "https://models.dev/catalog.json anthropic/claude-fable-5-2, retrieved 2026-11-10",
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"family": "Anthropic"
},
"claude-opus-5-5": {
@@ -1176,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": [
@@ -1629,14 +1644,16 @@
"family": "Google"
},
"gemini-4-argon": {
"source": "official vendor model docs, verified 2026-10-17; see source_receipts",
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"context_window": 1048576,
"max_output": 65536,
"reasoning": true,
"family": "Google"
},
"gemini-4-carbon": {
"source": "https://models.dev/catalog.json google/gemini-4-carbon, retrieved 2026-11-10",
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"context_window": 1048576,
"max_output": 32768,
"max_output": 65536,
"reasoning": true,
"family": "Google"
},
@@ -1874,53 +1891,38 @@
"source": "preserved public presentation facts, not route availability"
},
"mistral-large-4": {
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 262144,
"reasoning": true,
"family": "Mistral"
},
"mistral-large-4-0-8": {
"source": "models.dev mistral/mistral-large-4-0-8, discovered 2026-11-10; unreviewed",
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-11; unreviewed",
"context_window": 1048576,
"max_output": 262144,
"reasoning": true,
"family": "Mistral"
},
"minimax-m3.1-flash": {
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-10; unreviewed",
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-11; unreviewed",
"context_window": 1000000,
"max_output": 524288,
"reasoning": true,
"family": "MiniMax"
"family": "Minimax"
},
"kimi-k3.1-pro": {
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-10; unreviewed",
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-11; unreviewed",
"context_window": 1048576,
"max_output": 1048576,
"reasoning": true,
"family": "Moonshot/Kimi"
"family": "Moonshot"
},
"kimi-k3.1-pro-highspeed": {
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-10; unreviewed",
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-11; unreviewed",
"context_window": 1048576,
"max_output": 1048576,
"reasoning": true,
"family": "Moonshot/Kimi"
"family": "Moonshot"
},
"gpt-6.1-terra": {
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-10; unreviewed",
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-11; unreviewed",
"context_window": 1050000,
"max_output": 128000,
"reasoning": true,
"family": "OpenAI"
},
"muse-ember-1.0": {
"source": "models.dev meta/muse-ember-1.0, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 524288,
"reasoning": true,
"family": "Meta"
"family": "OpenAi"
}
},
"official_deepseek_aliases": {
@@ -4178,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",
@@ -4574,19 +4582,11 @@
{
"id": "mistral-large-4",
"label": "Mistral",
"added_at": "2026-10-06",
"added_at": "2026-11-05",
"aliases": [
"mistral-large-4"
]
},
{
"id": "mistral-large-4-0-8",
"label": "Mistral",
"added_at": "2026-10-06",
"aliases": [
"mistral-large-4-0-8"
]
},
{
"id": "MiniMax-M3.1-Flash",
"label": "MiniMax",
@@ -4618,14 +4618,6 @@
"aliases": [
"gpt-6.1-terra"
]
},
{
"id": "muse-ember-1.0",
"label": "Meta",
"added_at": "2026-11-09",
"aliases": [
"muse-ember-1.0"
]
}
],
"constants": {
@@ -4781,7 +4773,12 @@
"https://developers.openai.com/api/docs/models/gpt-6-sol": "verified 2026-10-17: $2 in / $0.20 cached / $10 out / $2.50 cache write; gpt-6.1-sol $2 / $0.10 / $10 / $2.50 and gpt-6-luna $0.10 / $0.01 / $0.50 per the changelog",
"https://developers.openai.com/api/docs/deprecations": "verified 2026-10-17: gpt-5.3-codex and gpt-5.1 move to gpt-6-sol and gpt-5.4-nano to gpt-6-luna, all shutting down 2027-04-01; the catalog has no lifecycle field, so the rows stay until shutdown",
"https://ai.google.dev/gemini-api/docs/changelog": "verified 2026-10-17: gemini-4-argon GA 2026-10-13, gemini-3.8-flash GA 2026-09-02; gemini-3.7-flash (auto-routed to gemini-3.8-flash) and gemini-3.5-flash (auto-routed to gemini-3.6-flash) deprecated 2026-10-08 and kept only as selector aliases",
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated but no output ceiling is, so none is recorded",
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated; the output ceiling is recorded from its own model page (see the aistudio.google.com receipts, verified 2026-11-10)",
"https://aistudio.google.com/docs/models/gemini-4-carbon": "verified 2026-11-10: model code gemini-4-carbon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high, function calling, structured outputs, implicit caching only; thought signatures are not mentioned",
"https://aistudio.google.com/docs/models/gemini-4-argon": "verified 2026-11-10: model code gemini-4-argon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high",
"https://aistudio.google.com/docs/pricing": "verified 2026-11-10: gemini-4-carbon is $2 in / $10 out / $0.10 cached through 2026-11-30 and $4 / $20 / $0.20 from 2026-12-01; the catalog carries one undated price, so the row shows the current rate",
"https://platform.claude.com/docs/en/models/fable-5-2/overview": "verified 2026-11-10: claude-fable-5-2, 1M context, 128K output, adaptive thinking always on, $8 in / $40 out, text and image input",
"https://platform.claude.com/docs/en/build-with-claude/effort": "verified 2026-11-10: xhigh is its own effort level on Fable 5.2, Mythos 5.2, Fable 5.1, Fable 5, Opus 5.5, Opus 5, Opus 4.8, Opus 4.7, Sonnet 5.5, Sonnet 5 and Haiku 5.5; not every model that supports max supports xhigh",
"https://docs.z.ai/guides/vlm/glm-5.3-flash": "verified 2026-10-17: model code glm-5.3-flash, 1M context, 128K output, thinking.type only supports enabled",
"https://dev.meta.ai/docs/models.md": "verified 2026-10-17: muse-spark-1.3 is recommended for new work, muse-spark-1.2 is the previous version, muse-spark-1.3-contributor has no max effort, muse-wonder-1.0 is a preview; all 1,048,576 context",
"https://dev.meta.ai/docs/pricing-rate-limits": "verified 2026-10-17: Muse Spark 1.3 and 1.2 $1.25 in / $0.15 cached / $4.25 out, contributor $0.10 / $0.002 / $0.20; Muse Wonder 1.0 $4 / $0.20 / $20; no long-context premium",
@@ -4791,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": [
{
@@ -5064,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",
@@ -6882,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",
+63 -119
View File
@@ -9,8 +9,8 @@
"default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline.",
"pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.",
"generated_by": "python3 scripts/catalog_models_dev.py seed render from scripts/catalog/models_dev_seed.toml and scripts/catalog/models_dev_seed.lock.json. Do not edit this file by hand; see docs/CATALOG_REFRESH.md.",
"upstream": "https://models.dev/catalog.json fetched 2026-11-10T22:14:03Z sha256 2d85d334089340e30ae6d3c6bbbcd8c7591e27b954e8089494348bb59d1bd7af",
"coverage": "23 providers, 137 provider model rows, 4 canonical model entries."
"upstream": "https://models.dev/catalog.json fetched 2026-11-10T21:14:00Z sha256 019e248c87ca384d63f16bfd2fde60f8e26b2c6fa1126927a1dac25fe88c9740",
"coverage": "23 providers, 136 provider model rows, 4 canonical model entries."
},
"models": {
"deepseek/deepseek-v4-pro-0813": {
@@ -3348,71 +3348,6 @@
"output": 20,
"cache_read": 0.2
}
},
"muse-ember-1.0": {
"id": "muse-ember-1.0",
"canonical_model_id": "meta/muse-ember-1.0",
"name": "Muse Ember",
"description": "Muse Ember is Meta's smallest and most efficient reasoning model, built for the work agents repeat all day: tool calls, coding subtasks, routing, extraction, and long-document parsing. It pairs with Muse Wonder or Muse Spark, which plan while Muse Ember handles the volume.",
"family": "muse",
"release_date": "2026-11-09",
"last_updated": "2026-11-09",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"open_weights": false,
"modalities": {
"input": [
"text",
"image",
"video",
"pdf",
"audio"
],
"output": [
"text"
]
},
"limit": {
"context": 1048576,
"output": 524288
},
"cost": {
"input": 0.1,
"output": 0.5,
"cache_read": 0.004,
"tiers": [
{
"input": 0.3,
"output": 1.5,
"cache_read": 0.012,
"tier": {
"type": "context",
"size": 200000
}
}
],
"context_over_200k": {
"input": 0.3,
"output": 1.5,
"cache_read": 0.012
}
}
}
}
},
@@ -5177,9 +5112,9 @@
"output": 384000
},
"cost": {
"input": 0.894012,
"output": 1.788024,
"cache_read": 0.074501
"input": 0.95526,
"output": 1.91052,
"cache_read": 0.079605
}
},
"deepseek/deepseek-v4-flash": {
@@ -8084,7 +8019,7 @@
"reasoning"
],
"reasoning": true,
"source": "https://models.dev/catalog.json anthropic/claude-fable-5-2, retrieved 2026-11-10",
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"family": "Anthropic"
},
"claude-opus-5-5": {
@@ -8865,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": [
@@ -9318,14 +9256,16 @@
"family": "Google"
},
"gemini-4-argon": {
"source": "official vendor model docs, verified 2026-10-17; see source_receipts",
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"context_window": 1048576,
"max_output": 65536,
"reasoning": true,
"family": "Google"
},
"gemini-4-carbon": {
"source": "https://models.dev/catalog.json google/gemini-4-carbon, retrieved 2026-11-10",
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"context_window": 1048576,
"max_output": 32768,
"max_output": 65536,
"reasoning": true,
"family": "Google"
},
@@ -9563,53 +9503,38 @@
"source": "preserved public presentation facts, not route availability"
},
"mistral-large-4": {
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 262144,
"reasoning": true,
"family": "Mistral"
},
"mistral-large-4-0-8": {
"source": "models.dev mistral/mistral-large-4-0-8, discovered 2026-11-10; unreviewed",
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-11; unreviewed",
"context_window": 1048576,
"max_output": 262144,
"reasoning": true,
"family": "Mistral"
},
"minimax-m3.1-flash": {
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-10; unreviewed",
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-11; unreviewed",
"context_window": 1000000,
"max_output": 524288,
"reasoning": true,
"family": "MiniMax"
"family": "Minimax"
},
"kimi-k3.1-pro": {
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-10; unreviewed",
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-11; unreviewed",
"context_window": 1048576,
"max_output": 1048576,
"reasoning": true,
"family": "Moonshot/Kimi"
"family": "Moonshot"
},
"kimi-k3.1-pro-highspeed": {
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-10; unreviewed",
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-11; unreviewed",
"context_window": 1048576,
"max_output": 1048576,
"reasoning": true,
"family": "Moonshot/Kimi"
"family": "Moonshot"
},
"gpt-6.1-terra": {
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-10; unreviewed",
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-11; unreviewed",
"context_window": 1050000,
"max_output": 128000,
"reasoning": true,
"family": "OpenAI"
},
"muse-ember-1.0": {
"source": "models.dev meta/muse-ember-1.0, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 524288,
"reasoning": true,
"family": "Meta"
"family": "OpenAi"
}
},
"official_deepseek_aliases": {
@@ -11867,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",
@@ -12263,19 +12194,11 @@
{
"id": "mistral-large-4",
"label": "Mistral",
"added_at": "2026-10-06",
"added_at": "2026-11-05",
"aliases": [
"mistral-large-4"
]
},
{
"id": "mistral-large-4-0-8",
"label": "Mistral",
"added_at": "2026-10-06",
"aliases": [
"mistral-large-4-0-8"
]
},
{
"id": "MiniMax-M3.1-Flash",
"label": "MiniMax",
@@ -12307,14 +12230,6 @@
"aliases": [
"gpt-6.1-terra"
]
},
{
"id": "muse-ember-1.0",
"label": "Meta",
"added_at": "2026-11-09",
"aliases": [
"muse-ember-1.0"
]
}
],
"constants": {
@@ -12470,7 +12385,12 @@
"https://developers.openai.com/api/docs/models/gpt-6-sol": "verified 2026-10-17: $2 in / $0.20 cached / $10 out / $2.50 cache write; gpt-6.1-sol $2 / $0.10 / $10 / $2.50 and gpt-6-luna $0.10 / $0.01 / $0.50 per the changelog",
"https://developers.openai.com/api/docs/deprecations": "verified 2026-10-17: gpt-5.3-codex and gpt-5.1 move to gpt-6-sol and gpt-5.4-nano to gpt-6-luna, all shutting down 2027-04-01; the catalog has no lifecycle field, so the rows stay until shutdown",
"https://ai.google.dev/gemini-api/docs/changelog": "verified 2026-10-17: gemini-4-argon GA 2026-10-13, gemini-3.8-flash GA 2026-09-02; gemini-3.7-flash (auto-routed to gemini-3.8-flash) and gemini-3.5-flash (auto-routed to gemini-3.6-flash) deprecated 2026-10-08 and kept only as selector aliases",
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated but no output ceiling is, so none is recorded",
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated; the output ceiling is recorded from its own model page (see the aistudio.google.com receipts, verified 2026-11-10)",
"https://aistudio.google.com/docs/models/gemini-4-carbon": "verified 2026-11-10: model code gemini-4-carbon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high, function calling, structured outputs, implicit caching only; thought signatures are not mentioned",
"https://aistudio.google.com/docs/models/gemini-4-argon": "verified 2026-11-10: model code gemini-4-argon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high",
"https://aistudio.google.com/docs/pricing": "verified 2026-11-10: gemini-4-carbon is $2 in / $10 out / $0.10 cached through 2026-11-30 and $4 / $20 / $0.20 from 2026-12-01; the catalog carries one undated price, so the row shows the current rate",
"https://platform.claude.com/docs/en/models/fable-5-2/overview": "verified 2026-11-10: claude-fable-5-2, 1M context, 128K output, adaptive thinking always on, $8 in / $40 out, text and image input",
"https://platform.claude.com/docs/en/build-with-claude/effort": "verified 2026-11-10: xhigh is its own effort level on Fable 5.2, Mythos 5.2, Fable 5.1, Fable 5, Opus 5.5, Opus 5, Opus 4.8, Opus 4.7, Sonnet 5.5, Sonnet 5 and Haiku 5.5; not every model that supports max supports xhigh",
"https://docs.z.ai/guides/vlm/glm-5.3-flash": "verified 2026-10-17: model code glm-5.3-flash, 1M context, 128K output, thinking.type only supports enabled",
"https://dev.meta.ai/docs/models.md": "verified 2026-10-17: muse-spark-1.3 is recommended for new work, muse-spark-1.2 is the previous version, muse-spark-1.3-contributor has no max effort, muse-wonder-1.0 is a preview; all 1,048,576 context",
"https://dev.meta.ai/docs/pricing-rate-limits": "verified 2026-10-17: Muse Spark 1.3 and 1.2 $1.25 in / $0.15 cached / $4.25 out, contributor $0.10 / $0.002 / $0.20; Muse Wonder 1.0 $4 / $0.20 / $20; no long-context premium",
@@ -12480,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": [
{
@@ -12753,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",
@@ -14571,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!(
+1
View File
@@ -1520,6 +1520,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
// Trust posture with a `/set` entry point but no row; surfaced where
// sandboxing acts, not as a settings sentence.
def("sandbox_mode", SettingKind::String, "", None),
def("agents", SettingKind::String, "", None),
// Route memory written by the pickers: per-provider defaults, enabled
// chooser sets, and pinned routes. Structured values no row could edit.
def("provider_models", SettingKind::String, "", None),
+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"] }
+355 -44
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() {
@@ -5452,14 +5452,14 @@ function requireDumper() {
let hasFoldableLine = false;
const shouldTrackWidth = lineWidth !== -1;
let previousLineBreak = -1;
let plain2 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
let plain3 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
if (singleLineOnly || forceQuotes) {
for (i2 = 0; i2 < string.length; char >= 65536 ? i2 += 2 : i2++) {
char = codePointAt(string, i2);
if (!isPrintable(char)) {
return STYLE_DOUBLE;
}
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
plain3 = plain3 && isPlainSafe(char, prevChar, inblock);
prevChar = char;
}
} else {
@@ -5475,13 +5475,13 @@ function requireDumper() {
} else if (!isPrintable(char)) {
return STYLE_DOUBLE;
}
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
plain3 = plain3 && isPlainSafe(char, prevChar, inblock);
prevChar = char;
}
hasFoldableLine = hasFoldableLine || shouldTrackWidth && (i2 - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " ");
}
if (!hasLineBreak && !hasFoldableLine) {
if (plain2 && !forceQuotes && !testAmbiguousType(string)) {
if (plain3 && !forceQuotes && !testAmbiguousType(string)) {
return STYLE_PLAIN;
}
return quotingType === QUOTING_TYPE_DOUBLE ? STYLE_DOUBLE : STYLE_SINGLE;
@@ -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: [
@@ -13553,11 +13554,23 @@ var init_composition = __esm({
});
// src/protocol.generated.ts
var MAGIC_ASCII;
var MAGIC_ASCII, ErrorCode;
var init_protocol_generated = __esm({
"src/protocol.generated.ts"() {
"use strict";
MAGIC_ASCII = "CWX1";
ErrorCode = {
ParseError: -32700,
InvalidRequest: -32600,
MethodNotFound: -32601,
InvalidParams: -32602,
Internal: -32603,
ExecutionFailed: -32e3,
NotAvailable: -32001,
Refused: -32002,
Denied: -32003,
Cancelled: -32800
};
}
});
@@ -13573,10 +13586,88 @@ var init_protocol = __esm({
});
// src/rpc.ts
var RpcError;
var init_rpc = __esm({
"src/rpc.ts"() {
"use strict";
init_protocol();
RpcError = class extends Error {
constructor(code, message, data2) {
super(message);
this.code = code;
this.data = data2;
this.name = "RpcError";
}
code;
data;
toWire() {
return this.data === void 0 ? { code: this.code, message: this.message } : { code: this.code, message: this.message, data: this.data };
}
};
}
});
// src/dsh/session-prompt.ts
function checkpointCall(host, ctx, session) {
const invocation = host.liveInvocation(ctx);
const ticket = invocation?.ticket;
return invocation !== void 0 && typeof ticket === "string" && ticket.length > 0 && session !== void 0 && session === invocation.session ? invocation : void 0;
}
var CHILD_ONLY, sessionsAdapter, systemPromptAdapter;
var init_session_prompt = __esm({
"src/dsh/session-prompt.ts"() {
"use strict";
init_lib2();
init_protocol();
init_rpc();
CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context";
sessionsAdapter = {
service: "sessions",
define(host) {
class SessionsService extends Service {
constructor(ctx) {
super(ctx, "sessions");
}
async flush(session) {
const ctx = this.ctx;
const call = checkpointCall(host, ctx, session);
if (call === void 0) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
const owner = call.owner;
const live = () => checkpointCall(host, ctx, session) === call && !call.signal.aborted;
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
const result2 = await host.rpc.request("session/checkpoint", { owner: owner.ref, ticket: call.ticket }, call.signal);
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
return result2;
};
const result = call.pending.then(run);
call.pending = result.then(() => void 0, () => void 0);
return result;
}
}
Object.freeze(SessionsService.prototype);
return SessionsService;
}
};
systemPromptAdapter = {
service: "systemPrompt",
define() {
class SystemPromptService extends Service {
constructor(ctx) {
super(ctx, "systemPrompt");
}
section() {
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
}
getSectionOrder() {
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
}
}
Object.freeze(SystemPromptService.prototype);
return SystemPromptService;
}
};
}
});
@@ -14208,11 +14299,12 @@ var init_lib3 = __esm({
});
// src/dsh/dsh-message-compat.js
var HarnessError, UserQuestionError;
var PERSONA_PREFIX_SECTION, HarnessError, UserQuestionError;
var init_dsh_message_compat = __esm({
"src/dsh/dsh-message-compat.js"() {
"use strict";
init_lib3();
PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
HarnessError = class extends Error {
code;
constructor(message, code, options) {
@@ -14232,6 +14324,223 @@ var init_dsh_message_compat = __esm({
}
});
// src/dsh/agents.ts
function plain(value, allowed, what) {
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`);
const result = /* @__PURE__ */ Object.create(null);
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (typeof key !== "string" || !allowed.includes(key) || !("value" in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`);
if (descriptor.value !== void 0) result[key] = descriptor.value;
}
return result;
}
function defineSubagents(host) {
class SubagentsShim extends Service {
constructor(ctx) {
super(ctx, "subagents");
}
getProvider(name) {
return name === "spawn" ? SPAWN_PROVIDER : void 0;
}
async startActivation(request) {
const owner = host.ownerOf(this.ctx);
const input = plain(request, ["provider", "label", "childId", "request", "signal", "delivery"], "activation");
if (input.provider !== "spawn") throw new RpcError(ErrorCode.NotAvailable, "only the spawn provider is available");
const spawn = plain(input.request, ["parent", "prompt", "agentOptions"], "spawn request");
const options = spawn.agentOptions === void 0 ? {} : plain(spawn.agentOptions, ["model"], "agentOptions");
if (options.model !== void 0 && typeof options.model !== "string") throw new RpcError(ErrorCode.InvalidParams, "agentOptions.model must be a string");
const delivery = input.delivery === void 0 ? "parent" : input.delivery;
if (delivery !== "caller" && delivery !== "parent") throw new RpcError(ErrorCode.InvalidParams, "delivery must be caller or parent");
const childId = input.childId;
if (typeof childId !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, "childId must be 1-128 letters, digits, or _.:-");
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, "prompt must be a non-empty content array");
const prompt = spawn.prompt.map((block) => {
const item = plain(block, ["type", "text"], "prompt block");
if (item.type !== "text" || typeof item.text !== "string") throw new RpcError(ErrorCode.NotAvailable, "only text prompt blocks are supported");
return item.text;
}).join("");
if (input.signal !== void 0 && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, "signal must be an AbortSignal");
const invocation = host.liveInvocation(this.ctx);
if (!invocation || invocation.agent === void 0 || spawn.parent !== invocation.agent) {
throw new RpcError(ErrorCode.NotAvailable, "startActivation requires the exact live calling agent as parent");
}
if (invocation.owner !== owner) throw new RpcError(ErrorCode.NotAvailable, "subagents service owner is no longer current");
if (invocation.delegated || invocation.subagentTicket === void 0) throw new RpcError(ErrorCode.NotAvailable, "child agents are only available to a root tool invocation");
const signal = input.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, input.signal]);
signal.throwIfAborted();
let persona;
const denied = /* @__PURE__ */ new Set();
let open = true;
const sealed = () => {
if (!open) throw new RpcError(ErrorCode.NotAvailable, "the child creation window has closed");
};
const child = Object.freeze({
id: childId,
ctx: Object.freeze({
systemPrompt: Object.freeze({
getSectionOrder(name) {
sealed();
if (name !== "DEPLOYMENT_PERSONA_PREFIX") throw new RpcError(ErrorCode.NotAvailable, "only the deployment persona slot is available");
return PERSONA_ORDER;
},
section(section) {
sealed();
const value = plain(section, ["name", "order", "text"], "system prompt section");
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== "string") {
throw new RpcError(ErrorCode.NotAvailable, "only the child deployment persona section is supported");
}
if (persona !== void 0) throw new RpcError(ErrorCode.InvalidParams, "the child persona is already set");
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, "child persona exceeds 32768 bytes");
persona = value.text;
return () => void 0;
}
}),
tools: Object.freeze({
restrict(filter) {
sealed();
const value = plain(filter, ["deny"], "tool filter");
if (!Array.isArray(value.deny) || value.deny.some((name) => typeof name !== "string" || name === "")) throw new RpcError(ErrorCode.InvalidParams, "deny must be a list of tool names");
for (const name of value.deny) denied.add(name);
return () => void 0;
}
})
})
});
const listeners = (this.ctx.root?.events?._hooks?.["agent/created"] ?? []).filter((hook) => !hook.global && hook.ctx !== void 0 && host.ownerOf(hook.ctx) === owner).map((hook) => hook.callback);
try {
await Promise.all(listeners.map(async (callback) => callback({ agent: child })));
} finally {
open = false;
}
signal.throwIfAborted();
const call = invocation;
const rpc = (method, params, abort) => host.rpc.request(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort);
const started = await rpc("subagent/start", {
prompt,
...options.model === void 0 ? {} : { model: options.model },
...persona === void 0 ? {} : { persona },
denied_tools: [...denied],
delivery
}, signal);
if (started === null || typeof started !== "object" || started.child_id !== childId || typeof started.message_id !== "string") {
throw new RpcError(ErrorCode.Internal, "invalid child activation receipt");
}
let result;
let disposed;
return Object.freeze({
childId,
messageId: started.message_id,
get result() {
result ??= rpc("subagent/result", {}, call.signal).then((value) => {
if (value === null || typeof value !== "object" || typeof value.stop_reason !== "string" || !Array.isArray(value.output)) {
throw new RpcError(ErrorCode.Internal, "invalid child result receipt");
}
return Object.freeze({
stopReason: value.stop_reason,
output: Object.freeze(value.output.map((block) => Object.freeze({ type: "text", text: String(block.text) }))),
...value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}
});
});
return result;
},
dispose() {
disposed ??= rpc("subagent/dispose", {}, call.signal).then(() => void 0);
return disposed;
}
});
}
}
Object.freeze(SubagentsShim.prototype);
return SubagentsShim;
}
function defineAgents() {
class AgentsShim extends Service {
constructor(ctx) {
super(ctx, "agents");
}
}
Object.freeze(AgentsShim.prototype);
return AgentsShim;
}
var SPAWN_PROVIDER, PERSONA_ORDER, DSH_AGENT_ADAPTERS;
var init_agents = __esm({
"src/dsh/agents.ts"() {
"use strict";
init_lib2();
init_protocol();
init_rpc();
init_dsh_message_compat();
SPAWN_PROVIDER = Object.freeze({
name: "spawn",
inheritsParentContext: false,
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true })
});
PERSONA_ORDER = 0;
DSH_AGENT_ADAPTERS = Object.freeze([
{ service: "subagents", define: defineSubagents },
{ service: "agents", define: defineAgents }
]);
}
});
// 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({
"src/dsh/adapters.ts"() {
"use strict";
init_session_prompt();
init_agents();
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
// src/dsh/dsh-tools-compat.js
var init_dsh_tools_compat = __esm({
"src/dsh/dsh-tools-compat.js"() {
@@ -14313,11 +14622,12 @@ var init_commands = __esm({
// src/root.ts
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
var REFUSED_SERVICES, PROVIDED_SERVICES, ownerStorage;
var REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ownerStorage;
var init_root = __esm({
"src/root.ts"() {
"use strict";
init_composition();
init_adapters();
init_protocol();
init_rpc();
init_dsh_message_compat();
@@ -14339,6 +14649,7 @@ var init_root = __esm({
init_commands();
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
ownerStorage = new AsyncLocalStorage2();
}
});
@@ -14455,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;
}
}
@@ -14487,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;
}
@@ -14659,7 +14970,7 @@ var init_composition_inventory = __esm({
import { createHash as createHash3 } from "node:crypto";
import { resolve as resolve5, relative as relative3, dirname as dirname4, sep as sep3 } from "node:path";
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL5 } from "node:url";
function plain(path) {
function plain2(path) {
return typeof path === "string" && path.length > 0 && path.length <= 4096 && !/[\\:\u0000-\u001f\u007f]/u.test(path) && !path.startsWith("/") && path.split("/").every((p) => p !== "" && p !== "." && p !== "..");
}
function fail(message) {
@@ -14699,7 +15010,7 @@ function containedPackageModule(name, documents, files) {
entry = value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).some((k) => k.startsWith(".")) ? target(value[subpath]) : subpath === "." ? target(value) : void 0;
} else if (subpath === ".") entry = typeof data2.main === "string" ? data2.main : "./index.js";
else entry = subpath;
if (typeof entry !== "string" || !entry.startsWith("./") || !plain(entry.slice(2))) return;
if (typeof entry !== "string" || !entry.startsWith("./") || !plain2(entry.slice(2))) return;
const path = `${packageRoot}/${entry.slice(2)}`;
if (!/\.(mjs|js|mts)$/.test(path) || !SHA.test(files[path] ?? "")) return;
return { name, path, sha256: files[path] };
@@ -14712,20 +15023,20 @@ async function reviewAgentPresets(input) {
const baseUrl = pathToFileURL5(`${base}${sep3}`).href;
function key(path) {
const inside = relative3(base, path).split(sep3).join("/");
if (!plain(inside)) fail("path escapes the admitted source root");
if (!plain2(inside)) fail("path escapes the admitted source root");
return inside;
}
const documents = /* @__PURE__ */ new Map();
let bytes = 0;
for (const doc of input.documents) {
if (!plain(doc.path) || !SHA.test(doc.sha256) || files[doc.path] !== doc.sha256 || hash(doc.source) !== doc.sha256 || documents.has(doc.path)) fail("document changed or is outside the source receipt");
if (!plain2(doc.path) || !SHA.test(doc.sha256) || files[doc.path] !== doc.sha256 || hash(doc.source) !== doc.sha256 || documents.has(doc.path)) fail("document changed or is outside the source receipt");
bytes += Buffer.byteLength(doc.source);
if (Buffer.byteLength(doc.source) > 1024 * 1024 || bytes > 4 * 1024 * 1024) fail("documents exceed their bound");
documents.set(doc.path, doc.source);
}
const directories = new Set(input.directories);
for (const path of directories) if (!plain(path)) fail("invalid source directory");
for (const path of Object.keys(files)) if (!plain(path) || !SHA.test(files[path])) fail("invalid source file receipt");
for (const path of directories) if (!plain2(path)) fail("invalid source directory");
for (const path of Object.keys(files)) if (!plain2(path) || !SHA.test(files[path])) fail("invalid source file receipt");
const invalid = /* @__PURE__ */ new Set();
const parsed = /* @__PURE__ */ new Map();
const io = {
@@ -14790,7 +15101,7 @@ async function reviewAgentPresets(input) {
}
if (config.roots !== void 0 && !Array.isArray(config.roots)) fail("roots must be a literal list");
for (const root of config.roots ?? []) {
if (!root || Object.keys(root).some((k) => !["path", "trust"].includes(k)) || !plain(root.path) || !["system", "user"].includes(root.trust)) fail("configured roots must stay inside the admitted package");
if (!root || Object.keys(root).some((k) => !["path", "trust"].includes(k)) || !plain2(root.path) || !["system", "user"].includes(root.trust)) fail("configured roots must stay inside the admitted package");
if (!directories.has(root.path)) fail("configured preset root was not admitted");
roots.push({ path: resolve5(base, root.path), trust: root.trust });
}
+376 -233
View File
@@ -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() {
@@ -7371,14 +7371,14 @@ function requireDumper() {
let hasFoldableLine = false;
const shouldTrackWidth = lineWidth !== -1;
let previousLineBreak = -1;
let plain = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
let plain2 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
if (singleLineOnly || forceQuotes) {
for (i2 = 0; i2 < string.length; char >= 65536 ? i2 += 2 : i2++) {
char = codePointAt(string, i2);
if (!isPrintable(char)) {
return STYLE_DOUBLE;
}
plain = plain && isPlainSafe(char, prevChar, inblock);
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
prevChar = char;
}
} else {
@@ -7394,13 +7394,13 @@ function requireDumper() {
} else if (!isPrintable(char)) {
return STYLE_DOUBLE;
}
plain = plain && isPlainSafe(char, prevChar, inblock);
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
prevChar = char;
}
hasFoldableLine = hasFoldableLine || shouldTrackWidth && (i2 - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " ");
}
if (!hasLineBreak && !hasFoldableLine) {
if (plain && !forceQuotes && !testAmbiguousType(string)) {
if (plain2 && !forceQuotes && !testAmbiguousType(string)) {
return STYLE_PLAIN;
}
return quotingType === QUOTING_TYPE_DOUBLE ? STYLE_DOUBLE : STYLE_SINGLE;
@@ -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: [
@@ -15899,6 +15900,7 @@ var init_protocol_generated = __esm({
{ name: "subagent/start", direction: "host_to_core", request: true, params: "SubagentStartParams", tiers: ["plugin", "builtin"] },
{ name: "subagent/result", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
{ name: "subagent/dispose", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
{ name: "dsh/broker", direction: "host_to_core", request: true, params: "DshBrokerParams", tiers: ["plugin", "builtin"] },
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
@@ -15939,6 +15941,16 @@ var init_protocol_generated = __esm({
required: { owner: { ref: "OwnerRef" } },
optional: { entry: { ref: "EntryRef" } }
},
DshBrokerParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, ticket: "string", service: "string", method: "string", input: "json" },
optional: {}
},
DshBrokerTicketWire: {
strict: true,
required: { service: "string", ticket: "string" },
optional: {}
},
EmptyParams: {
strict: true,
required: {},
@@ -16102,7 +16114,7 @@ var init_protocol_generated = __esm({
ToolCallParams: {
strict: false,
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", broker_tickets: { items: { ref: "DshBrokerTicketWire" } }, caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
},
UnregisterParams: {
strict: true,
@@ -16425,6 +16437,291 @@ var init_rpc = __esm({
}
});
// src/dsh/session-prompt.ts
function checkpointCall(host2, ctx, session) {
const invocation = host2.liveInvocation(ctx);
const ticket = invocation?.ticket;
return invocation !== void 0 && typeof ticket === "string" && ticket.length > 0 && session !== void 0 && session === invocation.session ? invocation : void 0;
}
var CHILD_ONLY, sessionsAdapter, systemPromptAdapter;
var init_session_prompt = __esm({
"src/dsh/session-prompt.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_lib2();
init_protocol();
init_rpc();
CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context";
sessionsAdapter = {
service: "sessions",
define(host2) {
class SessionsService extends Service {
constructor(ctx) {
super(ctx, "sessions");
}
async flush(session) {
const ctx = this.ctx;
const call = checkpointCall(host2, ctx, session);
if (call === void 0) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
const owner = call.owner;
const live = () => checkpointCall(host2, ctx, session) === call && !call.signal.aborted;
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
const result2 = await host2.rpc.request("session/checkpoint", { owner: owner.ref, ticket: call.ticket }, call.signal);
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
return result2;
};
const result = call.pending.then(run);
call.pending = result.then(() => void 0, () => void 0);
return result;
}
}
Object.freeze(SessionsService.prototype);
return SessionsService;
}
};
systemPromptAdapter = {
service: "systemPrompt",
define() {
class SystemPromptService extends Service {
constructor(ctx) {
super(ctx, "systemPrompt");
}
section() {
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
}
getSectionOrder() {
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
}
}
Object.freeze(SystemPromptService.prototype);
return SystemPromptService;
}
};
}
});
// src/dsh/agents.ts
function plain(value, allowed, what) {
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`);
const result = /* @__PURE__ */ Object.create(null);
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (typeof key !== "string" || !allowed.includes(key) || !("value" in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`);
if (descriptor.value !== void 0) result[key] = descriptor.value;
}
return result;
}
function defineSubagents(host2) {
class SubagentsShim extends Service {
constructor(ctx) {
super(ctx, "subagents");
}
getProvider(name) {
return name === "spawn" ? SPAWN_PROVIDER : void 0;
}
async startActivation(request) {
const owner = host2.ownerOf(this.ctx);
const input = plain(request, ["provider", "label", "childId", "request", "signal", "delivery"], "activation");
if (input.provider !== "spawn") throw new RpcError(ErrorCode.NotAvailable, "only the spawn provider is available");
const spawn = plain(input.request, ["parent", "prompt", "agentOptions"], "spawn request");
const options = spawn.agentOptions === void 0 ? {} : plain(spawn.agentOptions, ["model"], "agentOptions");
if (options.model !== void 0 && typeof options.model !== "string") throw new RpcError(ErrorCode.InvalidParams, "agentOptions.model must be a string");
const delivery = input.delivery === void 0 ? "parent" : input.delivery;
if (delivery !== "caller" && delivery !== "parent") throw new RpcError(ErrorCode.InvalidParams, "delivery must be caller or parent");
const childId = input.childId;
if (typeof childId !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, "childId must be 1-128 letters, digits, or _.:-");
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, "prompt must be a non-empty content array");
const prompt = spawn.prompt.map((block) => {
const item = plain(block, ["type", "text"], "prompt block");
if (item.type !== "text" || typeof item.text !== "string") throw new RpcError(ErrorCode.NotAvailable, "only text prompt blocks are supported");
return item.text;
}).join("");
if (input.signal !== void 0 && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, "signal must be an AbortSignal");
const invocation = host2.liveInvocation(this.ctx);
if (!invocation || invocation.agent === void 0 || spawn.parent !== invocation.agent) {
throw new RpcError(ErrorCode.NotAvailable, "startActivation requires the exact live calling agent as parent");
}
if (invocation.owner !== owner) throw new RpcError(ErrorCode.NotAvailable, "subagents service owner is no longer current");
if (invocation.delegated || invocation.subagentTicket === void 0) throw new RpcError(ErrorCode.NotAvailable, "child agents are only available to a root tool invocation");
const signal = input.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, input.signal]);
signal.throwIfAborted();
let persona;
const denied2 = /* @__PURE__ */ new Set();
let open2 = true;
const sealed = () => {
if (!open2) throw new RpcError(ErrorCode.NotAvailable, "the child creation window has closed");
};
const child = Object.freeze({
id: childId,
ctx: Object.freeze({
systemPrompt: Object.freeze({
getSectionOrder(name) {
sealed();
if (name !== "DEPLOYMENT_PERSONA_PREFIX") throw new RpcError(ErrorCode.NotAvailable, "only the deployment persona slot is available");
return PERSONA_ORDER;
},
section(section) {
sealed();
const value = plain(section, ["name", "order", "text"], "system prompt section");
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== "string") {
throw new RpcError(ErrorCode.NotAvailable, "only the child deployment persona section is supported");
}
if (persona !== void 0) throw new RpcError(ErrorCode.InvalidParams, "the child persona is already set");
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, "child persona exceeds 32768 bytes");
persona = value.text;
return () => void 0;
}
}),
tools: Object.freeze({
restrict(filter) {
sealed();
const value = plain(filter, ["deny"], "tool filter");
if (!Array.isArray(value.deny) || value.deny.some((name) => typeof name !== "string" || name === "")) throw new RpcError(ErrorCode.InvalidParams, "deny must be a list of tool names");
for (const name of value.deny) denied2.add(name);
return () => void 0;
}
})
})
});
const listeners = (this.ctx.root?.events?._hooks?.["agent/created"] ?? []).filter((hook) => !hook.global && hook.ctx !== void 0 && host2.ownerOf(hook.ctx) === owner).map((hook) => hook.callback);
try {
await Promise.all(listeners.map(async (callback) => callback({ agent: child })));
} finally {
open2 = false;
}
signal.throwIfAborted();
const call = invocation;
const rpc2 = (method, params, abort) => host2.rpc.request(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort);
const started = await rpc2("subagent/start", {
prompt,
...options.model === void 0 ? {} : { model: options.model },
...persona === void 0 ? {} : { persona },
denied_tools: [...denied2],
delivery
}, signal);
if (started === null || typeof started !== "object" || started.child_id !== childId || typeof started.message_id !== "string") {
throw new RpcError(ErrorCode.Internal, "invalid child activation receipt");
}
let result;
let disposed;
return Object.freeze({
childId,
messageId: started.message_id,
get result() {
result ??= rpc2("subagent/result", {}, call.signal).then((value) => {
if (value === null || typeof value !== "object" || typeof value.stop_reason !== "string" || !Array.isArray(value.output)) {
throw new RpcError(ErrorCode.Internal, "invalid child result receipt");
}
return Object.freeze({
stopReason: value.stop_reason,
output: Object.freeze(value.output.map((block) => Object.freeze({ type: "text", text: String(block.text) }))),
...value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}
});
});
return result;
},
dispose() {
disposed ??= rpc2("subagent/dispose", {}, call.signal).then(() => void 0);
return disposed;
}
});
}
}
Object.freeze(SubagentsShim.prototype);
return SubagentsShim;
}
function defineAgents() {
class AgentsShim extends Service {
constructor(ctx) {
super(ctx, "agents");
}
}
Object.freeze(AgentsShim.prototype);
return AgentsShim;
}
var SPAWN_PROVIDER, PERSONA_ORDER, DSH_AGENT_ADAPTERS;
var init_agents = __esm({
"src/dsh/agents.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_lib2();
init_protocol();
init_rpc();
init_dsh_message_compat();
SPAWN_PROVIDER = Object.freeze({
name: "spawn",
inheritsParentContext: false,
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true })
});
PERSONA_ORDER = 0;
DSH_AGENT_ADAPTERS = Object.freeze([
{ service: "subagents", define: defineSubagents },
{ service: "agents", define: defineAgents }
]);
}
});
// 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({
"src/dsh/adapters.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_session_prompt();
init_agents();
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
// src/shims/core.ts
function failureFor(error) {
if (error instanceof CoreCallError) return error;
@@ -17200,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));
@@ -17300,12 +17597,13 @@ function pendingFibers(fiber) {
}
return out;
}
var OWNER, REFUSED_SERVICES, PROVIDED_SERVICES, ACTIVATE_DEADLINE_MS, DISPOSE_DEADLINE_MS, ownerStorage, HostRoot;
var OWNER, REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ACTIVATE_DEADLINE_MS, DISPOSE_DEADLINE_MS, ownerStorage, HostRoot;
var init_root = __esm({
"src/root.ts"() {
"use strict";
init_define_BUILTIN_MODULE_DIGESTS();
init_composition();
init_adapters();
init_lib2();
init_protocol();
init_rpc();
@@ -17329,11 +17627,12 @@ var init_root = __esm({
OWNER = /* @__PURE__ */ Symbol.for("codewhale.extension-host.owner");
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
ACTIVATE_DEADLINE_MS = 5e3;
DISPOSE_DEADLINE_MS = 2e3;
ownerStorage = new AsyncLocalStorage2();
HostRoot = class {
constructor(rpc2, tier) {
constructor(rpc2, tier, adapters = DSH_ADAPTERS) {
this.rpc = rpc2;
this.tier = tier;
const root = new Context();
@@ -17471,28 +17770,6 @@ var init_root = __esm({
return this.api().delete(key);
}
}
class SessionsShim extends Service {
constructor(ctx) {
super(ctx, "sessions");
}
async flush(session) {
const invocation = host2.toolInvocations.getStore();
const owner = this.ctx[OWNER];
const live = () => invocation && invocation.active && !invocation.signal.aborted && invocation.owner === owner && owner?.state === "active" && !owner.disposing && invocation.ticket !== void 0 && session === invocation.session && session !== void 0;
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
const call = invocation;
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
const result2 = await host2.rpc.request("session/checkpoint", { owner: call.owner.ref, ticket: call.ticket }, call.signal);
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
return result2;
};
const result = call.pending.then(run);
call.pending = result.then(() => void 0, () => void 0);
return result;
}
}
class UserQuestionsShim extends Service {
constructor(ctx) {
super(ctx, "userQuestions");
@@ -17611,185 +17888,49 @@ var init_root = __esm({
return Promise.race([result, cancelled]).finally(() => signal.removeEventListener("abort", cancel));
}
}
const SPAWN_PROVIDER = Object.freeze({
name: "spawn",
inheritsParentContext: false,
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true })
});
const PERSONA_ORDER = 0;
class SubagentsShim extends Service {
constructor(ctx) {
super(ctx, "subagents");
}
getProvider(name) {
return name === "spawn" ? SPAWN_PROVIDER : void 0;
}
async startActivation(request) {
const invocation = host2.toolInvocations.getStore();
const owner = this.ctx[OWNER];
const plain = (value, allowed, what) => {
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`);
const result2 = /* @__PURE__ */ Object.create(null);
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (typeof key !== "string" || !allowed.includes(key) || !("value" in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`);
if (descriptor.value !== void 0) result2[key] = descriptor.value;
}
return result2;
};
const input = plain(request, ["provider", "label", "childId", "request", "signal", "delivery"], "activation");
if (input.provider !== "spawn") throw new RpcError(ErrorCode.NotAvailable, "only the spawn provider is available");
const spawn = plain(input.request, ["parent", "prompt", "agentOptions"], "spawn request");
const options = spawn.agentOptions === void 0 ? {} : plain(spawn.agentOptions, ["model"], "agentOptions");
if (options.model !== void 0 && typeof options.model !== "string") throw new RpcError(ErrorCode.InvalidParams, "agentOptions.model must be a string");
const delivery = input.delivery === void 0 ? "parent" : input.delivery;
if (delivery !== "caller" && delivery !== "parent") throw new RpcError(ErrorCode.InvalidParams, "delivery must be caller or parent");
const childId = input.childId;
if (typeof childId !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, "childId must be 1-128 letters, digits, or _.:-");
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, "prompt must be a non-empty content array");
const prompt = spawn.prompt.map((block) => {
const item = plain(block, ["type", "text"], "prompt block");
if (item.type !== "text" || typeof item.text !== "string") throw new RpcError(ErrorCode.NotAvailable, "only text prompt blocks are supported");
return item.text;
}).join("");
if (input.signal !== void 0 && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, "signal must be an AbortSignal");
if (!invocation || !invocation.active || invocation.signal.aborted || invocation.agent === void 0 || spawn.parent !== invocation.agent) {
throw new RpcError(ErrorCode.NotAvailable, "startActivation requires the exact live calling agent as parent");
}
if (invocation.owner !== owner || owner?.state !== "active" || owner.disposing) throw new RpcError(ErrorCode.NotAvailable, "subagents service owner is no longer current");
if (invocation.delegated || invocation.subagentTicket === void 0) throw new RpcError(ErrorCode.NotAvailable, "child agents are only available to a root tool invocation");
const signal = input.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, input.signal]);
signal.throwIfAborted();
let persona;
const denied2 = /* @__PURE__ */ new Set();
let open2 = true;
const sealed = () => {
if (!open2) throw new RpcError(ErrorCode.NotAvailable, "the child creation window has closed");
};
const child = Object.freeze({
id: childId,
ctx: Object.freeze({
systemPrompt: Object.freeze({
getSectionOrder(name) {
sealed();
if (name !== "DEPLOYMENT_PERSONA_PREFIX") throw new RpcError(ErrorCode.NotAvailable, "only the deployment persona slot is available");
return PERSONA_ORDER;
},
section(section) {
sealed();
const value = plain(section, ["name", "order", "text"], "system prompt section");
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== "string") {
throw new RpcError(ErrorCode.NotAvailable, "only the child deployment persona section is supported");
}
if (persona !== void 0) throw new RpcError(ErrorCode.InvalidParams, "the child persona is already set");
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, "child persona exceeds 32768 bytes");
persona = value.text;
return () => void 0;
}
}),
tools: Object.freeze({
restrict(filter) {
sealed();
const value = plain(filter, ["deny"], "tool filter");
if (!Array.isArray(value.deny) || value.deny.some((name) => typeof name !== "string" || name === "")) throw new RpcError(ErrorCode.InvalidParams, "deny must be a list of tool names");
for (const name of value.deny) denied2.add(name);
return () => void 0;
}
})
})
});
const listeners = (host2.root.events?._hooks?.["agent/created"] ?? []).filter((hook) => !hook.global && hook.ctx?.[OWNER] === owner).map((hook) => hook.callback);
try {
await Promise.all(listeners.map(async (callback) => callback({ agent: child })));
} finally {
open2 = false;
}
signal.throwIfAborted();
const call = invocation;
const rpc3 = (method, params, abort) => host2.rpc.request(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort);
const started = await rpc3("subagent/start", {
prompt,
...options.model === void 0 ? {} : { model: options.model },
...persona === void 0 ? {} : { persona },
denied_tools: [...denied2],
delivery
}, signal);
if (started === null || typeof started !== "object" || started.child_id !== childId || typeof started.message_id !== "string") {
throw new RpcError(ErrorCode.Internal, "invalid child activation receipt");
}
let result;
let disposed;
return Object.freeze({
childId,
messageId: started.message_id,
get result() {
result ??= rpc3("subagent/result", {}, call.signal).then((value) => {
if (value === null || typeof value !== "object" || typeof value.stop_reason !== "string" || !Array.isArray(value.output)) {
throw new RpcError(ErrorCode.Internal, "invalid child result receipt");
}
return Object.freeze({
stopReason: value.stop_reason,
output: Object.freeze(value.output.map((block) => Object.freeze({ type: "text", text: String(block.text) }))),
...value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}
});
});
return result;
},
dispose() {
disposed ??= rpc3("subagent/dispose", {}, call.signal).then(() => void 0);
return disposed;
}
});
}
}
class AgentsShim extends Service {
constructor(ctx) {
super(ctx, "agents");
}
}
class SystemPromptShim extends Service {
constructor(ctx) {
super(ctx, "systemPrompt");
}
section() {
throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context");
}
getSectionOrder() {
throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context");
}
}
Object.freeze(SubagentsShim.prototype);
Object.freeze(AgentsShim.prototype);
Object.freeze(SystemPromptShim.prototype);
Object.freeze(UserQuestionsShim.prototype);
Object.freeze(SessionsShim.prototype);
Object.freeze(StorageShim.prototype);
shimClasses.set("tools", ToolsShim);
shimClasses.set("commands", CommandsShim);
shimClasses.set("prompt", PromptShim);
shimClasses.set("storage", StorageShim);
shimClasses.set("sessions", SessionsShim);
shimClasses.set("userQuestions", UserQuestionsShim);
shimClasses.set("subagents", SubagentsShim);
shimClasses.set("agents", AgentsShim);
shimClasses.set("systemPrompt", SystemPromptShim);
shimClasses.set("skills", SkillsShim);
shimClasses.set("avatars", AvatarsShim);
shimClasses.set("mcp", McpShim);
shimClasses.set("shellHooks", ShellHooksShim);
root.plugin(ToolsShim);
root.plugin(CommandsShim);
root.plugin(PromptShim);
root.plugin(StorageShim);
root.plugin(SessionsShim);
root.plugin(UserQuestionsShim);
root.plugin(SubagentsShim);
root.plugin(AgentsShim);
root.plugin(SystemPromptShim);
root.plugin(SkillsShim);
root.plugin(AvatarsShim);
root.plugin(McpShim);
root.plugin(ShellHooksShim);
const adapterHost = {
tier,
rpc: rpc2,
ownerOf: (ctx) => ctx[OWNER],
liveInvocation: (ctx) => {
const invocation = host2.toolInvocations.getStore();
const owner = ctx[OWNER];
return invocation && invocation.active && !invocation.signal.aborted && owner !== void 0 && invocation.owner === owner && owner.state === "active" && !owner.disposing ? invocation : void 0;
},
log: (level, message, owner) => host2.log(level, message, owner),
broker: async (ctx, service, method, input) => {
const invocation = adapterHost.liveInvocation(ctx);
const ticket = invocation?.brokerTickets.get(service);
if (invocation === void 0 || ticket === void 0) throw new RpcError(ErrorCode.NotAvailable, `the ${String(service).slice(0, 64)} broker is only available to a live tool invocation the core gave a ticket for it`);
return rpc2.request("dsh/broker", { owner: invocation.owner.ref, ticket, service, method, input }, invocation.signal);
}
};
const shims = /* @__PURE__ */ new Map([
["tools", ToolsShim],
["commands", CommandsShim],
["prompt", PromptShim],
["storage", StorageShim],
["userQuestions", UserQuestionsShim],
["skills", SkillsShim],
["avatars", AvatarsShim],
["mcp", McpShim],
["shellHooks", ShellHooksShim]
]);
for (const adapter of adapters) {
if (shims.has(adapter.service)) throw new Error(`DSH adapter for \`${adapter.service}\` duplicates a service the host root already provides`);
shims.set(adapter.service, adapter.define(adapterHost));
}
const mismatched = [
...[...shims.keys()].filter((name) => !PROVIDED_SERVICES.has(name)).map((name) => `${name} is installed but not declared in compatibility.json`),
...[...PROVIDED_SERVICES].filter((name) => !CORDIS_SERVICES.has(name) && !shims.has(name)).map((name) => `${name} is declared in compatibility.json but has no installed service`)
];
if (mismatched.length > 0) throw new Error(`DSH provided services disagree with the installed services: ${mismatched.join("; ")}`);
for (const [name, shim] of shims) {
shimClasses.set(name, shim);
root.plugin(shim);
}
shimClasses.set("loader", ReviewedLoader);
installCompositionLoader(root);
}
@@ -17910,7 +18051,8 @@ var init_root = __esm({
const plugin = pickPlugin(module);
const missing = Object.keys(Inject.resolve(plugin.inject)).filter((name) => !PROVIDED_SERVICES.has(name));
if (missing.length > 0) {
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(", ")}, not provided by the Codewhale extension host in this phase`);
const owned = missing.filter((name) => REFUSED_SERVICES.has(name));
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(", ")}, not provided by the Codewhale extension host in this phase` + (owned.length > 0 ? `; ${owned.map((name) => `\`${name}\``).join(", ")} belong${owned.length === 1 ? "s" : ""} to the Codewhale core and no reviewed bridge exposes ${owned.length === 1 ? "it" : "them"} yet` : ""));
}
const ownerCtx = this.root.extend({ [OWNER]: owner });
const fiber = ownerStorage.run(owner, () => ownerCtx.plugin(plugin, params.config ?? {}));
@@ -18059,6 +18201,7 @@ var init_root = __esm({
agent,
questionTicket: identity?.question_ticket,
subagentTicket: identity?.subagent_ticket,
brokerTickets: new Map((identity?.broker_tickets ?? []).map(({ service, ticket: ticket2 }) => [service, ticket2])),
callerTools: Object.freeze([...identity?.caller_tools ?? []]),
delegated: identity?.agent_id !== void 0,
ticket: identity?.checkpoint_ticket,
+355 -44
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() {
@@ -5423,14 +5423,14 @@ function requireDumper() {
let hasFoldableLine = false;
const shouldTrackWidth = lineWidth !== -1;
let previousLineBreak = -1;
let plain2 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
let plain3 = isPlainSafeFirst(codePointAt(string, 0)) && isPlainSafeLast(codePointAt(string, string.length - 1));
if (singleLineOnly || forceQuotes) {
for (i2 = 0; i2 < string.length; char >= 65536 ? i2 += 2 : i2++) {
char = codePointAt(string, i2);
if (!isPrintable(char)) {
return STYLE_DOUBLE;
}
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
plain3 = plain3 && isPlainSafe(char, prevChar, inblock);
prevChar = char;
}
} else {
@@ -5446,13 +5446,13 @@ function requireDumper() {
} else if (!isPrintable(char)) {
return STYLE_DOUBLE;
}
plain2 = plain2 && isPlainSafe(char, prevChar, inblock);
plain3 = plain3 && isPlainSafe(char, prevChar, inblock);
prevChar = char;
}
hasFoldableLine = hasFoldableLine || shouldTrackWidth && (i2 - previousLineBreak - 1 > lineWidth && string[previousLineBreak + 1] !== " ");
}
if (!hasLineBreak && !hasFoldableLine) {
if (plain2 && !forceQuotes && !testAmbiguousType(string)) {
if (plain3 && !forceQuotes && !testAmbiguousType(string)) {
return STYLE_PLAIN;
}
return quotingType === QUOTING_TYPE_DOUBLE ? STYLE_DOUBLE : STYLE_SINGLE;
@@ -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: [
@@ -13229,11 +13230,23 @@ var init_prompt = __esm({
});
// src/protocol.generated.ts
var MAGIC_ASCII;
var MAGIC_ASCII, ErrorCode;
var init_protocol_generated = __esm({
"src/protocol.generated.ts"() {
"use strict";
MAGIC_ASCII = "CWX1";
ErrorCode = {
ParseError: -32700,
InvalidRequest: -32600,
MethodNotFound: -32601,
InvalidParams: -32602,
Internal: -32603,
ExecutionFailed: -32e3,
NotAvailable: -32001,
Refused: -32002,
Denied: -32003,
Cancelled: -32800
};
}
});
@@ -13249,10 +13262,88 @@ var init_protocol = __esm({
});
// src/rpc.ts
var RpcError;
var init_rpc = __esm({
"src/rpc.ts"() {
"use strict";
init_protocol();
RpcError = class extends Error {
constructor(code, message, data2) {
super(message);
this.code = code;
this.data = data2;
this.name = "RpcError";
}
code;
data;
toWire() {
return this.data === void 0 ? { code: this.code, message: this.message } : { code: this.code, message: this.message, data: this.data };
}
};
}
});
// src/dsh/session-prompt.ts
function checkpointCall(host, ctx, session) {
const invocation = host.liveInvocation(ctx);
const ticket = invocation?.ticket;
return invocation !== void 0 && typeof ticket === "string" && ticket.length > 0 && session !== void 0 && session === invocation.session ? invocation : void 0;
}
var CHILD_ONLY, sessionsAdapter, systemPromptAdapter;
var init_session_prompt = __esm({
"src/dsh/session-prompt.ts"() {
"use strict";
init_lib2();
init_protocol();
init_rpc();
CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context";
sessionsAdapter = {
service: "sessions",
define(host) {
class SessionsService extends Service {
constructor(ctx) {
super(ctx, "sessions");
}
async flush(session) {
const ctx = this.ctx;
const call = checkpointCall(host, ctx, session);
if (call === void 0) throw new RpcError(ErrorCode.NotAvailable, "sessions.flush requires this live tool invocation’s session");
const owner = call.owner;
const live = () => checkpointCall(host, ctx, session) === call && !call.signal.aborted;
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
const result2 = await host.rpc.request("session/checkpoint", { owner: owner.ref, ticket: call.ticket }, call.signal);
if (!live()) throw new RpcError(ErrorCode.NotAvailable, "session checkpoint invocation ended");
if (typeof result2 !== "boolean") throw new RpcError(ErrorCode.Internal, "invalid session checkpoint receipt");
return result2;
};
const result = call.pending.then(run);
call.pending = result.then(() => void 0, () => void 0);
return result;
}
}
Object.freeze(SessionsService.prototype);
return SessionsService;
}
};
systemPromptAdapter = {
service: "systemPrompt",
define() {
class SystemPromptService extends Service {
constructor(ctx) {
super(ctx, "systemPrompt");
}
section() {
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
}
getSectionOrder() {
throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY);
}
}
Object.freeze(SystemPromptService.prototype);
return SystemPromptService;
}
};
}
});
@@ -13884,11 +13975,12 @@ var init_lib3 = __esm({
});
// src/dsh/dsh-message-compat.js
var HarnessError, UserQuestionError;
var PERSONA_PREFIX_SECTION, HarnessError, UserQuestionError;
var init_dsh_message_compat = __esm({
"src/dsh/dsh-message-compat.js"() {
"use strict";
init_lib3();
PERSONA_PREFIX_SECTION = "deployment:persona-prefix";
HarnessError = class extends Error {
code;
constructor(message, code, options) {
@@ -13908,6 +14000,223 @@ var init_dsh_message_compat = __esm({
}
});
// src/dsh/agents.ts
function plain(value, allowed, what) {
if (value === null || typeof value !== "object" || Array.isArray(value) || ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`);
const result = /* @__PURE__ */ Object.create(null);
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (typeof key !== "string" || !allowed.includes(key) || !("value" in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`);
if (descriptor.value !== void 0) result[key] = descriptor.value;
}
return result;
}
function defineSubagents(host) {
class SubagentsShim extends Service {
constructor(ctx) {
super(ctx, "subagents");
}
getProvider(name) {
return name === "spawn" ? SPAWN_PROVIDER : void 0;
}
async startActivation(request) {
const owner = host.ownerOf(this.ctx);
const input = plain(request, ["provider", "label", "childId", "request", "signal", "delivery"], "activation");
if (input.provider !== "spawn") throw new RpcError(ErrorCode.NotAvailable, "only the spawn provider is available");
const spawn = plain(input.request, ["parent", "prompt", "agentOptions"], "spawn request");
const options = spawn.agentOptions === void 0 ? {} : plain(spawn.agentOptions, ["model"], "agentOptions");
if (options.model !== void 0 && typeof options.model !== "string") throw new RpcError(ErrorCode.InvalidParams, "agentOptions.model must be a string");
const delivery = input.delivery === void 0 ? "parent" : input.delivery;
if (delivery !== "caller" && delivery !== "parent") throw new RpcError(ErrorCode.InvalidParams, "delivery must be caller or parent");
const childId = input.childId;
if (typeof childId !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, "childId must be 1-128 letters, digits, or _.:-");
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, "prompt must be a non-empty content array");
const prompt = spawn.prompt.map((block) => {
const item = plain(block, ["type", "text"], "prompt block");
if (item.type !== "text" || typeof item.text !== "string") throw new RpcError(ErrorCode.NotAvailable, "only text prompt blocks are supported");
return item.text;
}).join("");
if (input.signal !== void 0 && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, "signal must be an AbortSignal");
const invocation = host.liveInvocation(this.ctx);
if (!invocation || invocation.agent === void 0 || spawn.parent !== invocation.agent) {
throw new RpcError(ErrorCode.NotAvailable, "startActivation requires the exact live calling agent as parent");
}
if (invocation.owner !== owner) throw new RpcError(ErrorCode.NotAvailable, "subagents service owner is no longer current");
if (invocation.delegated || invocation.subagentTicket === void 0) throw new RpcError(ErrorCode.NotAvailable, "child agents are only available to a root tool invocation");
const signal = input.signal === void 0 ? invocation.signal : AbortSignal.any([invocation.signal, input.signal]);
signal.throwIfAborted();
let persona;
const denied = /* @__PURE__ */ new Set();
let open = true;
const sealed = () => {
if (!open) throw new RpcError(ErrorCode.NotAvailable, "the child creation window has closed");
};
const child = Object.freeze({
id: childId,
ctx: Object.freeze({
systemPrompt: Object.freeze({
getSectionOrder(name) {
sealed();
if (name !== "DEPLOYMENT_PERSONA_PREFIX") throw new RpcError(ErrorCode.NotAvailable, "only the deployment persona slot is available");
return PERSONA_ORDER;
},
section(section) {
sealed();
const value = plain(section, ["name", "order", "text"], "system prompt section");
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== "string") {
throw new RpcError(ErrorCode.NotAvailable, "only the child deployment persona section is supported");
}
if (persona !== void 0) throw new RpcError(ErrorCode.InvalidParams, "the child persona is already set");
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, "child persona exceeds 32768 bytes");
persona = value.text;
return () => void 0;
}
}),
tools: Object.freeze({
restrict(filter) {
sealed();
const value = plain(filter, ["deny"], "tool filter");
if (!Array.isArray(value.deny) || value.deny.some((name) => typeof name !== "string" || name === "")) throw new RpcError(ErrorCode.InvalidParams, "deny must be a list of tool names");
for (const name of value.deny) denied.add(name);
return () => void 0;
}
})
})
});
const listeners = (this.ctx.root?.events?._hooks?.["agent/created"] ?? []).filter((hook) => !hook.global && hook.ctx !== void 0 && host.ownerOf(hook.ctx) === owner).map((hook) => hook.callback);
try {
await Promise.all(listeners.map(async (callback) => callback({ agent: child })));
} finally {
open = false;
}
signal.throwIfAborted();
const call = invocation;
const rpc = (method, params, abort) => host.rpc.request(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort);
const started = await rpc("subagent/start", {
prompt,
...options.model === void 0 ? {} : { model: options.model },
...persona === void 0 ? {} : { persona },
denied_tools: [...denied],
delivery
}, signal);
if (started === null || typeof started !== "object" || started.child_id !== childId || typeof started.message_id !== "string") {
throw new RpcError(ErrorCode.Internal, "invalid child activation receipt");
}
let result;
let disposed;
return Object.freeze({
childId,
messageId: started.message_id,
get result() {
result ??= rpc("subagent/result", {}, call.signal).then((value) => {
if (value === null || typeof value !== "object" || typeof value.stop_reason !== "string" || !Array.isArray(value.output)) {
throw new RpcError(ErrorCode.Internal, "invalid child result receipt");
}
return Object.freeze({
stopReason: value.stop_reason,
output: Object.freeze(value.output.map((block) => Object.freeze({ type: "text", text: String(block.text) }))),
...value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}
});
});
return result;
},
dispose() {
disposed ??= rpc("subagent/dispose", {}, call.signal).then(() => void 0);
return disposed;
}
});
}
}
Object.freeze(SubagentsShim.prototype);
return SubagentsShim;
}
function defineAgents() {
class AgentsShim extends Service {
constructor(ctx) {
super(ctx, "agents");
}
}
Object.freeze(AgentsShim.prototype);
return AgentsShim;
}
var SPAWN_PROVIDER, PERSONA_ORDER, DSH_AGENT_ADAPTERS;
var init_agents = __esm({
"src/dsh/agents.ts"() {
"use strict";
init_lib2();
init_protocol();
init_rpc();
init_dsh_message_compat();
SPAWN_PROVIDER = Object.freeze({
name: "spawn",
inheritsParentContext: false,
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true })
});
PERSONA_ORDER = 0;
DSH_AGENT_ADAPTERS = Object.freeze([
{ service: "subagents", define: defineSubagents },
{ service: "agents", define: defineAgents }
]);
}
});
// 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({
"src/dsh/adapters.ts"() {
"use strict";
init_session_prompt();
init_agents();
init_remote();
DSH_ADAPTERS = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter];
}
});
// src/dsh/dsh-tools-compat.js
var init_dsh_tools_compat = __esm({
"src/dsh/dsh-tools-compat.js"() {
@@ -13989,11 +14298,12 @@ var init_commands = __esm({
// src/root.ts
import { AsyncLocalStorage as AsyncLocalStorage2 } from "node:async_hooks";
var REFUSED_SERVICES, PROVIDED_SERVICES, ownerStorage;
var REFUSED_SERVICES, PROVIDED_SERVICES, CORDIS_SERVICES, ownerStorage;
var init_root = __esm({
"src/root.ts"() {
"use strict";
init_composition();
init_adapters();
init_protocol();
init_rpc();
init_dsh_message_compat();
@@ -14015,6 +14325,7 @@ var init_root = __esm({
init_commands();
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
PROVIDED_SERVICES = new Set(Object.values(compatibility_default.provided_services).flat());
CORDIS_SERVICES = new Set(compatibility_default.provided_services.cordis);
ownerStorage = new AsyncLocalStorage2();
}
});
@@ -14131,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;
}
}
@@ -14163,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;
}
@@ -14335,7 +14646,7 @@ var init_composition_inventory = __esm({
import { createHash as createHash2 } from "node:crypto";
import { resolve as resolve4, relative as relative2, dirname as dirname4, sep as sep2 } from "node:path";
import { fileURLToPath as fileURLToPath4, pathToFileURL as pathToFileURL5 } from "node:url";
function plain(path) {
function plain2(path) {
return typeof path === "string" && path.length > 0 && path.length <= 4096 && !/[\\:\u0000-\u001f\u007f]/u.test(path) && !path.startsWith("/") && path.split("/").every((p) => p !== "" && p !== "." && p !== "..");
}
function fail(message) {
@@ -14375,7 +14686,7 @@ function containedPackageModule(name, documents, files) {
entry = value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).some((k) => k.startsWith(".")) ? target(value[subpath]) : subpath === "." ? target(value) : void 0;
} else if (subpath === ".") entry = typeof data2.main === "string" ? data2.main : "./index.js";
else entry = subpath;
if (typeof entry !== "string" || !entry.startsWith("./") || !plain(entry.slice(2))) return;
if (typeof entry !== "string" || !entry.startsWith("./") || !plain2(entry.slice(2))) return;
const path = `${packageRoot}/${entry.slice(2)}`;
if (!/\.(mjs|js|mts)$/.test(path) || !SHA.test(files[path] ?? "")) return;
return { name, path, sha256: files[path] };
@@ -14388,20 +14699,20 @@ async function reviewAgentPresets(input) {
const baseUrl = pathToFileURL5(`${base}${sep2}`).href;
function key(path) {
const inside = relative2(base, path).split(sep2).join("/");
if (!plain(inside)) fail("path escapes the admitted source root");
if (!plain2(inside)) fail("path escapes the admitted source root");
return inside;
}
const documents = /* @__PURE__ */ new Map();
let bytes = 0;
for (const doc of input.documents) {
if (!plain(doc.path) || !SHA.test(doc.sha256) || files[doc.path] !== doc.sha256 || hash(doc.source) !== doc.sha256 || documents.has(doc.path)) fail("document changed or is outside the source receipt");
if (!plain2(doc.path) || !SHA.test(doc.sha256) || files[doc.path] !== doc.sha256 || hash(doc.source) !== doc.sha256 || documents.has(doc.path)) fail("document changed or is outside the source receipt");
bytes += Buffer.byteLength(doc.source);
if (Buffer.byteLength(doc.source) > 1024 * 1024 || bytes > 4 * 1024 * 1024) fail("documents exceed their bound");
documents.set(doc.path, doc.source);
}
const directories = new Set(input.directories);
for (const path of directories) if (!plain(path)) fail("invalid source directory");
for (const path of Object.keys(files)) if (!plain(path) || !SHA.test(files[path])) fail("invalid source file receipt");
for (const path of directories) if (!plain2(path)) fail("invalid source directory");
for (const path of Object.keys(files)) if (!plain2(path) || !SHA.test(files[path])) fail("invalid source file receipt");
const invalid = /* @__PURE__ */ new Set();
const parsed = /* @__PURE__ */ new Map();
const io = {
@@ -14466,7 +14777,7 @@ async function reviewAgentPresets(input) {
}
if (config.roots !== void 0 && !Array.isArray(config.roots)) fail("roots must be a literal list");
for (const root of config.roots ?? []) {
if (!root || Object.keys(root).some((k) => !["path", "trust"].includes(k)) || !plain(root.path) || !["system", "user"].includes(root.trust)) fail("configured roots must stay inside the admitted package");
if (!root || Object.keys(root).some((k) => !["path", "trust"].includes(k)) || !plain2(root.path) || !["system", "user"].includes(root.trust)) fail("configured roots must stay inside the admitted package");
if (!directories.has(root.path)) fail("configured preset root was not admitted");
roots.push({ path: resolve4(base, root.path), trust: root.trust });
}
+14 -1
View File
@@ -18,6 +18,7 @@ var ErrorCode = {
Denied: -32003,
Cancelled: -32800
};
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote"];
var METHODS = [
{ name: "host/initialize", direction: "core_to_host", request: true, params: "InitializeParams", tiers: ["plugin", "builtin"] },
{ name: "host/ping", direction: "core_to_host", request: true, params: "EmptyParams", tiers: ["plugin", "builtin"] },
@@ -42,6 +43,7 @@ var METHODS = [
{ name: "subagent/start", direction: "host_to_core", request: true, params: "SubagentStartParams", tiers: ["plugin", "builtin"] },
{ name: "subagent/result", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
{ name: "subagent/dispose", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
{ name: "dsh/broker", direction: "host_to_core", request: true, params: "DshBrokerParams", tiers: ["plugin", "builtin"] },
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
@@ -82,6 +84,16 @@ var SHAPES = {
required: { owner: { ref: "OwnerRef" } },
optional: { entry: { ref: "EntryRef" } }
},
DshBrokerParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, ticket: "string", service: "string", method: "string", input: "json" },
optional: {}
},
DshBrokerTicketWire: {
strict: true,
required: { service: "string", ticket: "string" },
optional: {}
},
EmptyParams: {
strict: true,
required: {},
@@ -245,7 +257,7 @@ var SHAPES = {
ToolCallParams: {
strict: false,
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", broker_tickets: { items: { ref: "DshBrokerTicketWire" } }, caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
},
UnregisterParams: {
strict: true,
@@ -415,6 +427,7 @@ function validateMessage(value, direction, tier, methods = METHODS) {
return value;
}
export {
DSH_SERVICES,
ErrorCode,
FrameDecoder,
FrameError,
+12 -1
View File
@@ -39,6 +39,7 @@ var METHODS = [
{ name: "subagent/start", direction: "host_to_core", request: true, params: "SubagentStartParams", tiers: ["plugin", "builtin"] },
{ name: "subagent/result", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
{ name: "subagent/dispose", direction: "host_to_core", request: true, params: "SubagentHandleParams", tiers: ["plugin", "builtin"] },
{ name: "dsh/broker", direction: "host_to_core", request: true, params: "DshBrokerParams", tiers: ["plugin", "builtin"] },
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
@@ -79,6 +80,16 @@ var SHAPES = {
required: { owner: { ref: "OwnerRef" } },
optional: { entry: { ref: "EntryRef" } }
},
DshBrokerParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, ticket: "string", service: "string", method: "string", input: "json" },
optional: {}
},
DshBrokerTicketWire: {
strict: true,
required: { service: "string", ticket: "string" },
optional: {}
},
EmptyParams: {
strict: true,
required: {},
@@ -242,7 +253,7 @@ var SHAPES = {
ToolCallParams: {
strict: false,
required: { handle: "uint", call_id: "string", input: "json", deadline_ms: "uint" },
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
optional: { workspace: "string", ticket: "string", checkpoint_ticket: "string", question_ticket: "string", subagent_ticket: "string", broker_tickets: { items: { ref: "DshBrokerTicketWire" } }, caller_tools: { items: "string" }, session_id: "string", agent_id: "string", origin_turn_id: "string" }
},
UnregisterParams: {
strict: true,
@@ -0,0 +1,24 @@
import type { Service } from '@deepseek-ai/cordis'
import type { DshService } from '../protocol.generated.ts'
import type { OwnerRecord, ToolInvocation } from '../root.ts'
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
readonly rpc: RpcPeer
ownerOf(ctx: unknown): OwnerRecord | undefined
liveInvocation(ctx: unknown): ToolInvocation | undefined
broker(ctx: unknown, service: DshService, method: string, input: unknown): Promise<unknown>
log(level: 'info' | 'warn' | 'error', message: string, owner?: OwnerRecord): void
}
export interface DshAdapter {
readonly service: string
define(host: AdapterHost): new (ctx: any) => Service
}
export const DSH_ADAPTERS: readonly DshAdapter[] = [sessionsAdapter, systemPromptAdapter, ...DSH_AGENT_ADAPTERS, remoteAdapter]
@@ -0,0 +1,154 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import { PERSONA_PREFIX_SECTION } from './dsh-message-compat.js'
import type { AdapterHost, DshAdapter } from './adapters.ts'
const SPAWN_PROVIDER = Object.freeze({
name: 'spawn',
inheritsParentContext: false,
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true }),
})
const PERSONA_ORDER = 0
function plain(value: unknown, allowed: string[], what: string): Record<string, unknown> {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`)
const result: Record<string, unknown> = Object.create(null)
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key)!
if (typeof key !== 'string' || !allowed.includes(key) || !('value' in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`)
if (descriptor.value !== undefined) result[key] = descriptor.value
}
return result
}
function defineSubagents(host: AdapterHost): new (ctx: any) => Service {
class SubagentsShim extends Service {
constructor(ctx: any) { super(ctx, 'subagents') }
getProvider(name: unknown) { return name === 'spawn' ? SPAWN_PROVIDER : undefined }
async startActivation(request: any) {
const owner = host.ownerOf(this.ctx)
const input = plain(request, ['provider', 'label', 'childId', 'request', 'signal', 'delivery'], 'activation')
if (input.provider !== 'spawn') throw new RpcError(ErrorCode.NotAvailable, 'only the spawn provider is available')
const spawn = plain(input.request, ['parent', 'prompt', 'agentOptions'], 'spawn request')
const options = spawn.agentOptions === undefined ? {} : plain(spawn.agentOptions, ['model'], 'agentOptions')
if (options.model !== undefined && typeof options.model !== 'string') throw new RpcError(ErrorCode.InvalidParams, 'agentOptions.model must be a string')
const delivery = input.delivery === undefined ? 'parent' : input.delivery
if (delivery !== 'caller' && delivery !== 'parent') throw new RpcError(ErrorCode.InvalidParams, 'delivery must be caller or parent')
const childId = input.childId
if (typeof childId !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, 'childId must be 1-128 letters, digits, or _.:-')
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'prompt must be a non-empty content array')
const prompt = spawn.prompt.map((block: unknown) => {
const item = plain(block, ['type', 'text'], 'prompt block')
if (item.type !== 'text' || typeof item.text !== 'string') throw new RpcError(ErrorCode.NotAvailable, 'only text prompt blocks are supported')
return item.text
}).join('')
if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, 'signal must be an AbortSignal')
const invocation = host.liveInvocation(this.ctx)
if (!invocation || invocation.agent === undefined || spawn.parent !== invocation.agent) {
throw new RpcError(ErrorCode.NotAvailable, 'startActivation requires the exact live calling agent as parent')
}
if (invocation.owner !== owner) throw new RpcError(ErrorCode.NotAvailable, 'subagents service owner is no longer current')
if (invocation.delegated || invocation.subagentTicket === undefined) throw new RpcError(ErrorCode.NotAvailable, 'child agents are only available to a root tool invocation')
const signal = input.signal === undefined ? invocation.signal : AbortSignal.any([invocation.signal, input.signal as AbortSignal])
signal.throwIfAborted()
let persona: string | undefined
const denied = new Set<string>()
let open = true
const sealed = () => { if (!open) throw new RpcError(ErrorCode.NotAvailable, 'the child creation window has closed') }
const child = Object.freeze({
id: childId,
ctx: Object.freeze({
systemPrompt: Object.freeze({
getSectionOrder(name: unknown) {
sealed()
if (name !== 'DEPLOYMENT_PERSONA_PREFIX') throw new RpcError(ErrorCode.NotAvailable, 'only the deployment persona slot is available')
return PERSONA_ORDER
},
section(section: unknown) {
sealed()
const value = plain(section, ['name', 'order', 'text'], 'system prompt section')
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== 'string') {
throw new RpcError(ErrorCode.NotAvailable, 'only the child deployment persona section is supported')
}
if (persona !== undefined) throw new RpcError(ErrorCode.InvalidParams, 'the child persona is already set')
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, 'child persona exceeds 32768 bytes')
persona = value.text
return () => undefined
},
}),
tools: Object.freeze({
restrict(filter: unknown) {
sealed()
const value = plain(filter, ['deny'], 'tool filter')
if (!Array.isArray(value.deny) || value.deny.some(name => typeof name !== 'string' || name === '')) throw new RpcError(ErrorCode.InvalidParams, 'deny must be a list of tool names')
for (const name of value.deny as string[]) denied.add(name)
return () => undefined
},
}),
}),
})
const listeners = (((this.ctx as any).root?.events?._hooks?.['agent/created']) ?? [])
.filter((hook: any) => !hook.global && hook.ctx !== undefined && host.ownerOf(hook.ctx) === owner)
.map((hook: any) => hook.callback)
try {
await Promise.all(listeners.map(async (callback: Function) => callback({ agent: child })))
} finally {
open = false
}
signal.throwIfAborted()
const call = invocation
const rpc = (method: string, params: Record<string, unknown>, abort: AbortSignal) =>
host.rpc.request<any>(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort)
const started = await rpc('subagent/start', {
prompt,
...(options.model === undefined ? {} : { model: options.model }),
...(persona === undefined ? {} : { persona }),
denied_tools: [...denied],
delivery,
}, signal)
if (started === null || typeof started !== 'object' || started.child_id !== childId || typeof started.message_id !== 'string') {
throw new RpcError(ErrorCode.Internal, 'invalid child activation receipt')
}
let result: Promise<unknown> | undefined
let disposed: Promise<void> | undefined
return Object.freeze({
childId,
messageId: started.message_id,
get result() {
result ??= rpc('subagent/result', {}, call.signal).then((value: any) => {
if (value === null || typeof value !== 'object' || typeof value.stop_reason !== 'string' || !Array.isArray(value.output)) {
throw new RpcError(ErrorCode.Internal, 'invalid child result receipt')
}
return Object.freeze({
stopReason: value.stop_reason,
output: Object.freeze(value.output.map((block: any) => Object.freeze({ type: 'text', text: String(block.text) }))),
...(value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}),
})
})
return result
},
dispose() {
disposed ??= rpc('subagent/dispose', {}, call.signal).then(() => undefined)
return disposed
},
})
}
}
Object.freeze(SubagentsShim.prototype)
return SubagentsShim
}
function defineAgents(): new (ctx: any) => Service {
class AgentsShim extends Service {
constructor(ctx: any) { super(ctx, 'agents') }
}
Object.freeze(AgentsShim.prototype)
return AgentsShim
}
export const DSH_AGENT_ADAPTERS: readonly DshAdapter[] = Object.freeze([
{ service: 'subagents', define: defineSubagents },
{ service: 'agents', define: defineAgents },
])
@@ -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,58 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { OwnerRecord, ToolInvocation } from '../root.ts'
import type { AdapterHost, DshAdapter } from './adapters.ts'
const CHILD_ONLY = "system prompt sections are only available on a child agent's agent/created context"
function checkpointCall(host: AdapterHost, ctx: unknown, session: unknown): ToolInvocation | undefined {
const invocation = host.liveInvocation(ctx)
const ticket = invocation?.ticket
return invocation !== undefined && typeof ticket === 'string' && ticket.length > 0
&& session !== undefined && session === invocation.session
? invocation
: undefined
}
export const sessionsAdapter: DshAdapter = {
service: 'sessions',
define(host) {
class SessionsService extends Service {
constructor(ctx: any) { super(ctx, 'sessions') }
async flush(session: unknown): Promise<boolean> {
const ctx = this.ctx
const call = checkpointCall(host, ctx, session)
if (call === undefined) throw new RpcError(ErrorCode.NotAvailable, 'sessions.flush requires this live tool invocation’s session')
const owner: OwnerRecord = call.owner
const live = () => checkpointCall(host, ctx, session) === call && !call.signal.aborted
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
const result = await host.rpc.request<unknown>('session/checkpoint', { owner: owner.ref, ticket: call.ticket }, call.signal)
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
if (typeof result !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid session checkpoint receipt')
return result
}
const result = call.pending.then(run)
call.pending = result.then(() => undefined, () => undefined)
return result
}
}
Object.freeze(SessionsService.prototype)
return SessionsService
},
}
export const systemPromptAdapter: DshAdapter = {
service: 'systemPrompt',
define() {
class SystemPromptService extends Service {
constructor(ctx: any) { super(ctx, 'systemPrompt') }
section() { throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY) }
getSectionOrder() { throw new RpcError(ErrorCode.NotAvailable, CHILD_ONLY) }
}
Object.freeze(SystemPromptService.prototype)
return SystemPromptService
},
}
@@ -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
},
}
@@ -25,6 +25,9 @@ export const ErrorCode = {
export type Direction = 'core_to_host' | 'host_to_core'
export const DSH_SERVICES = ['workspace', 'llm', 'network', 'settings', 'catalog', 'terminal', 'remote'] as const
export type DshService = typeof DSH_SERVICES[number]
/** Every method either side may send, and the trust tiers it is allowed on; nothing else is admitted. */
export const METHODS = [
{ name: 'host/initialize', direction: 'core_to_host', request: true, params: 'InitializeParams', tiers: ['plugin', 'builtin'] },
@@ -50,6 +53,7 @@ export const METHODS = [
{ name: 'subagent/start', direction: 'host_to_core', request: true, params: 'SubagentStartParams', tiers: ['plugin', 'builtin'] },
{ name: 'subagent/result', direction: 'host_to_core', request: true, params: 'SubagentHandleParams', tiers: ['plugin', 'builtin'] },
{ name: 'subagent/dispose', direction: 'host_to_core', request: true, params: 'SubagentHandleParams', tiers: ['plugin', 'builtin'] },
{ name: 'dsh/broker', direction: 'host_to_core', request: true, params: 'DshBrokerParams', tiers: ['plugin', 'builtin'] },
{ name: 'proc/launch', direction: 'host_to_core', request: true, params: 'ProcLaunchParams', tiers: ['builtin'] },
{ name: 'proc/read', direction: 'host_to_core', request: true, params: 'ProcSessionParams', tiers: ['builtin'] },
{ name: 'proc/write', direction: 'host_to_core', request: true, params: 'ProcWriteParams', tiers: ['builtin'] },
@@ -111,6 +115,16 @@ export const SHAPES: { readonly [name: string]: Shape } = {
required: { owner: { ref: 'OwnerRef' } },
optional: { entry: { ref: 'EntryRef' } },
},
DshBrokerParams: {
strict: true,
required: { owner: { ref: 'OwnerRef' }, ticket: 'string', service: 'string', method: 'string', input: 'json' },
optional: {},
},
DshBrokerTicketWire: {
strict: true,
required: { service: 'string', ticket: 'string' },
optional: {},
},
EmptyParams: {
strict: true,
required: {},
@@ -274,7 +288,7 @@ export const SHAPES: { readonly [name: string]: Shape } = {
ToolCallParams: {
strict: false,
required: { handle: 'uint', call_id: 'string', input: 'json', deadline_ms: 'uint' },
optional: { workspace: 'string', ticket: 'string', checkpoint_ticket: 'string', question_ticket: 'string', subagent_ticket: 'string', caller_tools: { items: 'string' }, session_id: 'string', agent_id: 'string', origin_turn_id: 'string' },
optional: { workspace: 'string', ticket: 'string', checkpoint_ticket: 'string', question_ticket: 'string', subagent_ticket: 'string', broker_tickets: { items: { ref: 'DshBrokerTicketWire' } }, caller_tools: { items: 'string' }, session_id: 'string', agent_id: 'string', origin_turn_id: 'string' },
},
UnregisterParams: {
strict: true,
@@ -355,6 +369,19 @@ export interface DeactivateResult {
leaked: string[]
}
export interface DshBrokerParams {
owner: OwnerRef
ticket: string
service: string
method: string
input: Json
}
export interface DshBrokerTicketWire {
service: string
ticket: string
}
export interface EmptyParams {}
export interface EntryRef {
@@ -592,6 +619,7 @@ export interface ToolCallParams {
checkpoint_ticket?: string
question_ticket?: string
subagent_ticket?: string
broker_tickets?: DshBrokerTicketWire[]
caller_tools?: string[]
session_id?: string
agent_id?: string
+55 -192
View File
@@ -1,4 +1,5 @@
import { ReviewedLoader, installCompositionLoader } from './dsh/composition.ts'
import { DSH_ADAPTERS, type AdapterHost, type DshAdapter } from './dsh/adapters.ts'
/**
* The Cordis root that plugin fibers run under.
*
@@ -31,7 +32,7 @@ import {
type ToolResultWire,
} from './protocol.ts'
import { RpcError, type RpcPeer } from './rpc.ts'
import { UserQuestionError, PERSONA_PREFIX_SECTION } from './dsh/dsh-message-compat.js'
import { UserQuestionError } from './dsh/dsh-message-compat.js'
import { explainImportError } from './dsh/resolve-hooks.ts'
import compatibility from './dsh/compatibility.json' with { type: 'json' }
import { assertSupportedJsonSchema, validateJsonSchemaValue, ToolOutputError } from './dsh/dsh-tools-compat.js'
@@ -68,6 +69,7 @@ export const REFUSED_SERVICES = new Set(compatibility.refused_services)
/** Services the root provides; `inject` of anything else fails activation. */
const PROVIDED_SERVICES = new Set(Object.values(compatibility.provided_services).flat())
const CORDIS_SERVICES = new Set(compatibility.provided_services.cordis)
const ACTIVATE_DEADLINE_MS = 5_000
const DISPOSE_DEADLINE_MS = 2_000
@@ -134,12 +136,13 @@ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promis
return Promise.race([promise, deadline]).finally(() => clearTimeout(timer))
}
interface ToolInvocation {
export interface ToolInvocation {
owner: OwnerRecord
session: object | undefined
agent: object | undefined
questionTicket: string | undefined
subagentTicket: string | undefined
brokerTickets: ReadonlyMap<string, string>
callerTools: readonly string[]
delegated: boolean
ticket: string | undefined
@@ -165,6 +168,7 @@ export class HostRoot {
private readonly rpc: RpcPeer,
/** The trust tier this process serves; it activates only owners of that tier. */
readonly tier: HostTier,
adapters: readonly DshAdapter[] = DSH_ADAPTERS,
) {
const root: any = new Context()
this.root = root
@@ -299,28 +303,6 @@ export class HostRoot {
set(key: string, value: Json) { return this.api().set(key, value) }
delete(key: string) { return this.api().delete(key) }
}
class SessionsShim extends Service {
constructor(ctx: any) { super(ctx, 'sessions') }
async flush(session: unknown): Promise<boolean> {
const invocation = host.toolInvocations.getStore()
const owner: OwnerRecord | undefined = (this.ctx as any)[OWNER]
const live = () => invocation && invocation.active && !invocation.signal.aborted
&& invocation.owner === owner && owner?.state === 'active' && !owner.disposing
&& invocation.ticket !== undefined && session === invocation.session && session !== undefined
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'sessions.flush requires this live tool invocation’s session')
const call = invocation!
const run = async () => {
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
const result = await host.rpc.request<unknown>('session/checkpoint', { owner: call.owner.ref, ticket: call.ticket }, call.signal)
if (!live()) throw new RpcError(ErrorCode.NotAvailable, 'session checkpoint invocation ended')
if (typeof result !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid session checkpoint receipt')
return result
}
const result = call.pending.then(run)
call.pending = result.then(() => undefined, () => undefined)
return result
}
}
class UserQuestionsShim extends Service {
constructor(ctx: any) { super(ctx, 'userQuestions') }
async ask(request: { agent?: unknown; questions: unknown; signal?: AbortSignal; wait?: unknown }) {
@@ -435,175 +417,52 @@ export class HostRoot {
return Promise.race([result, cancelled]).finally(() => signal.removeEventListener('abort', cancel))
}
}
const SPAWN_PROVIDER = Object.freeze({
name: 'spawn',
inheritsParentContext: false,
capabilities: Object.freeze({ persona: true, toolFilter: true, agentOptions: true }),
})
const PERSONA_ORDER = 0
class SubagentsShim extends Service {
constructor(ctx: any) { super(ctx, 'subagents') }
getProvider(name: unknown) { return name === 'spawn' ? SPAWN_PROVIDER : undefined }
async startActivation(request: any) {
const invocation = host.toolInvocations.getStore()
const owner: OwnerRecord | undefined = (this.ctx as any)[OWNER]
const plain = (value: unknown, allowed: string[], what: string): Record<string, unknown> => {
if (value === null || typeof value !== 'object' || Array.isArray(value)
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) throw new RpcError(ErrorCode.InvalidParams, `expected a plain ${what} object`)
const result: Record<string, unknown> = Object.create(null)
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key)!
if (typeof key !== 'string' || !allowed.includes(key) || !('value' in descriptor)) throw new RpcError(ErrorCode.NotAvailable, `unsupported ${what} property ${String(key)}`)
if (descriptor.value !== undefined) result[key] = descriptor.value
}
return result
}
const input = plain(request, ['provider', 'label', 'childId', 'request', 'signal', 'delivery'], 'activation')
if (input.provider !== 'spawn') throw new RpcError(ErrorCode.NotAvailable, 'only the spawn provider is available')
const spawn = plain(input.request, ['parent', 'prompt', 'agentOptions'], 'spawn request')
const options = spawn.agentOptions === undefined ? {} : plain(spawn.agentOptions, ['model'], 'agentOptions')
if (options.model !== undefined && typeof options.model !== 'string') throw new RpcError(ErrorCode.InvalidParams, 'agentOptions.model must be a string')
const delivery = input.delivery === undefined ? 'parent' : input.delivery
if (delivery !== 'caller' && delivery !== 'parent') throw new RpcError(ErrorCode.InvalidParams, 'delivery must be caller or parent')
const childId = input.childId
if (typeof childId !== 'string' || !/^[A-Za-z0-9_.:-]{1,128}$/.test(childId)) throw new RpcError(ErrorCode.InvalidParams, 'childId must be 1-128 letters, digits, or _.:-')
if (!Array.isArray(spawn.prompt) || spawn.prompt.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'prompt must be a non-empty content array')
const prompt = spawn.prompt.map((block: unknown) => {
const item = plain(block, ['type', 'text'], 'prompt block')
if (item.type !== 'text' || typeof item.text !== 'string') throw new RpcError(ErrorCode.NotAvailable, 'only text prompt blocks are supported')
return item.text
}).join('')
if (input.signal !== undefined && !(input.signal instanceof AbortSignal)) throw new RpcError(ErrorCode.InvalidParams, 'signal must be an AbortSignal')
if (!invocation || !invocation.active || invocation.signal.aborted || invocation.agent === undefined || spawn.parent !== invocation.agent) {
throw new RpcError(ErrorCode.NotAvailable, 'startActivation requires the exact live calling agent as parent')
}
if (invocation.owner !== owner || owner?.state !== 'active' || owner.disposing) throw new RpcError(ErrorCode.NotAvailable, 'subagents service owner is no longer current')
if (invocation.delegated || invocation.subagentTicket === undefined) throw new RpcError(ErrorCode.NotAvailable, 'child agents are only available to a root tool invocation')
const signal = input.signal === undefined ? invocation.signal : AbortSignal.any([invocation.signal, input.signal as AbortSignal])
signal.throwIfAborted()
let persona: string | undefined
const denied = new Set<string>()
let open = true
const sealed = () => { if (!open) throw new RpcError(ErrorCode.NotAvailable, 'the child creation window has closed') }
const child = Object.freeze({
id: childId,
ctx: Object.freeze({
systemPrompt: Object.freeze({
getSectionOrder(name: unknown) {
sealed()
if (name !== 'DEPLOYMENT_PERSONA_PREFIX') throw new RpcError(ErrorCode.NotAvailable, 'only the deployment persona slot is available')
return PERSONA_ORDER
},
section(section: unknown) {
sealed()
const value = plain(section, ['name', 'order', 'text'], 'system prompt section')
if (value.name !== PERSONA_PREFIX_SECTION || value.order !== PERSONA_ORDER || typeof value.text !== 'string') {
throw new RpcError(ErrorCode.NotAvailable, 'only the child deployment persona section is supported')
}
if (persona !== undefined) throw new RpcError(ErrorCode.InvalidParams, 'the child persona is already set')
if (Buffer.byteLength(value.text) > 32 * 1024) throw new RpcError(ErrorCode.InvalidParams, 'child persona exceeds 32768 bytes')
persona = value.text
return () => undefined
},
}),
tools: Object.freeze({
restrict(filter: unknown) {
sealed()
const value = plain(filter, ['deny'], 'tool filter')
if (!Array.isArray(value.deny) || value.deny.some(name => typeof name !== 'string' || name === '')) throw new RpcError(ErrorCode.InvalidParams, 'deny must be a list of tool names')
for (const name of value.deny as string[]) denied.add(name)
return () => undefined
},
}),
}),
})
const listeners = ((host.root as any).events?._hooks?.['agent/created'] ?? [])
.filter((hook: any) => !hook.global && hook.ctx?.[OWNER] === owner)
.map((hook: any) => hook.callback)
try {
await Promise.all(listeners.map(async (callback: Function) => callback({ agent: child })))
} finally {
open = false
}
signal.throwIfAborted()
const call = invocation
const rpc = (method: string, params: Record<string, unknown>, abort: AbortSignal) =>
host.rpc.request<any>(method, { owner: call.owner.ref, ticket: call.subagentTicket, child_id: childId, ...params }, abort)
const started = await rpc('subagent/start', {
prompt,
...(options.model === undefined ? {} : { model: options.model }),
...(persona === undefined ? {} : { persona }),
denied_tools: [...denied],
delivery,
}, signal)
if (started === null || typeof started !== 'object' || started.child_id !== childId || typeof started.message_id !== 'string') {
throw new RpcError(ErrorCode.Internal, 'invalid child activation receipt')
}
let result: Promise<unknown> | undefined
let disposed: Promise<void> | undefined
return Object.freeze({
childId,
messageId: started.message_id,
get result() {
result ??= rpc('subagent/result', {}, call.signal).then((value: any) => {
if (value === null || typeof value !== 'object' || typeof value.stop_reason !== 'string' || !Array.isArray(value.output)) {
throw new RpcError(ErrorCode.Internal, 'invalid child result receipt')
}
return Object.freeze({
stopReason: value.stop_reason,
output: Object.freeze(value.output.map((block: any) => Object.freeze({ type: 'text', text: String(block.text) }))),
...(value.omitted_blocks > 0 ? { diagnostic: `${value.omitted_blocks} non-text output block(s) are not supported` } : {}),
})
})
return result
},
dispose() {
disposed ??= rpc('subagent/dispose', {}, call.signal).then(() => undefined)
return disposed
},
})
}
}
class AgentsShim extends Service {
constructor(ctx: any) { super(ctx, 'agents') }
}
class SystemPromptShim extends Service {
constructor(ctx: any) { super(ctx, 'systemPrompt') }
section() { throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context") }
getSectionOrder() { throw new RpcError(ErrorCode.NotAvailable, "system prompt sections are only available on a child agent's agent/created context") }
}
Object.freeze(SubagentsShim.prototype)
Object.freeze(AgentsShim.prototype)
Object.freeze(SystemPromptShim.prototype)
Object.freeze(UserQuestionsShim.prototype)
Object.freeze(SessionsShim.prototype)
Object.freeze(StorageShim.prototype)
shimClasses.set('tools', ToolsShim)
shimClasses.set('commands', CommandsShim)
shimClasses.set('prompt', PromptShim)
shimClasses.set('storage', StorageShim)
shimClasses.set('sessions', SessionsShim)
shimClasses.set('userQuestions', UserQuestionsShim)
shimClasses.set('subagents', SubagentsShim)
shimClasses.set('agents', AgentsShim)
shimClasses.set('systemPrompt', SystemPromptShim)
shimClasses.set('skills', SkillsShim)
shimClasses.set('avatars', AvatarsShim)
shimClasses.set('mcp',McpShim)
shimClasses.set('shellHooks',ShellHooksShim)
root.plugin(ToolsShim)
root.plugin(CommandsShim)
root.plugin(PromptShim)
root.plugin(StorageShim)
root.plugin(SessionsShim)
root.plugin(UserQuestionsShim)
root.plugin(SubagentsShim)
root.plugin(AgentsShim)
root.plugin(SystemPromptShim)
root.plugin(SkillsShim)
root.plugin(AvatarsShim)
root.plugin(McpShim)
root.plugin(ShellHooksShim)
const adapterHost: AdapterHost = {
tier,
rpc,
ownerOf: (ctx) => (ctx as { [OWNER]?: OwnerRecord })[OWNER],
liveInvocation: (ctx) => {
const invocation = host.toolInvocations.getStore()
const owner: OwnerRecord | undefined = (ctx as { [OWNER]?: OwnerRecord })[OWNER]
return invocation && invocation.active && !invocation.signal.aborted && owner !== undefined
&& invocation.owner === owner && owner.state === 'active' && !owner.disposing
? invocation
: undefined
},
log: (level, message, owner) => host.log(level, message, owner),
broker: async (ctx, service, method, input) => {
const invocation = adapterHost.liveInvocation(ctx)
const ticket = invocation?.brokerTickets.get(service)
if (invocation === undefined || ticket === undefined) throw new RpcError(ErrorCode.NotAvailable, `the ${String(service).slice(0, 64)} broker is only available to a live tool invocation the core gave a ticket for it`)
return rpc.request<unknown>('dsh/broker', { owner: invocation.owner.ref, ticket, service, method, input }, invocation.signal)
},
}
const shims = new Map<string, new (ctx: any) => Service>([
['tools', ToolsShim],
['commands', CommandsShim],
['prompt', PromptShim],
['storage', StorageShim],
['userQuestions', UserQuestionsShim],
['skills', SkillsShim],
['avatars', AvatarsShim],
['mcp', McpShim],
['shellHooks', ShellHooksShim],
])
for (const adapter of adapters) {
if (shims.has(adapter.service)) throw new Error(`DSH adapter for \`${adapter.service}\` duplicates a service the host root already provides`)
shims.set(adapter.service, adapter.define(adapterHost))
}
const mismatched = [
...[...shims.keys()].filter((name) => !PROVIDED_SERVICES.has(name)).map((name) => `${name} is installed but not declared in compatibility.json`),
...[...PROVIDED_SERVICES].filter((name) => !CORDIS_SERVICES.has(name) && !shims.has(name)).map((name) => `${name} is declared in compatibility.json but has no installed service`),
]
if (mismatched.length > 0) throw new Error(`DSH provided services disagree with the installed services: ${mismatched.join('; ')}`)
for (const [name, shim] of shims) {
shimClasses.set(name, shim)
root.plugin(shim)
}
shimClasses.set('loader', ReviewedLoader)
installCompositionLoader(root)
}
@@ -714,7 +573,9 @@ export class HostRoot {
const plugin = pickPlugin(module)
const missing = Object.keys(Inject.resolve(plugin.inject)).filter((name) => !PROVIDED_SERVICES.has(name))
if (missing.length > 0) {
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(', ')}, not provided by the Codewhale extension host in this phase`)
const owned = missing.filter((name) => REFUSED_SERVICES.has(name))
throw new Error(`requires ${missing.map((name) => `\`${name}\``).join(', ')}, not provided by the Codewhale extension host in this phase`
+ (owned.length > 0 ? `; ${owned.map((name) => `\`${name}\``).join(', ')} belong${owned.length === 1 ? 's' : ''} to the Codewhale core and no reviewed bridge exposes ${owned.length === 1 ? 'it' : 'them'} yet` : ''))
}
const ownerCtx = this.root.extend({ [OWNER]: owner })
const fiber = ownerStorage.run(owner, () => ownerCtx.plugin(plugin, params.config ?? {}))
@@ -873,6 +734,7 @@ export class HostRoot {
agent,
questionTicket: identity?.question_ticket,
subagentTicket: identity?.subagent_ticket,
brokerTickets: new Map((identity?.broker_tickets ?? []).map(({ service, ticket }) => [service, ticket])),
callerTools: Object.freeze([...(identity?.caller_tools ?? [])]),
delegated: identity?.agent_id !== undefined,
ticket: identity?.checkpoint_ticket,
@@ -943,6 +805,7 @@ export class HostRoot {
interface InvocationIdentityWire {
question_ticket?: string
subagent_ticket?: string
broker_tickets?: { service: string, ticket: string }[]
caller_tools?: string[]
checkpoint_ticket?: string
session_id?: string
@@ -0,0 +1,71 @@
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))
const rpc = { request: async () => ({}), notify: () => {}, tier: 'plugin' }
let HostRoot
let Service
let DSH_ADAPTERS
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-adapters-'))
const outfile = join(dir, 'root.mjs')
await build({
entryPoints: [join(here, '../src/root.ts')],
outfile,
bundle: true,
platform: 'node',
format: 'esm',
packages: 'external',
absWorkingDir: join(here, '..'),
logLevel: 'silent',
})
const adapters = join(dir, 'adapters.mjs')
await build({
entryPoints: [join(here, '../src/dsh/adapters.ts')],
outfile: adapters,
bundle: true,
platform: 'node',
format: 'esm',
packages: 'external',
absWorkingDir: join(here, '..'),
logLevel: 'silent',
})
;({ HostRoot } = await import(pathToFileURL(outfile).href))
;({ DSH_ADAPTERS } = await import(pathToFileURL(adapters).href))
;({ Service } = await import('@deepseek-ai/cordis'))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
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', 'remote'])
assert.ok(new HostRoot(rpc, 'plugin'))
assert.ok(new HostRoot(rpc, 'builtin'))
})
test('an adapter for a service compatibility.json does not declare is refused at construction', () => {
assert.throws(() => new HostRoot(rpc, 'plugin', [...DSH_ADAPTERS, adapterFor('llm')]), /llm is installed but not declared in compatibility\.json/)
})
test('an adapter may not replace a service the root already provides', () => {
assert.throws(() => new HostRoot(rpc, 'plugin', [adapterFor('storage')]), /duplicates a service the host root already provides/)
assert.throws(() => new HostRoot(rpc, 'plugin', [...DSH_ADAPTERS, adapterFor('sessions')]), /duplicates a service/)
})
test('the adapter broker refuses locally, without sending, when no live invocation holds a ticket for the service', async () => {
const sent = []
const peer = { ...rpc, request: async (method, params) => { sent.push([method, params]); return {} } }
let host
const capture = { service: 'llm', define: (adapterHost) => { host = adapterHost; return adapterFor('llm').define() } }
assert.throws(() => new HostRoot(peer, 'plugin', [capture]), /not declared in compatibility\.json/)
for (const service of ['workspace', 'llm', 'vault']) {
await assert.rejects(host.broker({}, service, 'read', {}), (error) => error.code === -32001 && /broker is only available to a live tool invocation/.test(error.message))
}
assert.deepEqual(sent, [])
})
@@ -0,0 +1,72 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { activate, startHost } from './harness.mjs'
import { ErrorCode } from '../dist/protocol.mjs'
const PLUGIN = `
export const inject = ['tools', 'subagents', 'agents']
export function apply(ctx) {
ctx.on('agent/created', ({ agent }) => {
agent.ctx.systemPrompt.section({ name: 'deployment:persona-prefix', order: agent.ctx.systemPrompt.getSectionOrder('DEPLOYMENT_PERSONA_PREFIX'), text: 'Reviewer persona' })
agent.ctx.tools.restrict({ deny: ['exec_shell'] })
})
ctx.tools.register({ name: 'team_start', description: 'Start one child agent.', parameters: { type: 'object' },
async execute(input, exec) {
try {
const parent = input.forged ? { id: exec.agent?.id } : exec.agent
const handle = await ctx.subagents.startActivation({
provider: 'spawn', childId: input.childId ?? 'reviewer-1', delivery: 'caller',
request: { parent, prompt: [{ type: 'text', text: 'Review the diff' }] },
})
const result = await handle.result
await handle.dispose()
return { messageId: handle.messageId, stopReason: result.stopReason, text: result.output.map(block => block.text).join('') }
} catch (error) {
return { refused: error.code ?? String(error) }
}
},
})
}
`
test('the agents adapters serve subagents and agents through the reviewed adapter table', async (t) => {
const host = await startHost({
subagent: (params, _id, method) => method === 'subagent/start'
? { child_id: params.child_id, message_id: 'message-1' }
: method === 'subagent/result'
? { stop_reason: 'end_turn', output: [{ type: 'text', text: 'looks good' }], omitted_blocks: 0 }
: {},
})
t.after(() => host.stop())
const dir = mkdtempSync(join(tmpdir(), 'cw-agents-'))
t.after(() => rmSync(dir, { recursive: true, force: true }))
const entry = join(dir, 'index.mjs')
writeFileSync(entry, PLUGIN)
const { ref, result } = await activate(host, 'agent-team', entry)
assert.deepEqual(result, { status: 'ok', tools: ['team_start'], commands: [] })
const handle = host.registry.find(row => row.op === 'register' && row.spec.name === 'team_start').handle
const call = (input, identity) => host.call('tool/call', { handle, call_id: 'team', input, deadline_ms: 5000, ...identity })
const root = { session_id: 'session-root', workspace: '/workspace/team', subagent_ticket: 'subagent-ticket-1' }
assert.deepEqual((await call({}, root)).structured, { messageId: 'message-1', stopReason: 'end_turn', text: 'looks good' })
assert.deepEqual(host.subagents.map(row => row.method), ['subagent/start', 'subagent/result', 'subagent/dispose'])
const [start] = host.subagents
assert.deepEqual(start.owner, ref)
assert.equal(start.ticket, 'subagent-ticket-1')
assert.equal(start.child_id, 'reviewer-1')
assert.equal(start.prompt, 'Review the diff')
assert.equal(start.persona, 'Reviewer persona')
assert.deepEqual(start.denied_tools, ['exec_shell'])
assert.equal(start.delivery, 'caller')
assert.ok(host.subagents.every(row => row.ticket === 'subagent-ticket-1' && row.child_id === 'reviewer-1'))
const before = host.subagents.length
assert.deepEqual((await call({}, { ...root, agent_id: 'agent-child' })).structured, { refused: ErrorCode.NotAvailable }, 'a delegated caller cannot nest child agents')
assert.deepEqual((await call({}, { session_id: 'session-root', workspace: '/workspace/team' })).structured, { refused: ErrorCode.NotAvailable }, 'no subagent ticket, no child')
assert.deepEqual((await call({ forged: true }, root)).structured, { refused: ErrorCode.NotAvailable }, 'a forged parent is not the live caller')
assert.deepEqual((await call({ childId: 'bad id' }, root)).structured, { refused: ErrorCode.InvalidParams })
assert.equal(host.subagents.length, before, 'refused activations never reach the core')
})
+10 -5
View File
@@ -51,7 +51,7 @@ export const LIMITS = { max_frame: 32 * 1024 * 1024, max_inflight: 256, dispose_
* cancelled is not answered. Requests are recorded in `coreCalls`, the host's
* `$/cancel` ids in `cancels`.
*/
export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', coreCall, checkpoint, userQuestions, compiledHost, cwd } = {}) {
export async function startHost({ admit, env, ownGroup = false, tier = 'plugin', coreCall, checkpoint, userQuestions, subagent, compiledHost, cwd } = {}) {
const started = performance.now()
// `ownGroup` spawns the host as a process-group leader and tells it so, as
// the Rust core does on Unix.
@@ -73,6 +73,7 @@ export async function startHost({ admit, env, ownGroup = false, tier = 'plugin',
coreCalls: [],
checkpoints: [],
questions: [],
subagents: [],
cancels: [],
registry: [],
hello: null,
@@ -146,11 +147,15 @@ export async function startHost({ admit, env, ownGroup = false, tier = 'plugin',
break
case 'session/checkpoint':
case 'user-questions/ask':
case 'subagent/start':
case 'subagent/result':
case 'subagent/dispose':
case 'core/call': {
const handler = message.method === 'session/checkpoint' ? checkpoint : message.method === 'user-questions/ask' ? userQuestions : coreCall
const records = message.method === 'session/checkpoint' ? host.checkpoints : message.method === 'user-questions/ask' ? host.questions : host.coreCalls
records.push({ id: message.id, ...message.params })
const answer = handler ? handler(message.params, message.id) : { error: { code: -32002, message: 'no handler for ' + message.method } }
const childRequest = message.method.startsWith('subagent/')
const handler = message.method === 'session/checkpoint' ? checkpoint : message.method === 'user-questions/ask' ? userQuestions : childRequest ? subagent : coreCall
const records = message.method === 'session/checkpoint' ? host.checkpoints : message.method === 'user-questions/ask' ? host.questions : childRequest ? host.subagents : host.coreCalls
records.push({ id: message.id, ...(childRequest ? { method: message.method } : {}), ...message.params })
const answer = handler ? handler(message.params, message.id, message.method) : { error: { code: -32002, message: 'no handler for ' + message.method } }
const id = message.id
Promise.resolve(answer).then((value) => {
if (host.cancels.includes(id) || child.exitCode !== null || stopping) return
@@ -174,6 +174,7 @@ test('injecting a service the host does not provide fails with its name', async
const { result } = await activate(host, 'needs-secrets', plugin.entry)
assert.equal(result.status, 'failed')
assert.match(result.diagnostic, /requires `secrets`/)
assert.doesNotMatch(result.diagnostic, /belongs? to the Codewhale core/)
const provided = tempPlugin("export const inject = ['shellHooks', 'sessions', 'userQuestions']; export function apply(ctx) { if (!ctx.shellHooks || typeof ctx.sessions.flush !== 'function' || typeof ctx.userQuestions.ask !== 'function') throw new Error('missing provided service') }\n")
t.after(() => provided.cleanup())
const available = await activate(host, 'needs-shell-hooks', provided.entry)
@@ -187,6 +188,7 @@ export function apply() { createUserMessage({ content: [], source: { kind: 'fixt
const refused = (await activate(host, `pure-needs-${service}`, pure.entry)).result
assert.equal(refused.status, 'failed')
assert.ok(refused.diagnostic.includes('requires `' + service + '`'), refused.diagnostic)
assert.match(refused.diagnostic, /`llm` belongs to the Codewhale core and no reviewed bridge exposes it yet/)
}
})
@@ -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 sessionsAdapter
let systemPromptAdapter
let Context
let ErrorCode
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-session-prompt-'))
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)
}
;({ sessionsAdapter, systemPromptAdapter } = await bundle('../src/dsh/session-prompt.ts', 'session-prompt.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))
function flushHarness(invocation, requests) {
const session = {}
const host = {
tier: 'plugin',
rpc: { request: async (method, params) => { requests.push({ method, params }); return true }, notify() {} },
ownerOf: () => invocation?.owner,
liveInvocation: () => invocation,
log() {},
}
const ctx = new Context()
ctx.plugin(sessionsAdapter.define(host))
return { ctx, session }
}
const live = (extra = {}) => {
const session = {}
return { owner: { ref: { plugin_id: 'p' } }, session, ticket: 'ticket', signal: new AbortController().signal, pending: Promise.resolve(), ...extra }
}
test('sessions.flush refuses a live invocation that carries no checkpoint ticket', async () => {
const requests = []
const invocation = live({ ticket: undefined })
const { ctx } = flushHarness(invocation, requests)
await settle()
await assert.rejects(ctx.sessions.flush(invocation.session), (error) => error.code === ErrorCode.NotAvailable)
assert.equal(requests.length, 0)
})
test('sessions.flush refuses a session other than the invocation session and sends nothing', async () => {
const requests = []
const invocation = live()
const { ctx } = flushHarness(invocation, requests)
await settle()
await assert.rejects(ctx.sessions.flush({}), (error) => error.code === ErrorCode.NotAvailable)
await assert.rejects(ctx.sessions.flush(undefined), (error) => error.code === ErrorCode.NotAvailable)
assert.equal(requests.length, 0)
})
test('sessions.flush sends the exact owner and ticket, and a stale invocation is refused before the receipt returns', async () => {
const requests = []
const invocation = live()
let current = invocation
const host = { tier: 'plugin', rpc: { request: async (method, params) => { requests.push({ method, params }); current = undefined; return true }, notify() {} }, ownerOf: () => invocation.owner, liveInvocation: () => current, log() {} }
const ctx = new Context()
ctx.plugin(sessionsAdapter.define(host))
await settle()
await assert.rejects(ctx.sessions.flush(invocation.session), (error) => error.code === ErrorCode.NotAvailable)
assert.deepEqual(requests, [{ method: 'session/checkpoint', params: { owner: invocation.owner.ref, ticket: 'ticket' } }])
})
test('systemPrompt stays child-only: the root context refuses sections', async () => {
const ctx = new Context()
ctx.plugin(systemPromptAdapter.define({}))
await settle()
assert.throws(() => ctx.systemPrompt.section({ name: 'x', text: 'y' }), (error) => error.code === ErrorCode.NotAvailable)
assert.throws(() => ctx.systemPrompt.getSectionOrder('X'), (error) => error.code === ErrorCode.NotAvailable)
})
@@ -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, [])
})
+155 -1
View File
@@ -220,9 +220,22 @@ impl CodewhaleClient {
Some(level) if thinking_capable && supports_adaptive => {
body["thinking"] = json!({ "type": "adaptive" });
if !is_minimax {
let row_lists_xhigh = self
.route_facts
.as_ref()
.filter(|facts| facts.wire_model_id == request.model)
.is_some_and(|facts| {
facts.reasoning_options.iter().any(|option| {
option.get("type").and_then(Value::as_str) == Some("effort")
&& option.get("values").and_then(Value::as_array).is_some_and(
|values| values.iter().any(|v| v.as_str() == Some("xhigh")),
)
})
});
let mapped = match level {
"low" | "minimal" => "low",
"medium" | "mid" => "medium",
"xhigh" if row_lists_xhigh => "xhigh",
"max" | "xhigh" | "highest" => "max",
_ => "high",
};
@@ -797,7 +810,18 @@ fn anthropic_tool_choice(tool_choice: &Value, model: &str) -> Value {
Some("none") => json!({ "type": "none" }),
Some("any" | "required") => json!({ "type": "any" }),
Some(name) => json!({ "type": "tool", "name": to_api_tool_name(name) }),
None => tool_choice.clone(),
None => match forced_tool_name(tool_choice) {
Some(name) => {
let mut choice = tool_choice.clone();
choice["type"] = json!("tool");
choice["name"] = json!(to_api_tool_name(name));
if let Some(object) = choice.as_object_mut() {
object.remove("function");
}
choice
}
None => tool_choice.clone(),
},
}
}
@@ -1489,6 +1513,94 @@ mod tests {
CodewhaleClient::new(&config).expect("Model Studio Messages client constructs")
}
#[test]
fn dotted_tool_names_stay_inside_the_anthropic_pattern_and_decode_back() {
let client = test_client();
let mut request = request_with("claude-sonnet-4-6", None, None, None);
let tool = |name: &str| Tool {
tool_type: None,
name: name.to_string(),
description: "d".to_string(),
input_schema: json!({"type": "object", "additionalProperties": false}),
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
};
request.tools = Some(vec![tool("read_file"), tool("web.run")]);
request.tool_choice = Some(json!({"type": "tool", "name": "web.run"}));
request.messages.push(Message {
role: Role::Assistant,
content: vec![ContentBlock::ToolUse {
execution_id: None,
id: "toolu_w".to_string(),
name: "web.run".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
}],
});
let body = client.build_anthropic_body(&request, true);
let valid = |name: &str| {
(1..=128).contains(&name.len())
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
};
let mut sent = Vec::new();
for tool in body["tools"].as_array().expect("tools") {
sent.push(tool["name"].as_str().expect("tool name").to_string());
}
sent.push(
body.pointer("/tool_choice/name")
.and_then(Value::as_str)
.expect("forced tool name")
.to_string(),
);
for message in body["messages"].as_array().expect("messages") {
for block in message["content"].as_array().into_iter().flatten() {
if block["type"] == "tool_use" {
sent.push(block["name"].as_str().expect("tool_use name").to_string());
}
}
}
assert!(sent.iter().all(|name| valid(name)), "{sent:?}");
assert!(sent.iter().any(|name| name != "read_file"), "{sent:?}");
let wire = to_api_tool_name("web.run");
let message = decode_anthropic_message(json!({
"id": "m", "type": "message", "role": "assistant",
"model": "claude-sonnet-4-6", "stop_reason": "tool_use",
"content": [{"type": "tool_use", "id": "toolu_w", "name": wire, "input": {}}],
"usage": {"input_tokens": 1, "output_tokens": 1},
}))
.unwrap();
let ContentBlock::ToolUse { name, .. } = &message.content[0] else {
panic!("expected tool_use")
};
assert_eq!(name, "web.run");
let event = convert_anthropic_sse_data(
&json!({
"type": "content_block_start", "index": 0,
"content_block": {"type": "tool_use", "id": "toolu_w", "name": wire, "input": {}},
})
.to_string(),
)
.unwrap()
.unwrap();
let StreamEvent::ContentBlockStart {
content_block: codewhale_models::ContentBlockStart::ToolUse { name, .. },
..
} = event
else {
panic!("expected tool_use start")
};
assert_eq!(name, "web.run");
}
#[test]
fn body_keeps_native_cache_control_on_system_and_tools() {
let client = test_client();
@@ -1559,6 +1671,36 @@ mod tests {
Some("max")
);
let offering = |model: &str| {
client
.catalog_facts_for_model(model)
.unwrap_or_else(|| panic!("bundled catalog carries {model}"))
};
let mut xhigh_client = test_client();
xhigh_client.route_facts = Some(offering("claude-opus-4-8"));
let body = xhigh_client.build_anthropic_body(
&request_with("claude-opus-4-8", Some("xhigh"), None, None),
true,
);
assert_eq!(
body.pointer("/output_config/effort")
.and_then(Value::as_str),
Some("xhigh"),
"a row that lists xhigh is sent xhigh: {body}"
);
let mut no_xhigh_client = test_client();
no_xhigh_client.route_facts = Some(offering("claude-sonnet-4-6"));
let body = no_xhigh_client.build_anthropic_body(
&request_with("claude-sonnet-4-6", Some("xhigh"), None, None),
true,
);
assert_eq!(
body.pointer("/output_config/effort")
.and_then(Value::as_str),
Some("max"),
"a row without xhigh keeps the max mapping: {body}"
);
let body = client.build_anthropic_body(
&request_with("claude-sonnet-4-6", Some("off"), None, None),
true,
@@ -1800,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(
+54
View File
@@ -41,6 +41,60 @@ pub trait CommandCatalog: Send + Sync {
fn answers_to(&self, name: &str) -> bool;
fn builtins(&self) -> Vec<BuiltinCommand>;
fn user_commands(&self, workspace: &Path) -> Vec<UserCommand>;
/// Whether this host executes `name` remotely at all. Checked before any
/// thread state is assembled, so a name outside the remote surface is
/// refused exactly like a name no host knows. `false` is the default: a
/// host opts in one command at a time, and widening this must widen
/// [`Self::execute_builtin`] too.
fn serves_builtin(&self, name: &str) -> bool {
let _ = name;
false
}
/// Execute one built-in command against a host snapshot. `None` means
/// this host does not implement remote execution, or does not answer to
/// the name; `Some` carries the command's own answer.
fn execute_builtin(
&self,
name: &str,
args: Option<&str>,
host: &BuiltinCommandHostSnapshot,
) -> Option<BuiltinCommandExecution> {
let _ = (name, args, host);
None
}
}
/// The thread state a remote built-in execution reads.
///
/// Engine-side so the app-server can assemble it from thread state and the
/// terminal host can interpret it without either depending on the other. The
/// projections are the command contract's own data-only types: the host maps
/// them into the facet envelope, and the capability mask each portable
/// handler declares decides which commands this snapshot can honestly serve.
pub struct BuiltinCommandHostSnapshot {
pub mode: codewhale_command_contract::types::CommandMode,
/// The host's own label for `mode` ("WORK", "PLAN"), the same string the
/// terminal footer shows.
pub mode_label: String,
pub locale: codewhale_localization::Locale,
pub model: String,
pub system_prompt: codewhale_command_contract::facets::DebugSystemPrompt,
/// Published usage telemetry and the host's cost breakdown for the
/// thread — the complete data source the portable `/tokens` and `/cost`
/// renderers consume.
pub tokens: codewhale_command_contract::facets::DebugTokenProjection,
}
/// One remote built-in execution's outcome.
pub struct BuiltinCommandExecution {
pub name: String,
pub text: String,
pub is_error: bool,
/// `false` when the command also asked the terminal host for an action —
/// the text is still what the command answered, but no API client can
/// perform that action (a balance fetch, a snapshot restore) on the
/// host's behalf.
pub text_only: bool,
}
static CATALOG: OnceLock<Arc<dyn CommandCatalog>> = OnceLock::new();
+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())
}
}
+328 -11
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;
@@ -75,9 +77,10 @@ use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
use super::ManagerShared;
use super::dsh_broker::{self, Arm, BrokerCall, BrokerRefusal};
use super::protocol::{
ContentBlockWire, CoreCallParams, RpcErrorWire, SessionCheckpointParams, ToolResultWire,
UserQuestionParams, error_code,
ContentBlockWire, CoreCallParams, DshBrokerParams, DshBrokerTicketWire, DshService,
RpcErrorWire, SessionCheckpointParams, ToolResultWire, UserQuestionParams, error_code,
};
use super::supervisor::HostRequestContext;
use super::ticket::{Grant, Presented, Ticket, TicketKind};
@@ -100,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;
@@ -263,6 +267,8 @@ pub struct Invocation {
calls: CodemodeInvoker,
gate: NestedCallGate,
root_caller: Option<SessionCheckpointCaller>,
broker: Option<BrokerScope>,
nested_calls: AtomicU32,
tickets: Vec<Ticket>,
caller_tools: Vec<String>,
activations: Mutex<
@@ -280,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 {
@@ -313,6 +411,7 @@ impl CoreCalls {
gate,
None,
String::new(),
&dsh_broker::test_grants().unwrap_or_default(),
)
}
#[allow(clippy::too_many_arguments)]
@@ -327,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 {
@@ -405,6 +505,45 @@ impl CoreCalls {
uses: MAX_CALLS_PER_INVOCATION,
})
});
let broker_session = context
.execution
.session_objects
.as_ref()
.map(|session| session.session_id.clone())
.filter(|id| !id.is_empty());
let broker_tickets: Vec<(DshService, Ticket)> = broker_session
.iter()
.flat_map(|session| {
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())
@@ -425,10 +564,13 @@ impl CoreCalls {
calls,
gate: gate.clone(),
root_caller,
broker,
nested_calls: AtomicU32::new(0),
tickets: std::iter::once(ticket.clone())
.chain(checkpoint_ticket.iter().cloned())
.chain(question_ticket.iter().cloned())
.chain(subagent_ticket.iter().cloned())
.chain(broker_tickets.iter().map(|(_, ticket)| ticket.clone()))
.collect(),
caller_tools,
activations: Mutex::new(HashMap::new()),
@@ -457,7 +599,14 @@ impl CoreCalls {
Arc::clone(&invocation),
);
}
for (_, broker_ticket) in &broker_tickets {
self.invocations
.lock()
.expect("invocations lock")
.insert(broker_ticket.expose().to_string(), Arc::clone(&invocation));
}
Some(InvocationGuard {
broker_tickets,
question_ticket,
subagent_ticket,
checkpoint_ticket,
@@ -553,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,
@@ -573,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.
@@ -1063,6 +1230,137 @@ impl CoreCalls {
result
}
pub async fn serve_broker(
&self,
shared: &ManagerShared,
tier: HostTier,
host_generation: u64,
params: DshBrokerParams,
cx: HostRequestContext,
) -> Result<Value, RpcErrorWire> {
self.serve_broker_in(dsh_broker::TABLE, shared, tier, host_generation, params, cx)
.await
}
pub(super) async fn serve_broker_in(
&self,
table: &[(DshService, Arm)],
shared: &ManagerShared,
tier: HostTier,
host_generation: u64,
params: DshBrokerParams,
cx: HostRequestContext,
) -> Result<Value, RpcErrorWire> {
let refused = |message: String| {
dsh_broker::coded(
RpcErrorWire {
code: error_code::REFUSED,
message,
data: None,
},
dsh_broker::TICKET_REFUSED,
)
};
let cancelled = |message: &str| {
dsh_broker::coded(
RpcErrorWire {
code: error_code::CANCELLED,
message: message.to_string(),
data: None,
},
dsh_broker::CANCELLED,
)
};
let service = DshService::parse(&params.service)
.ok_or_else(|| BrokerRefusal::UnknownService(params.service.clone()).wire())?;
let invocation = self
.redeem_invocation_or_limit(
shared,
tier,
host_generation,
&params.owner,
&params.ticket,
dsh_broker::ticket_method(service),
&cx,
)
.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();
let _withdraw_guard = withdraw.clone().drop_guard();
let call = BrokerCall {
owner: params.owner.clone(),
session_id,
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
.registry
.lock()
.expect("registry lock")
.tier_of(&params.owner)
!= Some(tier)
{
return Err(refused("dsh/broker owner is no longer current".into()));
}
shared
.check_selection(
invocation
.plugins
.as_ref()
.and_then(|plugins| plugins.caller_selection()),
invocation.plugins.as_deref(),
&params.owner.plugin_id,
&invocation.content_hash,
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)
}
/// Redeem `params` presented by `tier`'s host process `host_generation`
/// and run the call. An invalid presentation is a refusal, and a burst of
/// them a violation `cx` reports.
@@ -1088,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();
@@ -1191,6 +1495,7 @@ pub struct InvocationGuard {
checkpoint_ticket: Option<Ticket>,
question_ticket: Option<Ticket>,
subagent_ticket: Option<Ticket>,
broker_tickets: Vec<(DshService, Ticket)>,
invocation: Arc<Invocation>,
}
@@ -1212,6 +1517,18 @@ impl InvocationGuard {
self.subagent_ticket.as_ref().map(Ticket::expose)
}
pub fn broker_tickets(&self) -> Option<Vec<DshBrokerTicketWire>> {
(!self.broker_tickets.is_empty()).then(|| {
self.broker_tickets
.iter()
.map(|(service, ticket)| DshBrokerTicketWire {
service: service.name().to_string(),
ticket: ticket.expose().to_string(),
})
.collect()
})
}
pub fn caller_tools(&self) -> Option<Vec<String>> {
self.subagent_ticket
.as_ref()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,254 @@
use std::future::Future;
use std::pin::Pin;
use codewhale_config::OwnerRef;
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,
pub session_id: String,
pub service: DshService,
pub method: String,
pub input: Value,
pub context: BrokerContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BrokerRefusal {
UnknownService(String),
UnknownMethod {
service: DshService,
method: String,
},
InvalidInput {
service: DshService,
message: String,
},
NotAvailable(DshService),
NotGranted(DshService),
Denied {
service: DshService,
message: String,
},
Refused {
service: DshService,
message: String,
},
}
impl BrokerRefusal {
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::UnknownService(_) => "unknown_service",
Self::UnknownMethod { .. } => "unknown_method",
Self::InvalidInput { .. } => "invalid_input",
Self::NotAvailable(_) => "not_available",
Self::NotGranted(_) => "not_granted",
Self::Denied { .. } => "denied",
Self::Refused { .. } => "refused",
}
}
#[must_use]
pub fn wire(self) -> RpcErrorWire {
let code = self.code();
let (rpc, message, service) = match self {
Self::UnknownService(name) => (
error_code::INVALID_PARAMS,
"dsh/broker names a service the core does not broker".to_string(),
Value::String(name.chars().take(64).collect()),
),
Self::UnknownMethod { service, method } => (
error_code::INVALID_PARAMS,
format!(
"the {} broker has no method `{}`",
service.name(),
method.chars().take(64).collect::<String>()
),
Value::String(service.name().into()),
),
Self::InvalidInput { service, message } => (
error_code::INVALID_PARAMS,
format!("invalid {} broker input: {message}", service.name()),
Value::String(service.name().into()),
),
Self::NotAvailable(service) => (
error_code::NOT_AVAILABLE,
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,
message,
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]
pub fn coded(mut error: RpcErrorWire, code: &str) -> RpcErrorWire {
error.data = Some(json!({ "dsh_refusal": code }));
error
}
pub type BrokerFuture = Pin<Box<dyn Future<Output = Result<Value, BrokerRefusal>> + Send>>;
pub type Arm = fn(BrokerCall) -> BrokerFuture;
pub const TABLE: &[(DshService, Arm)] = &[
(DshService::Workspace, dsh_workspace::serve as Arm),
(DshService::Llm, dsh_llm::serve as Arm),
(DshService::Network, dsh_network::serve as Arm),
(DshService::Settings, dsh_settings::serve as Arm),
(DshService::Catalog, dsh_catalog::serve as Arm),
(DshService::Terminal, dsh_terminal::serve as 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
.iter()
.find(|(candidate, _)| *candidate == service)
.map(|(_, arm)| *arm)
}
#[must_use]
pub fn ticket_method(service: DshService) -> &'static str {
match service {
DshService::Workspace => "dsh/broker:workspace",
DshService::Llm => "dsh/broker:llm",
DshService::Network => "dsh/broker:network",
DshService::Settings => "dsh/broker:settings",
DshService::Catalog => "dsh/broker:catalog",
DshService::Terminal => "dsh/broker:terminal",
DshService::Remote => "dsh/broker:remote",
}
}
pub trait BrokerMethods: DeserializeOwned {
const NAMES: &'static [&'static str];
}
pub fn decode<M: BrokerMethods>(call: &BrokerCall) -> Result<M, BrokerRefusal> {
if !M::NAMES.contains(&call.method.as_str()) {
return Err(BrokerRefusal::UnknownMethod {
service: call.service,
method: call.method.clone(),
});
}
serde_json::from_value(json!({ "method": call.method, "input": call.input })).map_err(|error| {
BrokerRefusal::InvalidInput {
service: call.service,
message: error.to_string(),
}
})
}
pub fn not_available(service: DshService) -> BrokerFuture {
Box::pin(std::future::ready(Err(BrokerRefusal::NotAvailable(
service,
))))
}
@@ -0,0 +1,6 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Catalog)
}
@@ -0,0 +1,879 @@
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) {
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:?}"
);
}
#[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:?}");
}
@@ -0,0 +1,6 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Llm)
}
@@ -0,0 +1,843 @@
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
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);
}
}
@@ -0,0 +1,569 @@
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 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");
}
}
@@ -0,0 +1,6 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Settings)
}
@@ -0,0 +1,524 @@
use std::path::Path;
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" })
);
}
}
@@ -0,0 +1,6 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Workspace)
}
+139 -13
View File
@@ -122,6 +122,15 @@
pub mod command;
pub mod composition_review;
pub mod core_call;
pub mod dsh_broker;
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;
pub mod execution;
pub use execution::StockOperation;
pub mod avatars;
@@ -143,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)]
@@ -531,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:
@@ -546,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 →
@@ -1011,6 +1024,23 @@ impl HostEvents for Events {
.serve_checkpoint(&shared, self.tier, self.generation, params, cx)
.await
}
protocol::HostRequest::DshBroker(params) => {
let Some(shared) = self.shared.upgrade() else {
return Err(refuse("extension host manager is gone"));
};
if shared
.tier_runtime(self.tier)
.host_generation
.load(Ordering::SeqCst)
!= self.generation
{
return Err(refuse("stale host generation"));
}
shared
.core_calls
.serve_broker(&shared, self.tier, self.generation, params, cx)
.await
}
protocol::HostRequest::CoreCall(params) => {
let Some(shared) = self.shared.upgrade() else {
return Err(refuse("extension host manager is gone"));
@@ -1349,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);
@@ -2198,6 +2236,7 @@ impl ExtensionHostManager {
authority: Some(want.authority),
content_hash,
entries: want.entries,
host_services: want.host_services,
},
)
.await;
@@ -2225,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);
@@ -2371,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");
@@ -2418,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 {
@@ -2745,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.
@@ -3205,11 +3262,41 @@ 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 =
codewhale_plugins::registry::reviewed_record(&source.authority).ok();
if record.as_ref().is_some_and(|record| {
record.approved_by.as_ref().is_some_and(|approver| {
approver.cohort()
!= codewhale_plugins::host_services::PluginCohort::Reviewed
})
}) {
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
.map(|record| record.host_services)
.unwrap_or_default()
.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
@@ -3514,6 +3601,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>,
@@ -176,6 +176,7 @@ pub const METHODS: &[MethodSpec] = &[
row(Direction::HostToCore, "subagent/start", true),
row(Direction::HostToCore, "subagent/result", true),
row(Direction::HostToCore, "subagent/dispose", true),
row(Direction::HostToCore, "dsh/broker", true),
MethodSpec {
tiers: &[HostTier::Builtin],
..row(Direction::HostToCore, "proc/launch", true)
@@ -530,6 +531,66 @@ pub struct CoreCallParams {
pub input: Value,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DshService {
Workspace,
Llm,
Network,
Settings,
Catalog,
Terminal,
Remote,
}
impl DshService {
pub const ALL: [Self; 7] = [
Self::Workspace,
Self::Llm,
Self::Network,
Self::Settings,
Self::Catalog,
Self::Terminal,
Self::Remote,
];
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Workspace => "workspace",
Self::Llm => "llm",
Self::Network => "network",
Self::Settings => "settings",
Self::Catalog => "catalog",
Self::Terminal => "terminal",
Self::Remote => "remote",
}
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|service| service.name() == name)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct DshBrokerParams {
pub owner: OwnerRef,
pub ticket: String,
pub service: String,
pub method: String,
pub input: Value,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct DshBrokerTicketWire {
pub service: String,
pub ticket: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(test, derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
@@ -764,6 +825,7 @@ pub enum HostRequest {
SubagentStart(SubagentStartParams),
SubagentResult(SubagentHandleParams),
SubagentDispose(SubagentHandleParams),
DshBroker(DshBrokerParams),
ExecutionRedeem(ExecutionRedeemParams),
ProcClose(ProcSessionParams),
NetStart(ProcLaunchParams),
@@ -788,6 +850,7 @@ impl HostRequest {
Self::UserQuestion(params) => &params.owner.plugin_id,
Self::SubagentStart(params) => &params.owner.plugin_id,
Self::SubagentResult(params) | Self::SubagentDispose(params) => &params.owner.plugin_id,
Self::DshBroker(params) => &params.owner.plugin_id,
Self::ExecutionRedeem(params) => &params.owner.plugin_id,
Self::ProcClose(params) => &params.owner.plugin_id,
Self::NetClose(params) => &params.owner.plugin_id,
@@ -921,6 +984,10 @@ pub fn parse_host_message(value: Value, tier: HostTier) -> Result<HostMessage, P
id,
request: Box::new(HostRequest::SubagentDispose(params(&method, p)?)),
},
("dsh/broker", Some(id)) => HostMessage::Request {
id,
request: Box::new(HostRequest::DshBroker(params(&method, p)?)),
},
("session/checkpoint", Some(id)) => HostMessage::Request {
id,
request: Box::new(HostRequest::SessionCheckpoint(params(&method, p)?)),
@@ -1040,6 +1107,7 @@ impl HostMessage {
HostRequest::SessionCheckpoint(p) => {
request_value(*id, "session/checkpoint", to_value(p))
}
HostRequest::DshBroker(p) => request_value(*id, "dsh/broker", to_value(p)),
HostRequest::ProcClose(p) => request_value(*id, "proc/close", to_value(p)),
HostRequest::NetStart(p) => request_value(*id, "net/start", to_value(p)),
HostRequest::NetFetch(p) => request_value(*id, "net/fetch", to_value(p)),
@@ -1145,6 +1213,8 @@ pub struct ToolCallParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub subagent_ticket: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub broker_tickets: Option<Vec<DshBrokerTicketWire>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caller_tools: Option<Vec<String>>,
/// Per-invocation identity; never cached at activation or by the host root.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -56,6 +56,7 @@ fn params_schema(method: &str, generator: &mut SchemaGenerator) -> Schema {
"user-questions/ask" => generator.subschema_for::<UserQuestionParams>(),
"subagent/start" => generator.subschema_for::<SubagentStartParams>(),
"subagent/result" | "subagent/dispose" => generator.subschema_for::<SubagentHandleParams>(),
"dsh/broker" => generator.subschema_for::<DshBrokerParams>(),
"ext/faulted" => generator.subschema_for::<FaultedParams>(),
"log" => generator.subschema_for::<LogParams>(),
other => panic!("method `{other}` has no params type in the TypeScript generator"),
@@ -195,6 +196,15 @@ fn render() -> String {
let _ = writeln!(out, " {name}: {code},");
}
out.push_str("} as const\n\nexport type Direction = 'core_to_host' | 'host_to_core'\n");
let services: Vec<String> = DshService::ALL
.iter()
.map(|service| quote(service.name()))
.collect();
let _ = writeln!(
out,
"\nexport const DSH_SERVICES = [{}] as const\nexport type DshService = typeof DSH_SERVICES[number]",
services.join(", ")
);
out.push_str("\n/** Every method either side may send, and the trust tiers it is allowed on; nothing else is admitted. */\nexport const METHODS = [\n");
for (spec, params) in &methods {
let tiers: Vec<String> = spec.tiers.iter().map(|tier| quote(tier.name())).collect();
@@ -275,6 +285,11 @@ const REVIEWED: &[(&str, &str, &str)] = &[
"subagent/dispose",
"opaque live ticket and a child id this invocation started; asks the manager to cancel and dispose its own child; no other child, session or turn",
),
(
"host_to_core",
"dsh/broker",
"opaque live invocation ticket minted per plugin owner, DSH service and session; Rust redeems it under the same owner, tier, host-generation, lifetime and use checks as core/call, takes the session from the ticket and never from the host, and dispatches only to that service's typed Rust arm, which refuses an unknown method or an unimplemented service; no tool name, approval, credential or model route",
),
(
"host_to_core",
"user-questions/ask",
@@ -623,3 +638,63 @@ fn production_methods_follow_their_tier_rows_and_reserved_families_stay_builtin_
HostTier::Builtin
));
}
#[test]
fn dsh_broker_is_one_strict_envelope_and_names_only_the_closed_service_list() {
let frame = json!({
"jsonrpc": "2.0", "id": 91, "method": "dsh/broker",
"params": {
"owner": {"plugin_id": "dsh-owner", "owner_token": "opaque", "generation": 1},
"ticket": "broker-ticket", "service": "workspace", "method": "read", "input": {"path": "a"},
},
});
for tier in HostTier::ALL {
let parsed = parse_host_message(frame.clone(), tier).unwrap();
assert_eq!(parsed.to_value(), frame);
let HostMessage::Request { request, .. } = parsed else {
panic!("dsh/broker is a request")
};
let HostRequest::DshBroker(params) = *request else {
panic!("wrong broker variant")
};
assert_eq!(params.service, "workspace");
assert!(parse_core_message(frame.clone(), tier).is_err());
for field in ["session_id", "call_id", "approval", "name"] {
let mut forged = frame.clone();
forged["params"][field] = json!("foreign-selector");
assert!(parse_host_message(forged, tier).is_err(), "{field}");
}
for field in ["owner", "ticket", "service", "method", "input"] {
let mut missing = frame.clone();
missing["params"].as_object_mut().unwrap().remove(field);
assert!(parse_host_message(missing, tier).is_err(), "{field}");
}
let mut notification = frame.clone();
notification.as_object_mut().unwrap().remove("id");
assert!(parse_host_message(notification, tier).is_err());
let mut unknown = frame.clone();
unknown["params"]["service"] = json!("vault");
assert!(parse_host_message(unknown, tier).is_ok());
}
let names: Vec<&str> = DshService::ALL
.iter()
.map(|service| service.name())
.collect();
assert_eq!(
names,
[
"workspace",
"llm",
"network",
"settings",
"catalog",
"terminal",
"remote"
]
);
for service in DshService::ALL {
assert_eq!(DshService::parse(service.name()), Some(service));
}
assert_eq!(DshService::parse("Workspace"), None);
assert_eq!(DshService::parse("vault"), None);
}
+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());
}
}
@@ -1404,6 +1404,11 @@ pub fn registry_host_request<E: HostEvents + ?Sized>(
message: "session/checkpoint is not served here".to_string(),
data: None,
}),
HostRequest::DshBroker(_) => Err(RpcErrorWire {
code: error_code::NOT_AVAILABLE,
message: "dsh/broker is not served here".to_string(),
data: Some(json!({ "dsh_refusal": "not_available" })),
}),
HostRequest::ExecutionRedeem(_)
| HostRequest::CoreCall(_)
| HostRequest::ProcLaunch(_)
@@ -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(),
@@ -2503,6 +2504,7 @@ async fn ordinary_exit_rejects_requests_from_a_drained_calls_waker() {
checkpoint_ticket: None,
question_ticket: None,
subagent_ticket: None,
broker_tickets: None,
caller_tools: None,
session_id: None,
agent_id: None,
@@ -2567,6 +2569,7 @@ async fn idle_retirement_seals_admission_and_does_not_wait_for_heartbeat() {
checkpoint_ticket: None,
question_ticket: None,
subagent_ticket: None,
broker_tickets: None,
caller_tools: None,
session_id: None,
agent_id: None,
@@ -2667,6 +2670,7 @@ async fn two_dirty_teardowns_wait_for_a_live_call_then_replay_without_spending_c
checkpoint_ticket: None,
question_ticket: None,
subagent_ticket: None,
broker_tickets: None,
caller_tools: None,
session_id: None,
agent_id: None,
+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);
+2
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 {
@@ -382,6 +383,7 @@ impl ToolSpec for HostToolSpec {
subagent_ticket: invocation
.as_ref()
.and_then(|i| i.subagent_ticket().map(str::to_string)),
broker_tickets: invocation.as_ref().and_then(|i| i.broker_tickets()),
caller_tools: invocation.as_ref().and_then(|i| i.caller_tools()),
session_id: context
.execution
File diff suppressed because it is too large. Load diff
+135
View File
@@ -520,10 +520,145 @@ pub async fn launch(id: &str) -> Result<ExternalAgentLaunch, ExecutorUnavailable
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct QuotaGate {
pub enabled: bool,
pub consented: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaSource {
AgentReported,
LocalSignin,
}
impl QuotaSource {
pub fn as_str(self) -> &'static str {
match self {
Self::AgentReported => "agent_reported",
Self::LocalSignin => "local_signin",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuotaUnknown {
FlagOff,
ConsentMissing,
NotImplemented,
UnknownAgent,
}
impl QuotaUnknown {
pub fn as_str(self) -> &'static str {
match self {
Self::FlagOff => "flag_off",
Self::ConsentMissing => "consent_missing",
Self::NotImplemented => "not_implemented",
Self::UnknownAgent => "unknown_agent",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct QuotaWindow {
pub name: String,
pub used_percent: Option<f64>,
pub resets_at: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AgentQuota {
Known {
source: QuotaSource,
windows: Vec<QuotaWindow>,
},
Unknown(QuotaUnknown),
}
pub enum LocalSigninReader {}
fn local_signin_reader(_agent: &ExternalAgent) -> Option<LocalSigninReader> {
None
}
pub async fn local_signin_quota(agent: &ExternalAgent, gate: QuotaGate) -> AgentQuota {
if !gate.enabled {
return AgentQuota::Unknown(QuotaUnknown::FlagOff);
}
if !gate.consented {
return AgentQuota::Unknown(QuotaUnknown::ConsentMissing);
}
match local_signin_reader(agent) {
None => AgentQuota::Unknown(QuotaUnknown::NotImplemented),
Some(reader) => match reader {},
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn quota_gate_reads_nothing_without_flag_and_consent_and_logs_nothing() {
let buffer = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u8>::new()));
let writer = buffer.clone();
let subscriber = tracing_subscriber::fmt()
.with_max_level(tracing::Level::TRACE)
.with_writer(move || CaptureWriter(writer.clone()))
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let cases = [
(QuotaGate::default(), QuotaUnknown::FlagOff),
(
QuotaGate {
enabled: false,
consented: true,
},
QuotaUnknown::FlagOff,
),
(
QuotaGate {
enabled: true,
consented: false,
},
QuotaUnknown::ConsentMissing,
),
(
QuotaGate {
enabled: true,
consented: true,
},
QuotaUnknown::NotImplemented,
),
];
for agent in AGENTS {
assert!(local_signin_reader(agent).is_none(), "{}", agent.id);
for (gate, reason) in cases {
assert_eq!(
local_signin_quota(agent, gate).await,
AgentQuota::Unknown(reason),
"{} {gate:?}",
agent.id
);
}
}
assert!(buffer.lock().unwrap().is_empty());
}
struct CaptureWriter(std::sync::Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for CaptureWriter {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn names(paths: &[PathBuf]) -> Vec<String> {
paths
.iter()
+1
View File
@@ -17,6 +17,7 @@ pub mod compaction;
pub mod compaction_archive;
pub mod core;
pub mod cost_status;
pub mod diagnostics_messages;
pub mod exec_events;
pub mod extension_host;
pub mod fleet;
+99
View File
@@ -70,6 +70,10 @@ impl AccountFence {
}
async fn read_account_epoch(profile: Option<String>) -> Option<AccountEpoch> {
#[cfg(any(test, feature = "test-support"))]
if let Some(seam) = TEST_ACCOUNT_EPOCH.with(|cell| cell.borrow().clone()) {
return seam;
}
tokio::task::spawn_blocking(move || account_epoch(profile.as_deref()))
.await
.ok()?
@@ -271,6 +275,43 @@ pub fn fail_account_load_for_test(message: &'static str) -> AccountLoadFailureGu
AccountLoadFailureGuard
}
#[cfg(any(test, feature = "test-support"))]
thread_local! {
static TEST_ACCOUNT_EPOCH: std::cell::RefCell<Option<Option<AccountEpoch>>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(any(test, feature = "test-support"))]
pub fn pin_account_epoch_for_test(account_id: Option<&str>) -> AccountEpochGuard {
let guard = AccountEpochGuard;
guard.set(account_id);
guard
}
#[cfg(any(test, feature = "test-support"))]
pub struct AccountEpochGuard;
#[cfg(any(test, feature = "test-support"))]
impl AccountEpochGuard {
pub fn set(&self, account_id: Option<&str>) {
let epoch = account_id.map(|id| AccountEpoch {
api_base: runtime_account_api_base(),
state: AccountSessionState::Authenticated,
account_id: Some(id.to_owned()),
session_id: Some(format!("session_{id}")),
signed_out_revision: None,
});
TEST_ACCOUNT_EPOCH.with(|cell| *cell.borrow_mut() = Some(epoch));
}
}
#[cfg(any(test, feature = "test-support"))]
impl Drop for AccountEpochGuard {
fn drop(&mut self) {
TEST_ACCOUNT_EPOCH.with(|cell| *cell.borrow_mut() = None);
}
}
#[cfg(any(test, feature = "test-support"))]
pub struct AccountLoadFailureGuard;
@@ -384,4 +425,62 @@ mod tests {
document.notes = "unsafe\0control".into();
assert!(document.as_user_constitution().is_err());
}
#[tokio::test]
async fn desktop_browser_call_is_fenced_to_the_admitting_account() {
const BROWSER: &str = "mcp_codewhale-browser_navigate";
const OTHER: &str = "mcp_other_navigate";
let refused = |result: Result<(), crate::tools::spec::ToolError>| {
result.expect_err("call must be refused").to_string()
};
let epoch = pin_account_epoch_for_test(Some("acct_a"));
assert!(
refused(admit_desktop_browser_call(BROWSER, None).await)
.contains("could not confirm which account started this turn")
);
let fence = AccountFence::capture(None).await;
admit_desktop_browser_call(BROWSER, Some(&fence))
.await
.expect("unchanged epoch is admitted");
admit_desktop_browser_call(OTHER, Some(&fence))
.await
.expect("non-browser tool is unaffected");
admit_desktop_browser_call("read_file", None)
.await
.expect("non-browser tool needs no fence");
epoch.set(Some("acct_b"));
assert!(
refused(admit_desktop_browser_call(BROWSER, Some(&fence)).await)
.contains("account changed after this turn started")
);
admit_desktop_browser_call(OTHER, Some(&fence))
.await
.expect("non-browser tool still unaffected after a switch");
let fence_b = AccountFence::capture(None).await;
admit_desktop_browser_call(BROWSER, Some(&fence_b))
.await
.expect("a turn admitted under the new account is admitted");
epoch.set(Some("acct_a"));
assert!(
refused(admit_desktop_browser_call(BROWSER, Some(&fence_b)).await)
.contains("account changed after this turn started")
);
epoch.set(None);
assert!(
refused(admit_desktop_browser_call(BROWSER, Some(&fence_b)).await)
.contains("could not confirm the signed-in account")
);
let unconfirmed = AccountFence::capture(None).await;
assert!(
refused(admit_desktop_browser_call(BROWSER, Some(&unconfirmed)).await)
.contains("could not confirm which account started this turn")
);
admit_desktop_browser_call(OTHER, Some(&unconfirmed))
.await
.expect("non-browser tool unaffected by an unreadable store");
}
}
Loaded 100 of 596 files, more files were not shown because too many files have changed in this diff. Show more