Compare commits

..
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 4c4e66489b fix(config): show the telemetry setting default as on, matching the runtime
The settings schema declared telemetry default false while the resolver
(telemetry_consent_from_env) treats an unset value as on, so the settings
API reported default false for a default-on policy.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

On the previous source it found three:

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-12 14:05:10 -08:00
CodeWhale BotandClaude Opus 5.5 cb57a2eacb chore(web): regenerate agent docs for the Runtime API line the Qwen Code registry change edited
2c9174012e (#7293) changed one line of docs/RUNTIME_API.md without rerunning
node web/scripts/derive-facts.mjs, so web/data/agent-docs.generated.json on
origin/main carried the old hash and text. This is that regeneration and
nothing else (facts.generated.ts unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-12 14:04:02 -08:00
365 changed files with 8761 additions and 13735 deletions

No files matched your search

+3 -3
View File
@@ -48,7 +48,7 @@
tags: cnb:arch:amd64
cpus: 16
docker:
image: rust:1.100.0-bookworm
image: rust:1.88-bookworm
# codewhale-cnb-bridge GitHub App credentials from the CNB KeyStore
# (codewhale.net/codewhale-ci-secrets, github-bridge.yml), injected as
# environment variables. Values are never printed.
@@ -112,7 +112,7 @@
tags: cnb:arch:amd64
cpus: 16
docker:
image: rust:1.100.0-bookworm
image: rust:1.88-bookworm
stages:
- name: install release dependencies
script: |
@@ -163,7 +163,7 @@ main:
$:
tag_push:
- docker:
image: rust:1.100.0-bookworm
image: rust:1.88-bookworm
stages:
- name: build linux x64 release assets (static)
# The static full-LTO link can also outlive CNB's default no-output
+1 -1
View File
@@ -1,4 +1,4 @@
ARG RUST_VERSION=1.100.0
ARG RUST_VERSION=1.88
FROM rust:${RUST_VERSION}-slim-bookworm
# Native DBus headers and pkg-config are required by libdbus-sys during
+1 -1
View File
@@ -4,7 +4,7 @@
"context": "..",
"dockerfile": "Dockerfile",
"args": {
"RUST_VERSION": "1.100.0"
"RUST_VERSION": "1.88"
}
},
"customizations": {
-2
View File
@@ -319,5 +319,3 @@ namezzb = sailorZ <[email protected]>
alphapku-ux = Alpha Wu <[email protected]>
BX166 = BX166 <[email protected]>
c020627 = c020627 <[email protected]>
[email protected] = Parman Mohammadalizadeh <[email protected]>
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
-1
View File
@@ -75,7 +75,6 @@ security boundaries and adds bridge-specific controls.
- **Token gate**: All runtime API calls carry `CODEWHALE_RUNTIME_TOKEN`
- **Chat allowlist**: Only chats/users listed in `WECOM_CHAT_ALLOWLIST` can interact. First-pairing mode (`WECOM_ALLOW_UNLISTED=true`) is meant for onboarding only
- **Approval required**: Tool calls from WeCom sessions must be approved — either via explicit `/allow <id>` commands or natural-language keywords (`允许`, `yes`, `ok`, etc.)
- **Thread ownership**: `/threads` and `/resume` cover only the threads the chat itself created; any other thread ID is refused with one fixed reply
- **No workspace exposure**: Only prompts, status summaries, and approval requests are sent to WeCom. Workspace contents, shell output, and runtime internals stay on the local machine
### Reporting WeCom Bridge vulnerabilities
+19 -119
View File
@@ -248,7 +248,7 @@ assert.match(nightly, /startsWith\(matrix\.target, 'aarch64-'\).*runner\.arch ==
const nightlyArmMuslSetup = namedStep(nightly, "Install Linux ARM64 musl toolchain");
assert.match(nightlyArmMuslSetup, /matrix\.target == 'aarch64-unknown-linux-musl'/);
assert.match(nightlyArmMuslSetup, /apt-get install -y binutils musl-tools/);
assert.match(nightlyArmMuslSetup, /rustup target add aarch64-unknown-linux-musl/);
assert.match(nightlyArmMuslSetup, /rustup target add --toolchain stable aarch64-unknown-linux-musl/);
const nightlyArmStaticSmoke = namedStep(
nightly,
"Verify static Linux ARM64 binary and launch",
@@ -373,7 +373,7 @@ assert.deepEqual([...new Set(valuesForKey(artifacts, "target"))].sort(), expecte
const releaseMuslBuild = namedStep(artifacts, "Build static Linux binaries (musl)");
assert.match(releaseMuslBuild, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
assert.match(releaseMuslBuild, /apt-get install -y binutils musl-tools/);
assert.match(releaseMuslBuild, /rustup target add \$\{\{ matrix\.target \}\}/);
assert.match(releaseMuslBuild, /rustup target add --toolchain stable \$\{\{ matrix\.target \}\}/);
assert.match(
releaseMuslBuild,
/cargo build --profile dist --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
@@ -810,20 +810,6 @@ assert.doesNotMatch(
// A job that calls a reusable workflow (`uses:`) cannot carry the key at all —
// GitHub rejects it — so the callee owns its own caps. That is why the artifact
// bounds live in release-artifacts.yml rather than in its callers.
function timeoutMinutes(value) {
const literal = /^\d+$/.test(value);
const conditional = value.match(
/^\$\{\{\s*startsWith\(matrix\.os,\s*'macos-'\)\s*&&\s*(\d+)\s*\|\|\s*(\d+)\s*\}\}$/,
);
if (!literal && !conditional) return null;
const macos = Number(literal ? value : conditional[1]);
const other = Number(literal ? value : conditional[2]);
if (![macos, other].every((cap) => Number.isInteger(cap) && cap > 0 && cap <= 360)) {
return null;
}
return { macos, other };
}
function jobsWithoutTimeout(source) {
const lines = source.split("\n");
const jobsAt = lines.findIndex((line) => /^jobs:\s*$/.test(line));
@@ -837,8 +823,7 @@ function jobsWithoutTimeout(source) {
for (let j = i + 1; j < lines.length; j += 1) {
if (/^ [A-Za-z0-9_-]+:\s*$/.test(lines[j])) break;
if (/^ uses:/.test(lines[j])) reusable = true;
const timeout = lines[j].match(/^ timeout-minutes:\s*(.+?)\s*$/);
if (timeout && timeoutMinutes(timeout[1])) capped = true;
if (/^ timeout-minutes:\s*\d+\s*$/.test(lines[j])) capped = true;
}
if (!reusable && !capped) offenders.push(header[1]);
}
@@ -877,41 +862,26 @@ for (const [name, source] of [
);
}
function jobTimeout(source, job, platform = "other") {
// The Windows artifact build historically runs 40-45 minutes, so its cap has to
// keep real margin — a tight bound here fails healthy releases.
const buildTimeout = artifacts.match(/^ build:\n(?:.*\n)*? timeout-minutes: (\d+)$/m);
assert.ok(buildTimeout, "release-artifacts build job must be capped");
assert.ok(
Number(buildTimeout[1]) >= 60,
`artifact build cap ${buildTimeout[1]}m leaves no margin over a healthy 40-45m Windows build`,
);
function jobTimeout(source, job) {
const match = source.match(
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (.+)$`, "m"),
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (\\d+)$`, "m"),
);
assert.ok(match, `${job} must declare timeout-minutes`);
const caps = timeoutMinutes(match[1].trim());
assert.ok(caps, `${job} must declare a bounded timeout`);
return caps[platform];
return Number(match[1]);
}
const macosTimeout = "${{ startsWith(matrix.os, 'macos-') && 360 || 210 }}";
assert.deepEqual(timeoutMinutes(macosTimeout), { macos: 360, other: 210 });
for (const invalid of [
"0", "361", "Infinity", "${{ matrix.timeout }}",
"${{ startsWith(matrix.os, 'macos-') && 361 || 210 }}",
"${{ startsWith(matrix.os, 'macos-') && 360 || 0 }}",
]) {
assert.equal(timeoutMinutes(invalid), null, `${invalid} must not count as a bounded cap`);
}
assert.deepEqual(
jobsWithoutTimeout(`jobs:\n capped:\n timeout-minutes: ${macosTimeout}\n`),
[],
);
assert.equal(jobTimeout(artifacts, "build", "macos"), 360);
assert.equal(jobTimeout(artifacts, "build"), 210);
const artifactCacheSetup = artifacts.match(
/ - uses: mozilla-actions\/sccache-action@[^\n]+\n(?:(?! - ).*\n)*/,
);
assert.ok(artifactCacheSetup, "artifact compiler cache setup must be explicit");
assert.match(artifactCacheSetup[0], /^ if: runner\.os == 'Linux'$/m);
assert.match(
namedStep(artifacts, "Enable sccache"),
/^ if: steps\.sccache\.outcome == 'success' && runner\.os == 'Linux'$/m,
);
// Fast setup and packaging fail quickly. Native builds need the same finite
// cold-build allowance as full CI: both 0.10.1 macOS artifacts hit the old
// 90m compilation cap before reaching their launch and inventory checks.
assert.equal(jobTimeout(candidate, "resolve"), 10);
assert.equal(jobTimeout(candidate, "web"), 15);
assert.equal(jobTimeout(artifacts, "pin"), 10);
@@ -924,7 +894,7 @@ for (const job of ["bundle", "windows-installer", "assemble", "smoke"]) {
}
assert.equal(
jobTimeout(nightly, "build"), jobTimeout(artifacts, "build"),
"nightly and non-macOS release builds must share the cold-build allowance",
"nightly and release must share the native cold-build allowance",
);
assert.equal(jobTimeout(release, "resolve"), 10);
// The 0.10.1 cold parity build exhausted 45 minutes before tests started.
@@ -934,76 +904,6 @@ assert.ok(
"release parity must allow the full CI suite's cold-build budget",
);
const pinnedToolchain = read("rust-toolchain.toml").match(/^channel\s*=\s*"([^"]+)"/m)[1];
assert.match(pinnedToolchain, /^\d+\.\d+\.\d+$/, "rust-toolchain.toml must pin an exact Rust release");
const msrv = read("Cargo.toml").match(/^rust-version\s*=\s*"([^"]+)"/m)[1];
assert.match(msrv, /^\d+\.\d+$/, "Cargo.toml must declare a minor MSRV");
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const stripWorkflowComments = (source) =>
source
.split("\n")
.map((line) => line.replace(/(^|\s)#.*$/, ""))
.join("\n");
const workflowDir = path.join(repoRoot, ".github", "workflows");
const workflowSources = new Map();
for (const file of fs.readdirSync(workflowDir).filter((name) => /\.ya?ml$/.test(name)).sort()) {
workflowSources.set(file, stripWorkflowComments(read(`.github/workflows/${file}`)));
}
const ciSource = workflowSources.get("ci.yml");
const msrvHeader = /^ msrv:[ \t]*$/m.exec(ciSource);
assert.ok(msrvHeader, "ci.yml must keep an MSRV job");
const msrvStart = msrvHeader.index;
const afterMsrvHeader = msrvStart + msrvHeader[0].length;
const nextJob = /^ \S/m.exec(ciSource.slice(afterMsrvHeader));
const msrvEnd = nextJob ? afterMsrvHeader + nextJob.index : ciSource.length;
const selectorValue = (value) => value.trim().replace(/^(["'])(.*)\1$/, "$2");
const inMsrvJob = (index) => index >= msrvStart && index < msrvEnd;
for (const [file, source] of workflowSources) {
const selectors = [
...[...source.matchAll(/dtolnay\/rust-toolchain@([^\s]+)/g)].map((m) => ["action ref", m[1], m.index]),
...[...source.matchAll(/^[ \t]+toolchain:[ \t]*([^\r\n]*)$/gm)].map((m) => ["toolchain input", selectorValue(m[1]), m.index]),
...[...source.matchAll(/^[ \t]+rust-version:[ \t]*([^\r\n]*)$/gm)].map((m) => ["rust-version input", selectorValue(m[1]), m.index]),
...[...source.matchAll(/^[ \t]+RUSTUP_TOOLCHAIN:[ \t]*([^\r\n]*)$/gm)].map((m) => ["RUSTUP_TOOLCHAIN", selectorValue(m[1]), m.index]),
...[...source.matchAll(/\bcargo\s+\+\s*["']?([^\s"']+)/g)].map((m) => ["cargo + selector", m[1], m.index]),
];
for (const [kind, value, index] of selectors) {
if (kind === "action ref" && (/^[0-9a-f]{40}$/.test(value) || value === "master")) continue;
const expected = file === "ci.yml" && inMsrvJob(index) ? msrv : pinnedToolchain;
assert.equal(value, expected, `${file}: ${kind} ${value} must equal ${expected}`);
}
assert.doesNotMatch(source, /rustup (?:target|component) add --toolchain/, `${file}: rustup adds must follow rust-toolchain.toml`);
}
const msrvJob = ciSource.slice(msrvStart, msrvEnd);
assert.match(msrvJob, new RegExp(`RUSTUP_TOOLCHAIN: "${escapeRegExp(msrv)}"`), "the MSRV job must override rust-toolchain.toml for every step");
assert.match(msrvJob, new RegExp(`toolchain: "?${escapeRegExp(msrv)}"?`));
assert.match(msrvJob, new RegExp(`cargo \\+${escapeRegExp(msrv)} check --workspace --locked`));
assert.doesNotMatch(msrvJob, new RegExp(`\\b${escapeRegExp(pinnedToolchain)}\\b`), "the MSRV job must not select the pinned release toolchain");
const rootDockerfile = read("Dockerfile");
const devcontainerDockerfile = read(".devcontainer/Dockerfile");
const devcontainerConfig = read(".devcontainer/devcontainer.json");
const tencentCnb = read("deploy/tencent-lighthouse/cnb/cnb.yml.example");
const pinnedTag = `${pinnedToolchain}-bookworm`;
for (const [label, source] of [
["Dockerfile", rootDockerfile],
[".devcontainer/Dockerfile", devcontainerDockerfile],
]) {
assert.match(source, new RegExp(`^ARG RUST_VERSION=${escapeRegExp(pinnedToolchain)}$`, "m"), `${label} must default RUST_VERSION to the pinned toolchain`);
assert.match(source, /^FROM .*\brust:\$\{RUST_VERSION\}/m, `${label} must build from the RUST_VERSION image`);
}
assert.match(devcontainerConfig, new RegExp(`"RUST_VERSION":\\s*"${escapeRegExp(pinnedToolchain)}"`), ".devcontainer/devcontainer.json must pass the pinned RUST_VERSION build arg");
for (const [label, source] of [
[".cnb.yml", cnb],
["deploy/tencent-lighthouse/cnb/cnb.yml.example", tencentCnb],
]) {
const tags = [...stripWorkflowComments(source).matchAll(/^[ \t]*image:[ \t]*([^\r\n]*)$/gm)]
.map((m) => selectorValue(m[1]))
.filter((value) => value.startsWith("rust:"))
.map((value) => value.slice("rust:".length));
assert.ok(tags.length > 0, `${label} must declare a Rust build image`);
assert.deepEqual([...new Set(tags)], [pinnedTag], `${label}: Rust build images must match rust-toolchain.toml`);
}
console.log(
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 8-target/38-asset release candidate.",
);
+5 -5
View File
@@ -23,17 +23,17 @@ jobs:
continue-on-error: ${{ matrix.checks == 'advisories' }}
steps:
- uses: actions/checkout@v7
# rust-toolchain.toml pins `channel = "1.100.0"` with no target, so
# rust-toolchain.toml pins `channel = "stable"` with no target, so
# inside the action's alpine (musl) container rustup resolves it to
# `1.100.0-x86_64-unknown-linux-musl`, which the container does not
# `stable-x86_64-unknown-linux-musl` — which the container does not
# have. Without provisioning, the action's `rustup show` step prints
# "error: override toolchain '1.100.0-x86_64-unknown-linux-musl' is not
# "error: override toolchain 'stable-x86_64-unknown-linux-musl' is not
# installed" and then auto-installs mid-run (network-dependent ~12s
# detour, hard failure if the download stalls). `rust-version` makes the
# entrypoint run `rustup default 1.100.0` (same musl host triple) before
# entrypoint run `rustup default stable` (same musl host triple) before
# cargo-deny touches the workspace, so the toolchain file resolves to an
# already-installed toolchain on every run.
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check ${{ matrix.checks }}
rust-version: 1.100.0
rust-version: stable
+12 -14
View File
@@ -203,7 +203,7 @@ jobs:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
- uses: actions/[email protected]
with:
node-version: 22
@@ -428,7 +428,7 @@ jobs:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.100.0
toolchain: stable
- uses: mozilla-actions/[email protected]
id: sccache
# The GitHub Actions cache backend is main-only, mirroring
@@ -487,7 +487,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
if: needs.changes.outputs.heavy == 'true'
with:
toolchain: 1.100.0
toolchain: stable
components: rustfmt, clippy
- uses: mozilla-actions/[email protected]
id: sccache
@@ -728,8 +728,6 @@ jobs:
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
timeout-minutes: 45
runs-on: ubuntu-latest
env:
RUSTUP_TOOLCHAIN: "1.89"
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
@@ -753,7 +751,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/[email protected]
id: sccache
# Main-only GitHub Actions cache backend; see the Safety gate job.
@@ -827,11 +825,11 @@ jobs:
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
shell: pwsh
run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- name: Install macOS LLVM tools
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
run: rustup component add llvm-tools
run: rustup component add llvm-tools --toolchain stable
- name: Omit macOS build debug metadata
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
shell: bash
@@ -1224,7 +1222,7 @@ jobs:
if: needs.changes.outputs.heavy == 'true'
shell: pwsh
run: ./scripts/installer/update-user-path.tests.ps1
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true'
- name: Check every workspace target compiles on Windows
if: needs.changes.outputs.heavy == 'true'
@@ -1269,7 +1267,7 @@ jobs:
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
@@ -1307,7 +1305,7 @@ jobs:
run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context."
- uses: actions/checkout@v7
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
- uses: mozilla-actions/[email protected]
id: sccache
@@ -1315,7 +1313,7 @@ jobs:
# Main-only GitHub Actions cache backend; see the Safety gate job.
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && github.ref == 'refs/heads/main'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success' && matrix.os != 'windows-latest'
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
@@ -1366,7 +1364,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/[email protected]
id: sccache
# Main-only GitHub Actions cache backend; see the Safety gate job.
@@ -1425,7 +1423,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.100.0
toolchain: stable
components: rustfmt
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
+3 -3
View File
@@ -67,13 +67,13 @@ jobs:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.100.0
toolchain: stable
targets: ${{ matrix.target }}
- uses: mozilla-actions/[email protected]
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success' && runner.os != 'Windows'
if: steps.sccache.outcome == 'success'
shell: bash
run: |
{
@@ -99,7 +99,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y binutils musl-tools
rustup target add aarch64-unknown-linux-musl
rustup target add --toolchain stable aarch64-unknown-linux-musl
- name: Build
shell: bash
# Nightly artifacts are disposable smoke binaries (14-day retention),
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: Install Rust target
uses: dtolnay/rust-toolchain@1.100.0
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-unknown-linux-ohos
- name: Install OpenHarmony native SDK
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: pet/package-lock.json
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
- run: npm --prefix pet run sync
@@ -60,7 +60,7 @@ jobs:
cache-dependency-path: pet/package-lock.json
- run: npm --prefix pet ci --ignore-scripts
- run: npm --prefix pet run check
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
if: runner.os == 'macOS'
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
if: runner.os == 'macOS'
@@ -86,7 +86,7 @@ jobs:
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6.2.0
- uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: '17'
+7 -6
View File
@@ -88,7 +88,9 @@ jobs:
build:
name: Build ${{ matrix.platform }}
timeout-minutes: ${{ startsWith(matrix.os, 'macos-') && 360 || 210 }}
# Both cold macOS artifact builds on 0.10.1 exhausted 90 minutes inside
# compilation. Keep the full CI build budget; packaging stays capped below.
timeout-minutes: 210
# FreeBSD is a source-build target with no prebuilt asset, no npm binary and no CI check
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd).
strategy:
@@ -170,14 +172,13 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
with:
toolchain: 1.100.0
toolchain: stable
targets: ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
if: runner.os == 'Linux'
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success' && runner.os == 'Linux'
if: steps.sccache.outcome == 'success'
shell: bash
run: |
{
@@ -215,7 +216,7 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y binutils musl-tools
rustup target add ${{ matrix.target }}
rustup target add --toolchain stable ${{ matrix.target }}
cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
- name: Configure Android NDK linker
if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux'
@@ -286,7 +287,7 @@ jobs:
mirror: true
- name: Install Java for the OpenHarmony binary signer
if: matrix.platform == 'ohos-arm64'
uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v4
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v4
with:
distribution: temurin
java-version: '17'
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
package-manager-cache: false
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable 2026-07-18
with:
toolchain: 1.100.0
toolchain: stable
- name: Match dispatch to the requested commit
id: source
shell: bash
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
with:
toolchain: 1.100.0
toolchain: stable
components: clippy, rustfmt
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
id: sccache
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run cargo audit
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
with:
ref: ${{ inputs.expected_sha }}
fetch-depth: 1
- uses: dtolnay/rust-toolchain@1.100.0
- uses: dtolnay/rust-toolchain@stable
- uses: taiki-e/install-action@nextest
- name: Run focused tests
shell: bash
+13 -27
View File
@@ -7,43 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
- **Usage-reporting collector:** accepts runtime schema 6 alongside existing versions and keeps schema 6 reports first-party only.
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
- **Terminal input recovery:** retains a wake event from a replaced reader, suppresses retired events observed during a child-terminal handoff, and closes only the handoff window owned by that scope.
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
- **Delayed-message workspaces and shell stdin approvals:** `send_later` `schedule` now keeps its workspace inside the session's reachable paths instead of accepting an arbitrary directory, and `send_later` approval prompts show the requested workspace; session shell grants now match exact inputs when `stdin` (`stdin`, `input`, `data`) is supplied instead of reusing a command-only grant across different `stdin`.
- **Installers no longer say Computer Use comes bundled:** the macOS install script and the install guide no longer print or state that Computer Use ships with the binary or needs Node.js 20. Computer Use is released separately and stays off until you turn it on.
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and every value is also in words, so the model and other clients get the same facts as text. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and a plain-text summary accompanies each chart for the model and other clients. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
- **Terminal widgets:** narrow action hints wrap, scrollbars reach the final row, progress bars show fractional cells, focused tabs and panels are clearer, and the shared terminal kit supports measured ocean themes and reduced-motion-aware water drift.
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
- **Chat bridges thread ownership:** Chat bridges now list and resume only the threads the chat itself created: `/threads` shows owned threads, `/resume` refuses any other thread id with one fixed message, and `/new` always starts a new thread. In an existing thread map each chat keeps only the thread it is bound to, so threads it created earlier and left are no longer listed or resumable; a thread that several chats already pointed at is usable from none of them (send `/new`). Review `thread-map.json` after upgrading.
- **Skill and plugin installs:** skill and plugin installs now enforce the uncompressed size cap on the whole decompressed archive, and reject archive entries with names longer than 4096 bytes.
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **Website `/app` captures:** the `/app` page's desktop build screenshots (`conversation.webp`, `approval.webp`, `model-picker.webp`) and their alt and caption text have been refreshed from the current 0.10.3 desktop UI.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
@@ -123,7 +98,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- Child processes now inherit only the standard locale variables (`LC_ALL`, `LC_CTYPE` and the other locale categories, plus `LC_TERMINAL` and `LC_TERMINAL_VERSION`) and a listed set of `DOTNET_` host, SDK and runtime settings. Other `DOTNET_` variables, including application configuration keys, are no longer passed to child processes or MCP servers.
- **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.
@@ -705,7 +679,7 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **Windows uninstall removes the receipt and update leftovers.** The installer's uninstaller also deletes `.codewhale-install.sha256` and `codewhale.old-*`/`codew.old-*` left by a self-update, so `bin` can be removed. Refs #7091.
- **Windows `/undo` matches recorded file paths regardless of case.** A write recorded under a different drive-letter or directory case, or a `\\?\` prefix, is no longer dropped from the restore plan. Refs #6970.
- **Windows: Fleet and metrics read live files again.** Replaying a Fleet ledger and reading audit or receipt records no longer fail with a sharing violation while their writer is still open; the same regular-file, link and identity checks apply. Plugin enablement now says which snapshot check refused (stage root, content identity or final check) instead of one generic message. Generated protocol and SDK exports keep LF line endings in Windows checkouts.
- **Sessions in one folder no longer leave each other's sub-agents read-only or private:** a second session works beside another one, stale or suspended included, the older sub-agent store upgrades right away, and a store that cannot be opened is retried on the next sub-agent change instead of needing a restart. While another Codewhale session, a suspended one included, is open in a folder the desktop app or `serve` was asked to work in (other than the folder it started in), that Runtime still refuses sub-agents that would edit the folder directly; isolated worktrees are unaffected. A sub-agent store that cannot be opened shows its refusal once instead of repeating cleanup warnings on every sidebar refresh. Restart any session started with Codewhale 0.10.2 or older: once the folder upgrades it can no longer save sub-agent state (Refs #7232).
- **A held coordination lock no longer makes a new session read-only.** When another session owns the delegated-agent store, the new session starts on private state and says so, instead of refusing every sub-agent with “Failed to clean up sub-agents”.
- **A dev server can outlive the turn in an interactive session.** `persist:true` now works in the TUI and desktop when the thread has Full Access; restricted postures and Fleet/sub-agents still refuse it.
- **`codewhale exec --add-dir <path>`** (repeatable) and `addDirs` on `POST /v1/threads` let the file tools read and write a sibling directory, a second checkout or a shared scratch root without turning on Full Access.
- **Unsettled usage is persisted in production, not only in tests.** The interactive sink now records a legacy usage receipt when a provider response has no settlement, so children and turns stop reporting settlement failure.
@@ -742,6 +716,12 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
composer. Real Pinyin input on macOS and Windows is not yet confirmed.
Thanks [@cmdcorp6534](https://github.com/cmdcorp6534) for the report
(Refs #2323).
- **Sub-agents beside an older session:** while a Codewhale 0.10.2 session
still uses a folder's older delegated-agent store, a 0.10.3 session in the
same folder keeps working sub-agents with private state that is not saved,
instead of refusing every sub-agent action. The older session's files are
left untouched, and the store upgrades when a session starts after the older
one exits (Refs #7232).
- **Fleet members:** a replayed member start now matches only within its own
session, so two sessions that reuse an operation key no longer receive each
other's agent.
@@ -770,6 +750,12 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
bare self-package exports resolve inside the selected package directory.
Invalid or incompatible ranges and paths outside the reviewed package still
refuse. Real published-plugin activation remains unqualified.
- **Coordination lock diagnostics (#7232):** stale lock-file contents no longer
identify an owner PID. A refused legacy admission keeps its reason in Work
and shows one notice per cause instead of repeating cleanup warnings on
every sidebar refresh. Legacy migration still requires the actual holder to
exit and the refused owning Runtime to restart; locks and retained state
remain protected.
- **A local proxy that stops mid-session no longer strands the session (#7231):** the model, catalog, search, vision, finance, MCP-registry, profile and remote-control clients watch a loopback proxy port and go direct while it refuses connections, then resume proxying when it returns. Only a definite refusal from every resolved address changes the route; remote proxies, `NO_PROXY` matches, credentials and the existing precedence are unchanged. Set `CODEWHALE_LOCAL_PROXY_FALLBACK=0` to keep the old build-time snapshot.
- **Proxy diagnostic privacy ([#7233](https://github.com/codewhale-hq/codewhale/issues/7233)):** malformed MCP proxy warnings omit configured values; local-proxy log labels mask userinfo and omit URL paths, queries and fragments. Routing is unchanged. All 23 existing proxy tests passed on macOS; runtime log capture and other platforms remain unqualified. Thanks [@SparkofSpike](https://github.com/SparkofSpike) for the report.
- **Fork, undo and retry work after a workspace drift notice (#7234):** the history-boundary walks now treat a whole `<context_update>` envelope as engine-owned, so a session that received one can be cut at every saved turn. A person quoting a notice or a truncated envelope still counts as their own prompt.
Generated
+27 -6
View File
@@ -912,6 +912,14 @@ dependencies = [
"zeroize",
]
[[package]]
name = "codewhale-agent"
version = "0.10.3"
dependencies = [
"codewhale-config",
"serde",
]
[[package]]
name = "codewhale-agent-contract"
version = "0.10.3"
@@ -935,6 +943,7 @@ dependencies = [
"chrono",
"clap",
"codewhale-account-cli",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
"codewhale-build-support",
@@ -1040,7 +1049,6 @@ dependencies = [
"codewhale-paths",
"codewhale-release",
"codewhale-runtime",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-state",
"codewhale-telemetry",
@@ -1110,7 +1118,6 @@ dependencies = [
"codewhale-execpolicy",
"codewhale-paths",
"codewhale-protocol",
"codewhale-sanitize",
"codewhale-secrets",
"fd-lock",
"libc",
@@ -1134,6 +1141,7 @@ name = "codewhale-core"
version = "0.10.3"
dependencies = [
"codewhale-config",
"codewhale-hooks",
"codewhale-protocol",
"codewhale-state",
"regex",
@@ -1142,6 +1150,7 @@ dependencies = [
"sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
"tokio",
"uuid",
]
@@ -1170,12 +1179,14 @@ dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"arboard",
"async-stream",
"async-trait",
"axum",
"base64 0.23.1",
"chrono",
"clap",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
"codewhale-build-support",
@@ -1196,7 +1207,6 @@ dependencies = [
"codewhale-paths",
"codewhale-plugins",
"codewhale-protocol",
"codewhale-ratatui",
"codewhale-release",
"codewhale-repl",
"codewhale-runtime",
@@ -1211,6 +1221,7 @@ dependencies = [
"codewhale-util",
"codewhale-workflow",
"codewhale-workflow-js",
"cucumber",
"dirs 7.0.0",
"encoding_rs",
"fd-lock",
@@ -1229,10 +1240,12 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"portable-pty",
"pretty_assertions",
"proc-macro2",
"regex",
"reqwest",
"ring",
"rio-vt",
"rmcp",
"rusqlite",
"rustls",
@@ -1248,7 +1261,9 @@ dependencies = [
"tar",
"tempfile",
"thiserror 2.0.21",
"tiny_http",
"tokio",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
@@ -1292,11 +1307,14 @@ name = "codewhale-hooks"
version = "0.10.3"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"codewhale-app-server-protocol",
"codewhale-paths",
"codewhale-protocol",
"codewhale-release",
"reqwest",
"serde",
"serde_json",
"tempfile",
"tokio",
@@ -1416,7 +1434,7 @@ version = "0.10.3"
dependencies = [
"codewhale-config",
"codewhale-runtime",
"codewhale-sanitize",
"codewhale-secrets",
"dirs 7.0.0",
"regex",
"tempfile",
@@ -1593,12 +1611,12 @@ dependencies = [
"codewhale-paths",
"codewhale-protocol",
"codewhale-release",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-util",
"colored",
"dirs 7.0.0",
"fd-lock",
"futures-util",
"ignore",
"jsonwebtoken",
"libc",
@@ -1866,7 +1884,9 @@ dependencies = [
"reqwest",
"rio-vt",
"rquickjs",
"rusqlite",
"rustls",
"schemars 1.2.2",
"semver",
"serde",
"serde_json",
@@ -1876,6 +1896,7 @@ dependencies = [
"tempfile",
"tiny_http",
"tokio",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
@@ -1899,7 +1920,7 @@ version = "0.10.3"
dependencies = [
"codewhale-palette",
"codewhale-ratatui",
"codewhale-sanitize",
"codewhale-secrets",
"crossterm",
"ratatui",
"similar",
+1
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/account-cli",
"crates/agent",
"crates/agent-contract",
"crates/app-server",
"crates/app-server-protocol",
+1 -1
View File
@@ -12,7 +12,7 @@
# Or mount an env file:
# docker run --rm -it --env-file .env codewhale
ARG RUST_VERSION=1.100.0
ARG RUST_VERSION=1.88
# ── Stage 1: Build ────────────────────────────────────────────────────
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
+6 -3
View File
@@ -119,9 +119,12 @@ Connect [MCP servers](docs/MCP.md), add [skills](docs/SKILLS.md) and
[plugins](docs/PLUGINS.md), and run [hooks](docs/HOOKS.md) on session and tool
events. Code mode (experimental, on by default) lets the model write one
`execute_tools` program that calls several MCP and plugin tools; each call
still meets your approval rules and keeps its receipt. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin)
is a separate plugin released on its own (experimental, macOS only), and stays
off until you review its access and turn it on.
still meets your approval rules and keeps its receipt. The Computer Use plugin
bundled with v0.10.2 (experimental, macOS only) can observe and operate other
apps, and it stays off until you review its access and enable it
([guide](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/crates/tui/plugins/computer-use/README.md)).
Coming in 0.10.3: the plugin is released
[separately](https://github.com/codewhale-hq/codewhale-cu-plugin).
### Ways to run it
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "codewhale-agent"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Model/provider registry and fallback strategy for Codewhale"
[lints]
workspace = true
[dependencies]
codewhale-config = { path = "../config", version = "0.10.3" }
serde.workspace = true
File diff suppressed because it is too large. Load diff
+1
View File
@@ -32,6 +32,7 @@ codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
codewhale-agent = { path = "../agent", version = "0.10.3" }
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-core = { path = "../core", version = "0.10.3" }
codewhale-hooks = { path = "../hooks", version = "0.10.3" }
+33 -2
View File
@@ -14,6 +14,7 @@ use crate::stdio::{BoundedLines, run_stdio_loop};
use anyhow::Context as _;
use anyhow::{Result, anyhow};
use axum::http::{Method, StatusCode};
use codewhale_agent::ModelRegistry;
use codewhale_app_server_protocol::rpc::{
RequestId, RpcCall, RpcErrorDetails, RpcErrorResponse, RpcResponse,
};
@@ -22,6 +23,7 @@ use codewhale_app_server_protocol::runtime::MAX_RUNTIME_IMAGE_BODY_BYTES;
use codewhale_app_server_protocol::runtime::RuntimeImageInput;
use codewhale_config::ConfigStore;
use codewhale_core::Runtime;
use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink};
use codewhale_protocol::EventFrame;
use codewhale_state::StateStore;
use serde::Deserialize;
@@ -362,6 +364,7 @@ pub struct AppState {
/// Bookkeeping config and read-only historical archive access.
/// Actual turns and transcript writes belong to the captured owner.
runtime: Arc<RwLock<Runtime>>,
registry: ModelRegistry,
auth_token: Option<String>,
#[cfg(test)]
runtime_bridge: Arc<Mutex<Option<SharedRuntimeBridge>>>,
@@ -794,19 +797,46 @@ async fn build_state_off_runtime(
fn build_state_with_transport(
config_path: Option<PathBuf>,
auth_token: Option<String>,
_transport: AppTransport,
transport: AppTransport,
) -> Result<AppState> {
let has_explicit_config_path = config_path.is_some();
let store = ConfigStore::load(config_path)?;
let config_path = has_explicit_config_path.then(|| store.path().to_path_buf());
let config = store.config.clone();
let registry = ModelRegistry::default();
let state_db_path = config_path
.as_ref()
.and_then(|p| p.parent().map(|parent| parent.join("state.db")));
let state_store = StateStore::open(state_db_path)?;
let runtime = Runtime::new(config.clone(), state_store);
let mut hooks = HookDispatcher::default();
// Stdio carries JSON-RPC on stdout: printing raw hook events there
// corrupts the protocol stream (#5165). HTTP mode keeps the stdout
// sink for local development visibility.
if transport == AppTransport::Http {
hooks.add_sink(Arc::new(StdoutHookSink));
}
// The hook log sits beside the state database: next to an explicit
// config, otherwise in the directory the state store resolved under the
// Codewhale home. Never relative to the process cwd (#6513).
let hook_log_path = state_store
.db_path()
.parent()
.unwrap_or_else(|| Path::new("."))
.join("events.jsonl");
hooks.add_sink(Arc::new(JsonlHookSink::new(hook_log_path)));
if let Some(socket_path) = config
.hook_sinks
.as_ref()
.and_then(|sinks| sinks.unix_socket_path.as_ref())
.filter(|path| !path.as_os_str().is_empty())
{
hooks.add_sink(Arc::new(UnixSocketHookSink::new(socket_path.clone())));
}
let runtime = Runtime::new(config.clone(), state_store, hooks);
Ok(AppState {
captured_owner: None,
@@ -816,6 +846,7 @@ fn build_state_with_transport(
config_path,
config: Arc::new(RwLock::new(config)),
runtime: Arc::new(RwLock::new(runtime)),
registry,
auth_token,
#[cfg(test)]
runtime_bridge: Arc::new(Mutex::new(None)),
+5 -12
View File
@@ -111,14 +111,7 @@ pub(crate) async fn runtime_app_models(state: &AppState) -> Result<Vec<Value>> {
providers.len() <= 256,
"Runtime provider catalog exceeds its limit"
);
let aliases = codewhale_config::catalog::reviewed::bundled_reviewed()
.selections
.iter()
.filter_map(|row| {
codewhale_config::ProviderKind::parse_config_identity(&row.provider)
.map(|provider| (provider, row))
})
.collect::<Vec<_>>();
let aliases = state.registry.list();
let mut models = Vec::new();
let mut routes = std::collections::HashSet::new();
for provider in providers {
@@ -156,13 +149,13 @@ pub(crate) async fn runtime_app_models(state: &AppState) -> Result<Vec<Value>> {
.context("Runtime model identity is invalid")?;
let compatibility_aliases = aliases
.iter()
.find(|(provider, row)| {
.find(|entry| {
parsed != codewhale_config::ProviderKind::Custom
&& provider_id.is_none_or(|identity| identity == kind)
&& *provider == parsed
&& row.id == id
&& entry.provider == parsed
&& entry.id == id
})
.map(|(_, row)| row.aliases.clone())
.map(|entry| entry.aliases.clone())
.unwrap_or_default();
let capabilities: codewhale_config::route::RouteCapabilities =
match model.pointer("/facts/capabilities") {
+1 -1
View File
@@ -387,7 +387,7 @@ impl RuntimeWorkspaceScopes {
let owner = self.clone();
codewhale_runtime::runtime_control::owner_work(move || {
let (canonical, directory) =
codewhale_util::workspace_dir::open_pinned_workspace_directory(&lexical)?;
codewhale_util::workspace_dir::open_workspace_directory(&lexical)?;
let directory = Arc::new(directory);
let mut scopes = owner.scopes.lock();
if let Some(scope) = scopes.get(&lexical) {
@@ -429,7 +429,7 @@ pub(super) async fn get_plugin_index(
.clone()
.unwrap_or_else(|| format!("{} {}", hook.event, ordinal));
items.push(IndexItem {
id: format!("hook:{source}:{}:{ordinal}", hook.event),
id: format!("hook:{source}:{}:{name}", hook.event),
kind: "hook",
name,
description: None,
@@ -137,83 +137,3 @@ async fn the_index_lists_every_kind_once_and_a_plugin_carries_what_it_adds() ->
handle.abort();
Ok(())
}
#[tokio::test]
async fn the_index_keeps_same_name_hooks_distinct() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("hook-index-route");
let workspace = root.join("workspace");
let mut config = Config::default();
config.hooks = Some(codewhale_config::hooks::HooksConfig {
hooks: vec![
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo first",
)
.with_name("gate"),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo second",
)
.with_name("gate"),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo third",
),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo fourth",
)
.with_name("session_start 3"),
],
enabled: true,
..Default::default()
});
let (addr, _runtime_threads, handle) =
spawn_test_server_with_root_token_mobile_workspace_and_overrides(
root.clone(),
root.join("sessions"),
None,
false,
workspace.clone(),
TestServerOverrides {
config: Some(config),
plugin_discovery: Some(isolated_plugin_discovery(&root, &workspace)),
..TestServerOverrides::default()
},
)
.await?
.expect("hook index test server");
let client = codewhale_release::tls::reqwest_client();
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let hooks: Vec<_> = index["items"]
.as_array()
.expect("items")
.iter()
.filter(|item| item["kind"] == "hook")
.collect();
assert_eq!(hooks.len(), 4, "{index}");
for (ordinal, hook) in hooks.iter().enumerate() {
assert_eq!(
hook["id"],
format!("hook:user:session_start:{}", ordinal + 1)
);
assert_eq!(hook["state"], "enabled");
}
assert_eq!(hooks[0]["name"], "gate");
assert_eq!(hooks[1]["name"], "gate");
assert_eq!(hooks[2]["name"], "session_start 3");
assert_eq!(hooks[3]["name"], "session_start 3");
assert_eq!(hooks[3]["row"]["command"], "echo fourth");
assert_eq!(hooks[0]["row"]["command"], "echo first");
assert_eq!(hooks[1]["row"]["command"], "echo second");
handle.abort();
Ok(())
}
@@ -1202,15 +1202,15 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
{
let error = fs::rename(&selected_workspace, &retired_workspace)
.expect_err("a live Runtime owner must prevent workspace replacement");
assert_eq!(error.raw_os_error(), Some(32), "{error}");
assert_eq!(error.raw_os_error(), Some(5), "{error}");
assert!(!retired_workspace.exists());
}
manager.shutdown_and_wait().await?;
#[cfg(windows)]
{
let error = fs::rename(&selected_workspace, &retired_workspace)
.expect_err("Engine shutdown must retain the Runtime owner's pinned workspace");
assert_eq!(error.raw_os_error(), Some(32), "{error}");
.expect_err("Engine shutdown must retain the Runtime owner's workspace lock");
assert_eq!(error.raw_os_error(), Some(5), "{error}");
assert!(!retired_workspace.exists());
assert!(Arc::ptr_eq(
&scopes.admit(selected_workspace.clone()).await?,
+75
View File
@@ -367,6 +367,81 @@ fn build_state_keeps_resolved_explicit_config_path() {
);
}
#[tokio::test]
async fn stdio_transport_never_registers_the_stdout_hook_sink() {
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
fs::write(&config_path, "api_key = \"sk-deepseek-secret\"\n").expect("write config");
let http_state =
build_state_with_transport(Some(config_path.clone()), None, AppTransport::Http)
.expect("http state");
let stdio_state = build_state_with_transport(Some(config_path), None, AppTransport::Stdio)
.expect("stdio state");
let http_sinks = http_state.runtime.read().await.hooks.sink_count();
let stdio_sinks = stdio_state.runtime.read().await.hooks.sink_count();
assert_eq!(
http_sinks,
stdio_sinks + 1,
"HTTP mode keeps StdoutHookSink + JsonlHookSink; stdio must drop the stdout sink (#5165)"
);
}
// Points CODEWHALE_HOME at a temp dir for one test and restores it after.
// The variable is process-global, so these tests hold a lock.
static CODEWHALE_HOME_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
struct CodewhaleHomeGuard {
prior: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
impl CodewhaleHomeGuard {
fn set(value: &Path) -> Self {
let lock = CODEWHALE_HOME_TEST_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prior = std::env::var_os("CODEWHALE_HOME");
// SAFETY: serialised by CODEWHALE_HOME_TEST_LOCK.
unsafe { std::env::set_var("CODEWHALE_HOME", value) };
Self { prior, _lock: lock }
}
}
impl Drop for CodewhaleHomeGuard {
fn drop(&mut self) {
// SAFETY: serialised by CODEWHALE_HOME_TEST_LOCK.
unsafe {
match &self.prior {
Some(value) => std::env::set_var("CODEWHALE_HOME", value),
None => std::env::remove_var("CODEWHALE_HOME"),
}
}
}
}
#[tokio::test]
async fn hook_log_without_config_path_goes_beside_state_db_not_cwd() {
let home = tempfile::tempdir().expect("tempdir");
let _home = CodewhaleHomeGuard::set(home.path());
let state = build_state_with_transport(None, None, AppTransport::Stdio).expect("state");
state
.runtime
.read()
.await
.hooks
.emit(codewhale_hooks::HookEvent::ResponseStart {
response_id: "resp-6513".to_string(),
})
.await;
let log = fs::read_to_string(home.path().join("events.jsonl"))
.expect("hook log beside state.db in CODEWHALE_HOME");
assert!(log.contains("resp-6513"), "{log}");
}
async fn response_body_json(response: Response) -> Value {
let bytes = to_bytes(response.into_body(), usize::MAX)
.await
-1
View File
@@ -38,7 +38,6 @@ codewhale-workflow = { path = "../workflow", version = "0.10.3" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
codewhale-paths = { path = "../paths", version = "0.10.3" }
codewhale-release = { path = "../release", version = "0.10.3" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
codewhale-state = { path = "../state", version = "0.10.3" }
codewhale-telemetry = { path = "../telemetry", version = "0.10.3" }
+1 -1
View File
@@ -379,7 +379,7 @@ fn url_carries_credential(text: &str) -> bool {
// `***`; count masks rather than compare bytes, because it re-encodes the
// query and would otherwise flag benign URLs.
let masks = |value: &str| value.matches("***").count();
if masks(&codewhale_sanitize::sanitize::redact_url_for_display(text)) > masks(text) {
if masks(&codewhale_secrets::sanitize::redact_url_for_display(text)) > masks(text) {
return true;
}
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
-1
View File
@@ -25,7 +25,6 @@ semver.workspace = true
schemars = { version = "1.2.1", optional = true, features = ["derive", "preserve_order"] }
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
codewhale-paths = { path = "../paths", version = "0.10.3" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-secrets = { path = "../secrets", version = "0.10.3" }
fd-lock = "4.0.4"
libc = "0.2"
+2 -2
View File
@@ -300,7 +300,7 @@ pub(crate) fn remove_plaintext_api_keys_in_array(array: &mut toml_edit::Array) {
pub(crate) fn backup_value_carries_secret(value: &toml_edit::Value) -> bool {
value
.as_str()
.is_some_and(codewhale_sanitize::sanitize::contains_secret)
.is_some_and(codewhale_secrets::sanitize::contains_secret)
}
pub(crate) fn scrub_backup_decor(decor: &mut toml_edit::Decor) {
@@ -331,7 +331,7 @@ pub(crate) fn scrub_backup_comments(text: &str) -> String {
comment
.split_once('=')
.is_some_and(|(key, _)| is_sensitive_config_key(key))
|| codewhale_sanitize::sanitize::contains_secret(comment)
|| codewhale_secrets::sanitize::contains_secret(comment)
})
})
.collect()
+1 -1
View File
@@ -99,7 +99,7 @@ pub fn toml_error_location(source: Option<&str>, err: &toml::de::Error) -> Strin
.and_then(|rest| rest.strip_prefix("\nin `"))
.and_then(|rest| rest.strip_suffix("`\n"))
.filter(|keys| !keys.is_empty())
.map(|keys| format!("in `{}`", codewhale_sanitize::redact::redact_secrets(keys)));
.map(|keys| format!("in `{}`", codewhale_secrets::redact::redact_secrets(keys)));
match (position, key_path) {
(Some(position), Some(keys)) => format!("{position}, {keys}"),
(Some(position), None) => position,
+1 -1
View File
@@ -128,7 +128,7 @@ pub(crate) fn redact_secret(secret: &str) -> String {
pub fn is_sensitive_config_key(key: &str) -> bool {
key.rsplit('.')
.next()
.is_some_and(codewhale_sanitize::sanitize::is_sensitive_key_name)
.is_some_and(codewhale_secrets::sanitize::is_sensitive_key_name)
}
/// Resolve dotted paths without treating a dotted key as a top-level literal.
+7
View File
@@ -5,6 +5,13 @@ use std::path::PathBuf;
/// On-disk schema for the `[hook_sinks]` table.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct HookSinksToml {
/// Unix domain socket path used by the app-server event sink.
///
/// Known limitation: only `codewhale app-server` reads this table; the
/// TUI and the Runtime API do not dispatch hook events to it.
///
/// When unset, no Unix socket sink is registered. There is deliberately no
/// shared `/tmp` default because socket ownership should be explicit.
#[serde(default)]
pub unix_socket_path: Option<PathBuf>,
}
+2 -2
View File
@@ -314,10 +314,10 @@ fn rollback(snapshots: &[Snapshot]) {
}
// FEAT-025 D4: the pure secret-redaction primitives moved to
// `codewhale-sanitize::redact` so portable command helpers can share one
// `codewhale-secrets::redact` so portable command helpers can share one
// implementation without depending on this crate. Re-exported here to keep
// the existing `codewhale_config::persistence::*` public API stable.
pub use codewhale_sanitize::redact::{
pub use codewhale_secrets::redact::{
REDACTED, RedactionPolicy, redact_json_model_bound_secrets, redact_json_secrets,
redact_model_bound_secrets, redact_secrets, redact_secrets_with,
};
+2 -2
View File
@@ -59,7 +59,7 @@ pub fn check_config_toml_choice(key: &str, value: &str) -> Result<()> {
} else {
""
};
let value = codewhale_sanitize::redact::redact_secrets(value);
let value = codewhale_secrets::redact::redact_secrets(value);
bail!(
"invalid value '{value}' for '{key}': config.toml accepts {}.{settings_note} \
No value was changed.\nfix: codewhale config set {key} {}",
@@ -205,7 +205,7 @@ pub fn load_project_config_outcome(workspace: &Path) -> ProjectConfigOutcome {
// echoed; an ordinary typo stays readable.
Some(name) => format!(
"unknown provider '{}'",
codewhale_sanitize::redact::redact_secrets(&name)
codewhale_secrets::redact::redact_secrets(&name)
),
None => "unknown provider".to_string(),
},
+1 -7
View File
@@ -605,7 +605,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
def(
"show_thinking",
SettingKind::Bool(SHOW_THINKING),
"true",
"false",
ui(
TAB_APPEARANCE,
"display",
@@ -1546,12 +1546,6 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"false",
None,
),
def(
"account_preferences_notice_shown",
SettingKind::String,
"",
None,
),
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
def(
"work_surface_bottom_migrated",
+2
View File
@@ -14,6 +14,7 @@ workspace = true
serde.workspace = true
thiserror.workspace = true
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-hooks = { path = "../hooks", version = "0.10.3" }
codewhale-protocol = { path = "../protocol", version = "0.10.3" }
codewhale-state = { path = "../state", version = "0.10.3" }
regex = "1.11"
@@ -23,3 +24,4 @@ serde_json = { workspace = true, features = ["preserve_order"] }
[dev-dependencies]
tempfile.workspace = true
uuid.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "time"] }
+9
View File
@@ -0,0 +1,9 @@
//! `ThreadId` / `SessionId` for the `crates/core` boundary (issue #5261).
//!
//! Re-exports the protocol ids so every crate that depends on `core` (the
//! TUI, CLI, app-server) speaks the same typed ids without depending on
//! `protocol` directly. The persisted JSON shape stays a plain string
//! (`"thread-…"` / `"session-…"`) so existing `state.json` / `threads/`
//! files need no migration.
pub use codewhale_protocol::ids::{SessionId, ThreadId};
+78
View File
@@ -0,0 +1,78 @@
//! Session tree journal placeholder (issue #5262).
//!
//! The journal is append-only with an in-memory tree projection:
//! every non-header entry carries `id` + `parentId`, the active position is
//! a `leafId`, appending creates a child of the leaf, and branching only
//! moves the leaf — it never rewrites history. This file lands the entry
//! shape that #5262's tree operations hang off of; compaction and
//! branch-summary entry kinds are included as first-class kinds but their
//! *strategies* are deferred.
//!
//! Re-exports the protocol journal as the canonical shape so `protocol` and
//! `core` agree on the wire. `core` adds the `SessionJournal` wrapper that
//! owns the `current_leaf_id` column in `state.threads`.
pub use codewhale_protocol::journal::{Journal, JournalEntry};
use serde::{Deserialize, Serialize};
/// Persisted thread metadata extension for the tree. This is the
/// `current_leaf_id` column added to `state.threads`; `None` before the
/// first turn, `Some(id)` after. The existing `threads` JSON shape is
/// otherwise unchanged (back-compat: old rows read as `None` and the next
/// append mints the header leaf).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ThreadLeafState {
pub thread_id: String,
pub leaf_id: Option<String>,
}
/// First-class journal entry kinds (data shape lands now; strategies later).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JournalKind {
Header,
User,
Assistant,
ToolResult,
Compaction,
BranchSummary,
}
impl JournalKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Header => "header",
Self::User => "user",
Self::Assistant => "assistant",
Self::ToolResult => "tool_result",
Self::Compaction => "compaction",
Self::BranchSummary => "branch_summary",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn leaf_state_roundtrip() {
let s = ThreadLeafState {
thread_id: "thread-1".into(),
leaf_id: Some("entry-abc".into()),
};
let j = serde_json::to_string(&s).unwrap();
let back: ThreadLeafState = serde_json::from_str(&j).unwrap();
assert_eq!(back, s);
}
#[test]
fn journal_append_is_child_of_leaf() {
let mut j = Journal::new();
let a = j.append("header", json!({}));
let b = j.append("user", json!("hi"));
assert_eq!(j.get(&b).unwrap().parent_id.as_deref(), Some(a.as_str()));
}
}
+18 -5
View File
@@ -1,9 +1,12 @@
pub mod context_reference;
pub mod fragments;
pub mod ids;
pub mod journal;
pub mod prefix_cache;
pub mod request;
pub mod role;
pub mod secret_eq;
pub mod session;
pub mod tool_parser;
pub use context_reference::{
@@ -12,9 +15,10 @@ pub use context_reference::{
};
use codewhale_config::{ConfigToml, ProviderKind};
use codewhale_hooks::HookDispatcher;
use codewhale_state::StateStore;
/// Compatibility configuration.
/// Compatibility configuration and hooks.
///
/// Conversation execution and durable history belong to the held canonical
/// RuntimeThreadManager/Engine. Legacy State data is retained for read-only
@@ -22,11 +26,16 @@ use codewhale_state::StateStore;
pub struct Runtime {
pub config: ConfigToml,
state: StateStore,
pub hooks: HookDispatcher,
}
impl Runtime {
pub fn new(config: ConfigToml, state: StateStore) -> Self {
Self { config, state }
pub fn new(config: ConfigToml, state: StateStore, hooks: HookDispatcher) -> Self {
Self {
config,
state,
hooks,
}
}
/// Existing compatibility data and receipts; no transcript controller.
@@ -94,7 +103,7 @@ mod tests {
fn runtime_bookkeeping_keeps_the_legacy_store_read_only() {
let store = temp_core_state("read-only-runtime");
let before = store.list_threads(Default::default()).unwrap();
let mut runtime = Runtime::new(ConfigToml::default(), store);
let mut runtime = Runtime::new(ConfigToml::default(), store, HookDispatcher::default());
runtime.update_config(ConfigToml::default());
assert_eq!(
runtime
@@ -104,7 +113,11 @@ mod tests {
.len(),
before.len()
);
let reopened = Runtime::new(runtime.config.clone(), runtime.state_store().clone());
let reopened = Runtime::new(
runtime.config.clone(),
runtime.state_store().clone(),
HookDispatcher::default(),
);
assert!(
reopened
.state_store()
+135
View File
@@ -0,0 +1,135 @@
//! `Thread` / `Session` split (issue #5261).
//!
//! `codewhale`'s `Session` was really a thread. The new split is:
//! - `Thread` — durable, persisted, owns the append-only `Journal` and the
//! `leafId` cursor. One row in `state.threads`, one directory on disk.
//! - `Session` — ephemeral, per-turn / per-engine-lifetime, owns the
//! in-memory `TurnContext` plus the live approval/sandbox posture for this
//! `SessionId`. Many sessions can attach to one thread over time, but only
//! one `Session` drives a turn for a given `ThreadId` at a time.
//!
//! The canonical RuntimeThreadManager creates sessions without a TUI and drives
//! the same Engine turn loop used by mounted clients. These types preserve the
//! shared model, append log, prefix stability and message revision state; the
//! compatibility Core Runtime owns only configuration, hooks and job bookkeeping.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::ids::{SessionId, ThreadId};
use crate::journal::Journal;
/// Durable thread (the former `Session`). One per conversation, persisted in
/// `state.threads`. The only new field vs the old `Session` is `leaf_id` — the
/// journal cursor — plus the typed `ThreadId`. All other fields keep their
/// persisted JSON shape unchanged.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
pub thread_id: ThreadId,
/// Active branch tip. `None` before the first journal header.
#[serde(skip_serializing_if = "Option::is_none")]
pub leaf_id: Option<String>,
/// Journal (append-only). In-memory projection of the persisted
/// `threads/turns/items/events` layout is derived root→leaf.
#[serde(default)]
pub journal: Journal,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<String>,
pub workspace: PathBuf,
#[serde(default)]
pub ephemeral: bool,
}
impl Thread {
#[must_use]
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
Self {
thread_id,
leaf_id: None,
journal: Journal::new(),
model: model.into(),
reasoning_effort: None,
workspace,
ephemeral: false,
}
}
#[must_use]
pub fn leaf_id(&self) -> Option<&str> {
self.leaf_id.as_deref()
}
pub fn set_leaf(&mut self, leaf: Option<String>) {
self.leaf_id = leaf;
}
}
/// Ephemeral session within a thread (one engine lifetime / one turn's
/// live posture). The TUI's `EngineHandle` and the headless `exec` both
/// hold a `Session` that points at the same `ThreadId` but with different
/// `SessionId`s.
#[derive(Debug, Clone)]
pub struct Session {
pub session_id: SessionId,
pub thread_id: ThreadId,
/// Model for this session's next turn (may differ from thread default).
pub model: String,
pub workspace: PathBuf,
/// Monotonic `messages_revision` for prefix-cache memoization (carried
/// from the former `Session::messages_revision`).
pub messages_revision: u64,
}
impl Session {
#[must_use]
pub fn new(thread_id: ThreadId, workspace: PathBuf, model: impl Into<String>) -> Self {
Self {
session_id: SessionId::new(),
thread_id,
model: model.into(),
workspace,
messages_revision: 0,
}
}
pub fn bump_revision(&mut self) {
self.messages_revision = self.messages_revision.wrapping_add(1);
}
}
/// Split helper: derive a `Session` from an existing `Thread` without
/// cloning the journal. Headless and TUI call the same constructor so
/// the request shape stays identical.
#[must_use]
pub fn session_for_thread(thread: &Thread, workspace: PathBuf) -> Session {
Session::new(thread.thread_id.clone(), workspace, thread.model.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thread_and_session_ids_are_distinct_scopes() {
let t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "deepseek-v4-flash");
let s1 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
let s2 = Session::new(t.thread_id.clone(), PathBuf::from("/tmp"), &t.model);
assert_eq!(s1.thread_id, s2.thread_id);
assert_ne!(s1.session_id, s2.session_id);
}
#[test]
fn leaf_is_moved_not_rewritten() {
let mut t = Thread::new(ThreadId::new(), PathBuf::from("/tmp"), "m");
let a = t.journal.append("header", serde_json::json!({}));
let b = t.journal.append("user", serde_json::json!("b"));
t.leaf_id = t.journal.leaf_id.clone();
assert_eq!(t.leaf_id.as_deref(), Some(b.as_str()));
assert!(t.journal.branch_to(&a));
t.leaf_id = t.journal.leaf_id.clone();
assert_eq!(t.leaf_id.as_deref(), Some(a.as_str()));
assert_eq!(t.journal.len(), 2); // history never rewritten; branching only moved the leaf
}
}
+9 -1
View File
@@ -79,6 +79,7 @@ codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
codewhale-memory = { path = "../memory", version = "0.10.3" }
anyhow.workspace = true
codewhale-agent = { path = "../agent", version = "0.10.3" }
codewhale-agent-contract = { path = "../agent-contract", version = "0.10.3" }
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-command-contract = { path = "../command-contract", version = "0.10.3" }
@@ -171,7 +172,6 @@ sha2.workspace = true
# Reuse the locked cryptographic implementation for exact-call user decisions.
ring = "0.17"
codewhale-localization = { path = "../localization", version = "0.10.3" }
codewhale-ratatui = { path = "../../vendor/codewhale-ratatui", version = "0.2.0" }
shell-words = "1.1.1"
wiremock = { version = "0.6", optional = true }
@@ -203,9 +203,14 @@ codewhale-util = { path = "../util", version = "0.10.3", features = ["test-suppo
codewhale-models = { path = "../models", version = "0.10.3", features = [
"test-support",
] }
cucumber = "0.23.0"
wiremock = "0.6"
tiny_http = "0.12"
pretty_assertions = "1.4"
rio-vt = "0.5.1"
rusqlite.workspace = true
schemars = { version = "1.2.1", features = ["derive", "preserve_order"] }
tokio-tungstenite = "0.30"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@@ -214,6 +219,9 @@ libc = "0.2"
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemServices", "Win32_System_WindowsProgramming"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies]
arboard = { version = "3.4", features = ["wayland-data-control"] }
[target.'cfg(not(target_env = "ohos"))'.dependencies]
portable-pty = "0.9"
+1 -1
View File
@@ -18,7 +18,7 @@ var ErrorCode = {
Denied: -32003,
Cancelled: -32800
};
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote", "plugins"];
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"] },
@@ -25,7 +25,7 @@ export const ErrorCode = {
export type Direction = 'core_to_host' | 'host_to_core'
export const DSH_SERVICES = ['workspace', 'llm', 'network', 'settings', 'catalog', 'terminal', 'remote', 'plugins'] as const
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. */
+1 -1
View File
@@ -56,7 +56,7 @@ use codewhale_models::{
ContentBlock, Message, MessageRequest, MessageResponse, SystemPrompt, Usage,
};
use codewhale_runtime::logging;
use codewhale_sanitize::sanitize::redact_url_for_display;
use codewhale_secrets::sanitize::redact_url_for_display;
/// Every provider request that can feed the interactive TUI's attached CWC run
/// takes a shared permit at this lowest common dispatch seam. Runtime Chat holds
+1 -1
View File
@@ -115,7 +115,7 @@ impl RouteShape {
/// Which endpoint this request would be POSTed to, as typed facts.
///
/// `url` is the real, unredacted target: production needs it to send. Every
/// display surface must go through [`codewhale_sanitize::sanitize::redact_url_for_display`] rather
/// display surface must go through [`codewhale_secrets::sanitize::redact_url_for_display`] rather
/// than printing it, and the manifest only ever publishes the redacted
/// scheme/host and a fingerprint — never the path, which can itself carry a
/// deployment secret.
@@ -1130,7 +1130,7 @@ pub fn build_reviewer_context(
held_reason: &str,
tool_input: &Value,
) -> String {
use codewhale_sanitize::redact::{redact_json_model_bound_secrets, redact_model_bound_secrets};
use codewhale_secrets::redact::{redact_json_model_bound_secrets, redact_model_bound_secrets};
let mut input = redact_json_model_bound_secrets(tool_input);
let tool = redact_model_bound_secrets(ctx.tool_name.as_ref());
@@ -2152,7 +2152,10 @@ fn git_push_refspec_is_protected(refspec: &str) -> bool {
// Tag-like names (`v1`, `v0.9.1`): git resolves branch-vs-tag on the
// server, so treat them as publishes.
let mut chars = lower.chars();
chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit())
if chars.next() == Some('v') && chars.next().is_some_and(|ch| ch.is_ascii_digit()) {
return true;
}
false
}
fn git_tag_tokens_are_publish_like(tokens: &[&str]) -> bool {
+2 -2
View File
@@ -314,8 +314,8 @@ pub struct EngineConfig {
pub session_id: Option<String>,
/// Optional host-owned root for delegated-agent runtime state.
///
/// When unset, the worker ledger and complete transcript artifacts
/// retain their historical location under
/// When unset, the worker ledger, complete transcript artifacts and
/// coordination lock retain their historical location under
/// `workspace/.codewhale/state`. Embedders may set a session-scoped root
/// to separate that control-plane state from the execution workspace.
/// Child cwd and file authority still derive from `workspace`; hosts using
+9 -220
View File
@@ -2640,8 +2640,6 @@ mod tests {
enum Posture {
Ask,
FullAccess,
AutoReview,
Plan,
}
struct ExtensionTurn {
@@ -2657,44 +2655,18 @@ mod tests {
/// A turn whose model calls the extension tool (id `ext-1`) with `input`,
/// over the file, shell and web tools.
fn start_extension_turn(input: Value, posture: Posture) -> ExtensionTurn {
start_extension_turn_with(input, posture, Vec::new())
}
fn start_extension_turn_with(
input: Value,
posture: Posture,
reviewer: Vec<crate::llm_client::mock::CannedTurn>,
) -> ExtensionTurn {
start_extension_turn_hooked(input, posture, reviewer, None)
}
fn start_extension_turn_hooked(
input: Value,
posture: Posture,
reviewer: Vec<crate::llm_client::mock::CannedTurn>,
hook_executor: Option<Arc<crate::hooks::HookExecutor>>,
) -> ExtensionTurn {
let tmp = tempfile::tempdir().expect("fixture directory");
std::fs::write(tmp.path().join("a.txt"), "alpha").expect("fixture file");
let mock = Arc::new(MockLlmClient::new(
std::iter::once(canned::tool_call_turn(
"ext-1",
FAKE_EXT,
&input.to_string(),
))
.chain(reviewer)
.chain(std::iter::once(canned::simple_text_turn(
"Extension finished.",
)))
.collect(),
));
let mock = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn("ext-1", FAKE_EXT, &input.to_string()),
canned::simple_text_turn("Extension finished."),
]));
let (mut engine, handle) = Engine::new_with_model_client(
EngineConfig {
workspace: tmp.path().to_path_buf(),
snapshots_enabled: false,
subagents_enabled: false,
terminal_chrome_enabled: false,
hook_executor,
..EngineConfig::default()
},
&Config::default(),
@@ -2702,10 +2674,10 @@ mod tests {
);
let full = posture == Posture::FullAccess;
engine.session.auto_approve = full;
engine.session.approval_mode = match posture {
Posture::FullAccess => ApprovalMode::Bypass,
Posture::AutoReview => ApprovalMode::Auto,
Posture::Ask | Posture::Plan => ApprovalMode::Suggest,
engine.session.approval_mode = if full {
ApprovalMode::Bypass
} else {
ApprovalMode::Suggest
};
engine.session.mcp_config_path = tmp.path().join("mcp.json");
engine.session.add_message(Message {
@@ -2729,18 +2701,11 @@ mod tests {
registry.register(Arc::new(FakeExtensionTool {
withdraw: withdraw.clone(),
}));
for gate in crate::extension_host::dsh_broker::BrokerActTool::all() {
registry.register(gate);
}
let catalog = registry.to_api_tools_with_cache(true);
let surface = ToolSurfacePolicy::new(
registry,
Some(catalog),
if posture == Posture::Plan {
AppMode::Plan
} else {
AppMode::Agent
},
AppMode::Agent,
&engine.config.tools_always_load,
&[],
false,
@@ -3174,180 +3139,4 @@ mod tests {
vec![ApprovalOutcome::Cancelled]
);
}
fn broker_act(effect: &str) -> Value {
json!({
"plugin": "fake",
"service": "remote",
"method": "pair",
"effect": effect,
})
}
fn broker_calls(acts: &[&Value]) -> Value {
let calls: Vec<Value> = acts
.iter()
.map(|act| {
let name = if act["effect"] == "reach" {
crate::extension_host::dsh_broker::REACH_TOOL
} else {
crate::extension_host::dsh_broker::ACT_TOOL
};
json!({"name": name, "input": act})
})
.collect();
json!({ "calls": calls })
}
fn approvals(seen: &[Event]) -> usize {
seen.iter()
.filter(|event| matches!(event, Event::ApprovalRequired { .. }))
.count()
}
#[tokio::test]
async fn a_broker_act_asks_in_ask_and_proceeds_in_full_access() {
let act = broker_act("reach");
let _open = crate::extension_host::dsh_broker::OpenAct::open(&act, 0).expect("open act");
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::Ask);
let events = turn.events.clone();
let mut seen = Vec::new();
let event = next_approval_event(&events, &mut seen).await;
let (id, tool, description, _, _, force) = approval_fields(&event);
assert_eq!(tool, crate::extension_host::dsh_broker::REACH_TOOL);
assert!(!force, "the mode decides; no extension force-prompt");
assert!(
description.contains("plugin `fake`") && description.contains("remote"),
"{description}"
);
turn.handle.deny_tool_call(id).await.expect("deny");
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["rejected"], "Denied", "{answer}");
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::FullAccess);
let mut seen = Vec::new();
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["ok"], true, "{answer}");
assert_eq!(approvals(&seen), 0, "Full Access opens no card");
}
#[tokio::test]
async fn auto_review_reviewer_no_refuses_a_broker_act() {
let act = broker_act("write");
let _open = crate::extension_host::dsh_broker::OpenAct::open(&act, 0).expect("open act");
let mut turn = start_extension_turn_with(
broker_calls(&[&act]),
Posture::AutoReview,
vec![
canned::simple_text_turn(
r#"{"risk_level":"low","decision":"allow","reason":"the person asked to run the extension"}"#,
),
canned::simple_text_turn(
r#"{"risk_level":"high","decision":"deny","reason":"not what the person asked for"}"#,
),
],
);
let mut seen = Vec::new();
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
assert_eq!(approvals(&seen), 0, "the reviewer, not a person, decided");
}
#[tokio::test]
async fn plan_refuses_a_broker_act_that_writes_or_reaches_out() {
let reach = broker_act("reach");
let write = broker_act("write");
let read = broker_act("read");
let _open: Vec<_> = [&reach, &write, &read]
.into_iter()
.map(|act| crate::extension_host::dsh_broker::OpenAct::open(act, 0).expect("open act"))
.collect();
let mut turn = start_extension_turn(broker_calls(&[&reach, &write, &read]), Posture::Plan);
let mut seen = Vec::new();
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
assert_eq!(answer["results"][1]["rejected"], "Refused", "{answer}");
assert_eq!(answer["results"][2]["ok"], true, "{answer}");
assert_eq!(approvals(&seen), 0);
}
#[tokio::test]
async fn a_broker_act_nobody_opened_is_refused_before_any_card() {
let act =
json!({"plugin": "fake", "service": "network", "method": "fetch", "effect": "reach"});
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::Ask);
let mut seen = Vec::new();
let answer = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
assert_eq!(approvals(&seen), 0);
}
#[cfg(unix)]
#[tokio::test]
async fn a_hook_rewrite_cannot_make_the_gate_answer_for_another_act() {
use crate::extension_host::dsh_broker::{OpenAct, REACH_TOOL, answered_for};
let act = |target: &str| {
json!({
"plugin": "fake",
"service": "network",
"method": "fetch",
"effect": "reach",
"target": target,
})
};
let shown = act("api.github.com");
let asked = act("evil.example");
let hooks = tempfile::tempdir().expect("hook directory");
let hook = codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::ToolCallBefore,
&format!("printf '%s' '{}'", json!({ "updatedInput": shown })),
)
.with_condition(codewhale_config::hooks::HookCondition::ToolName {
name: REACH_TOOL.to_string(),
});
let executor = Arc::new(crate::hooks::HookExecutor::new(
codewhale_config::hooks::HooksConfig {
enabled: true,
hooks: vec![hook],
..codewhale_config::hooks::HooksConfig::default()
},
hooks.path().to_path_buf(),
));
let _asked_open = OpenAct::open(&asked, 0).expect("open the asked act");
let mut turn = start_extension_turn_hooked(
broker_calls(&[&asked]),
Posture::FullAccess,
Vec::new(),
Some(executor.clone()),
);
let mut seen = Vec::new();
let control = finish_extension_turn(&mut turn, &mut seen).await;
assert_eq!(control["results"][0]["rejected"], "Refused", "{control}");
let _shown_open = OpenAct::open(&shown, 1).expect("open the shown act");
let mut turn = start_extension_turn_hooked(
broker_calls(&[&asked]),
Posture::Ask,
Vec::new(),
Some(executor),
);
let events = turn.events.clone();
let mut seen = Vec::new();
let event = next_approval_event(&events, &mut seen).await;
let (id, _tool, description, _key, _, _force) = approval_fields(&event);
assert!(description.contains("api.github.com"), "{description}");
turn.handle.approve_tool_call(id).await.expect("approve");
let answer = finish_extension_turn(&mut turn, &mut seen).await;
let result = &answer["results"][0]["result"];
assert!(
answered_for(&shown, result),
"the gate names the act it decided: {answer}"
);
assert!(
!answered_for(&asked, result),
"the yes is not for the act that asked: {answer}"
);
}
}
@@ -30,13 +30,18 @@ use std::hash::{Hash, Hasher};
use crate::compaction::estimate_input_tokens_for_pressure;
use codewhale_models::{Message, SystemPrompt};
/// Default capacity for the rolling audit ring. Sized so a 64-entry window
/// covers a full capacity controller observation cycle without unbounded
/// growth on long-running sessions.
const AUDIT_RING_CAPACITY: usize = 64;
/// Process-local memoization for `estimate_input_tokens_for_pressure`.
///
/// The cache is keyed on the `(messages_revision, system_fingerprint)`
/// pair, both of which the engine bumps on every content change. On a hit
/// the previously stored token estimate is returned without re-walking the
/// message list. On a miss, the estimator runs and the result is stored
/// for the next lookup.
/// alongside the audit ring entry.
#[derive(Debug, Default, Clone)]
pub struct TokenEstimateCache {
/// Monotonic counter bumped by the engine on every message mutation.
@@ -46,6 +51,10 @@ pub struct TokenEstimateCache {
system_fingerprint: u64,
/// Cached token count, valid iff both keys match the current inputs.
cached_tokens: Option<usize>,
/// Audit ring of recent (revision, tokens) pairs. The most recent entry
/// is the tail; the oldest is dropped when capacity is exceeded. Used by
/// observability to surface cache effectiveness to `/status`.
audit_ring: Vec<(u64, usize)>,
/// Number of cache hits since the cache was last cleared. Saturates at
/// `u64::MAX` (effectively never in practice).
hits: u64,
@@ -88,6 +97,7 @@ impl TokenEstimateCache {
self.system_fingerprint = system_fingerprint;
self.cached_tokens = Some(tokens);
self.misses = self.misses.saturating_add(1);
self.push_audit(messages_revision, tokens);
tokens
}
@@ -105,6 +115,7 @@ impl TokenEstimateCache {
pub fn invalidate(&mut self) {
self.cached_tokens = None;
self.system_fingerprint = 0;
self.audit_ring.clear();
self.hits = 0;
self.misses = 0;
}
@@ -114,6 +125,20 @@ impl TokenEstimateCache {
pub fn stats(&self) -> (u64, u64) {
(self.hits, self.misses)
}
/// Returns the most recent `(revision, tokens)` audit entries, newest
/// first. Bounded by [`AUDIT_RING_CAPACITY`].
#[must_use]
pub fn recent_audit(&self) -> &[(u64, usize)] {
&self.audit_ring
}
fn push_audit(&mut self, revision: u64, tokens: usize) {
if self.audit_ring.len() >= AUDIT_RING_CAPACITY {
self.audit_ring.remove(0);
}
self.audit_ring.push((revision, tokens));
}
}
/// Stable 64-bit hash of the system prompt text. Walks the same shape the
@@ -263,4 +288,29 @@ mod tests {
assert_eq!(hits, 0);
assert_eq!(misses, 2);
}
#[test]
fn audit_ring_records_recent_pairs() {
let mut cache = TokenEstimateCache::new();
let messages = vec![user_text("hi")];
for rev in 1..=5 {
let _ = cache.lookup_or_compute(rev, None, &messages);
}
let ring = cache.recent_audit();
assert_eq!(ring.len(), 5);
assert_eq!(ring.last().copied(), Some((5, ring.last().unwrap().1)));
}
#[test]
fn audit_ring_bounded_by_capacity() {
let mut cache = TokenEstimateCache::new();
let messages = vec![user_text("hi")];
for rev in 1..=(AUDIT_RING_CAPACITY + 10) as u64 {
let _ = cache.lookup_or_compute(rev, None, &messages);
}
let ring = cache.recent_audit();
assert_eq!(ring.len(), AUDIT_RING_CAPACITY);
// newest entry should be the most recent revision we asked for
assert_eq!(ring.last().unwrap().0, (AUDIT_RING_CAPACITY + 10) as u64);
}
}
@@ -110,7 +110,6 @@ impl Engine {
// the ToolContext carries ShellPolicy::None.
.with_file_tools()
.with_present_tool()
.with_show_data_tool()
// Foreground-only shell registration: never add terminal/*
// lifecycle tools to Plan merely to keep the `bash` identity
// stable across modes.
+1 -3
View File
@@ -5968,9 +5968,7 @@ fn mode_blocks_write_capable_tool(
&& (matches!(
canonical_action_alias(tool_name, input),
"write_file" | "edit_file" | "apply_patch"
) || ((McpPool::is_mcp_tool(tool_name)
|| crate::extension_host::dsh_broker::is_gate_tool(tool_name))
&& !read_only))
) || (McpPool::is_mcp_tool(tool_name) && !read_only))
}
/// Synthesize the tool result recorded for a tool call that never executed
+2 -2
View File
@@ -301,7 +301,7 @@ fn mcp_snapshot_to_wire(snapshot: &McpManagerSnapshot) -> wire::McpManagerSnapsh
/// `EventMsg::McpSessionBoot`, which every SSE and stream-JSON consumer
/// receives and any frame-retaining log keeps.
///
/// URLs reuse the existing masking in `codewhale_sanitize::sanitize::redact_url_for_display`. Stdio
/// URLs reuse the existing masking in `codewhale_secrets::sanitize::redact_url_for_display`. Stdio
/// keeps the program name and elides the arguments, because a secret there can
/// be positional and is not reliably recognizable by key.
fn redacted_command_or_url(raw: &str) -> String {
@@ -315,7 +315,7 @@ fn redacted_command_or_url(raw: &str) -> String {
// mentions a URL — `docker run -e TOKEN=… img --url https://…` — would
// reach the wire with its positional secret intact.
if !trimmed.is_empty() && !trimmed.contains(char::is_whitespace) && trimmed.contains("://") {
return codewhale_sanitize::sanitize::redact_url_for_display(trimmed);
return codewhale_secrets::sanitize::redact_url_for_display(trimmed);
}
if let Some((program, rest)) = trimmed.split_once(char::is_whitespace)
&& !rest.trim().is_empty()
+1 -1
View File
@@ -204,7 +204,7 @@ pub enum CommandOutcome {
fn strip_escapes(text: &str) -> String {
let mut clean = String::with_capacity(text.len());
codewhale_sanitize::sanitize::strip_ansi_into(text, &mut clean);
codewhale_secrets::sanitize::strip_ansi_into(text, &mut clean);
clean
}
+15 -314
View File
@@ -42,9 +42,6 @@
//! network calls force a prompt: a session grant is not consulted, and
//! Full Access still opens their card. Auto-Review and Never refuse them
//! (`resolve_approval_request_disposition`); explicit denials always win.
//! The broker's own gate tools ([`dsh_broker::GATE_TOOLS`]) are the exception:
//! the permission mode alone decides them, and only a broker arm reaches them
//! (a plugin's own `core/call` of one is refused by name).
//!
//! **Caps.** Per invocation: 50 core tool calls in all, counted together
//! across `core/call` and the nested calls of broker arms, 4 at
@@ -80,7 +77,7 @@ use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
use super::ManagerShared;
use super::dsh_broker::{self, Arm, BrokerCall, BrokerEffect, BrokerRefusal, OpenAct};
use super::dsh_broker::{self, Arm, BrokerCall, BrokerRefusal};
use super::protocol::{
ContentBlockWire, CoreCallParams, DshBrokerParams, DshBrokerTicketWire, DshService,
RpcErrorWire, SessionCheckpointParams, ToolResultWire, UserQuestionParams, error_code,
@@ -109,7 +106,6 @@ const TICKET_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const GRANT_PROBE: Duration = Duration::from_millis(50);
/// Receipts attached to an extension tool's result metadata.
pub const RECEIPTS_ATTACHED: usize = 50;
pub const MAX_ACT_RECORD_BYTES: usize = 1024;
/// Tools an extension may never reach through `core/call`, by lower-case
/// name, checked on the spelling the host sent and on the canonical action
@@ -208,8 +204,7 @@ pub fn refusal(specs: &[Arc<dyn ToolSpec>], name: &str, input: &Value) -> Option
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OriginApproval {
/// Planning's decision stands (the tool is in [`EXT_AUTO_ELIGIBLE`] and
/// nothing asked for approval, or it is one of the broker's gate tools,
/// which the permission mode alone decides).
/// nothing asked for approval).
Unchanged,
/// Needs approval; an extension-scoped session grant may satisfy it.
Prompt,
@@ -231,9 +226,6 @@ pub fn origin_approval(
let lower = name.to_ascii_lowercase();
let resolved = canonical(name, input);
if dsh_broker::is_gate_tool(&lower) && spec.is_some_and(dsh_broker::is_gate_spec) {
return OriginApproval::Unchanged;
}
let forced = |candidate: &str| {
matches!(
get_tool_category_for_call(candidate, input),
@@ -277,11 +269,8 @@ pub struct Invocation {
root_caller: Option<SessionCheckpointCaller>,
broker: Option<BrokerScope>,
nested_calls: AtomicU32,
broker_acts: Mutex<(usize, Vec<Value>, bool)>,
tickets: Vec<Ticket>,
caller_tools: Vec<String>,
gates: Vec<&'static str>,
act_slots: tokio::sync::Semaphore,
activations: Mutex<
HashMap<
String,
@@ -320,145 +309,17 @@ impl BrokerContext {
&self.cancel
}
pub fn record(&self, entry: Value) -> bool {
let mut entry = match entry {
Value::Object(fields) => fields,
other => {
let mut fields = serde_json::Map::new();
fields.insert("detail".to_string(), other);
fields
}
};
entry.insert(
"service".to_string(),
Value::String(self.service.name().to_string()),
);
let mut entry = Value::Object(entry);
if entry.to_string().len() > MAX_ACT_RECORD_BYTES {
let cut = |key: &str| {
entry
.get(key)
.and_then(Value::as_str)
.map_or(Value::Null, |text| {
Value::String(text.chars().take(dsh_broker::MAX_ACT_TARGET).collect())
})
};
entry = json!({
"service": self.service.name(),
"method": cut("method"),
"outcome": cut("outcome"),
"cut": true,
});
}
let mut acts = self
.invocation
.broker_acts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if acts.2 {
return false;
}
acts.0 += 1;
if acts.1.len() < RECEIPTS_ATTACHED {
acts.1.push(entry);
}
true
}
pub async fn act(
pub async fn core_call(
&self,
method: &str,
effect: BrokerEffect,
target: Option<&str>,
) -> Result<(), BrokerRefusal> {
let target: Option<String> =
target.map(|target| target.chars().take(dsh_broker::MAX_ACT_TARGET).collect());
let record = |outcome: &str| -> bool {
let mut entry =
json!({ "method": method, "effect": effect.name(), "outcome": outcome });
if let Some(target) = &target {
entry["target"] = json!(target);
}
self.record(entry)
};
name: &str,
input: Value,
) -> Result<ToolCallResponse, BrokerRefusal> {
if self.cancel.is_cancelled() {
return Err(self.withdrawn());
return Err(BrokerRefusal::nested(
self.service,
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
));
}
if effect == BrokerEffect::Read {
if let Err(refusal) = self.count() {
record(refusal.code());
return Err(refusal);
}
if !record("read") {
return Err(self.withdrawn());
}
return Ok(());
}
let gate_tool = effect.gate_tool();
if !self.invocation.gates.contains(&gate_tool) {
record("refused");
return Err(BrokerRefusal::Refused {
service: self.service,
message: "the permission gate is not available in this turn".to_string(),
});
}
let slot = tokio::select! {
slot = self.invocation.act_slots.acquire() => slot.ok(),
() = self.cancel.cancelled() => None,
() = self.invocation.cancel.cancelled() => None,
};
if slot.is_none() {
return Err(self.withdrawn());
}
if let Err(refusal) = self.count() {
record(refusal.code());
return Err(refusal);
}
let mut input = json!({
"plugin": self.invocation.owner.plugin_id,
"service": self.service.name(),
"method": method,
"effect": effect.name(),
});
if let Some(target) = &target {
input["target"] = json!(target);
}
let Some(_open) = OpenAct::open(&input, Arc::as_ptr(&self.invocation) as usize) else {
record("refused");
return Err(BrokerRefusal::Refused {
service: self.service,
message: "too many broker acts are waiting on the permission gate".to_string(),
});
};
match self.call_counted(gate_tool, input.clone()).await {
Ok(response) if response.ok && dsh_broker::answered_for(&input, &response.result) => {
if !record("allowed") {
return Err(self.withdrawn());
}
Ok(())
}
Ok(_) => {
record("refused");
Err(BrokerRefusal::Refused {
service: self.service,
message: "the permission gate refused this act".to_string(),
})
}
Err(refusal) => {
record(refusal.code());
Err(refusal)
}
}
}
fn withdrawn(&self) -> BrokerRefusal {
BrokerRefusal::nested(
self.service,
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
)
}
fn count(&self) -> Result<(), BrokerRefusal> {
if self.invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
return Err(BrokerRefusal::nested(
self.service,
@@ -470,26 +331,6 @@ impl BrokerContext {
},
));
}
Ok(())
}
pub async fn core_call(
&self,
name: &str,
input: Value,
) -> Result<ToolCallResponse, BrokerRefusal> {
if self.cancel.is_cancelled() {
return Err(self.withdrawn());
}
self.count()?;
self.call_counted(name, input).await
}
async fn call_counted(
&self,
name: &str,
input: Value,
) -> Result<ToolCallResponse, BrokerRefusal> {
self.invocation
.calls
.call(name.to_string(), input, Some(&self.cancel))
@@ -504,7 +345,7 @@ impl BrokerContext {
let tool = ToolContext::new(std::path::PathBuf::from("."));
let gate = NestedCallGate::admitting_for_test();
let calls = CodemodeInvoker::for_extension(
dsh_broker::BrokerActTool::all().to_vec(),
Vec::new(),
tool.clone(),
gate.clone(),
String::new(),
@@ -522,11 +363,8 @@ impl BrokerContext {
root_caller: None,
broker: None,
nested_calls: AtomicU32::new(0),
broker_acts: Mutex::new((0, Vec::new(), false)),
tickets: Vec::new(),
caller_tools: Vec::new(),
gates: dsh_broker::GATE_TOOLS.to_vec(),
act_slots: tokio::sync::Semaphore::new(dsh_broker::MAX_OPEN_ACTS_PER_INVOCATION),
activations: Mutex::new(HashMap::new()),
session_call_lock: tokio::sync::Mutex::new(()),
cancel: CancellationToken::new(),
@@ -673,16 +511,12 @@ impl CoreCalls {
.as_ref()
.map(|session| session.session_id.clone())
.filter(|id| !id.is_empty());
let root_agent = context.execution.owner_agent_id.is_none();
let broker_tickets: Vec<(DshService, Ticket)> = broker_session
.iter()
.flat_map(|session| {
DshService::EVERY
DshService::ALL
.into_iter()
.filter(|service| {
broker_services.contains(service)
&& dsh_broker::mintable(tier, owner, *service, root_agent)
})
.filter(|service| tier == HostTier::Plugin && broker_services.contains(service))
.map(move |service| {
(
service,
@@ -710,19 +544,9 @@ impl CoreCalls {
context: Arc::new(context),
}
});
let gates: Vec<&'static str> = dsh_broker::GATE_TOOLS
.into_iter()
.filter(|gate| {
specs.iter().any(|spec| {
spec.name().eq_ignore_ascii_case(gate)
&& dsh_broker::is_gate_spec(spec.as_ref())
})
})
.collect();
let caller_tools = specs
.iter()
.map(|spec| spec.name().to_string())
.filter(|name| !dsh_broker::is_gate_tool(name))
.filter(|name| {
!context
.disallowed_tools
@@ -742,7 +566,6 @@ impl CoreCalls {
root_caller,
broker,
nested_calls: AtomicU32::new(0),
broker_acts: Mutex::new((0, Vec::new(), false)),
tickets: std::iter::once(ticket.clone())
.chain(checkpoint_ticket.iter().cloned())
.chain(question_ticket.iter().cloned())
@@ -750,8 +573,6 @@ impl CoreCalls {
.chain(broker_tickets.iter().map(|(_, ticket)| ticket.clone()))
.collect(),
caller_tools,
gates,
act_slots: tokio::sync::Semaphore::new(dsh_broker::MAX_OPEN_ACTS_PER_INVOCATION),
activations: Mutex::new(HashMap::new()),
session_call_lock: tokio::sync::Mutex::new(()),
cancel: CancellationToken::new(),
@@ -1478,9 +1299,6 @@ impl CoreCalls {
.as_ref()
.map(|scope| (scope.session_id.clone(), Arc::clone(&scope.context)))
.ok_or_else(|| refused("dsh/broker requires an invocation in a session".into()))?;
if service.builtin_only() && tool.execution.owner_agent_id.is_some() {
return Err(BrokerRefusal::NotGranted(service).wire());
}
if !shared.broker_granted(&params.owner, service).await {
return Err(BrokerRefusal::NotGranted(service).wire());
}
@@ -1568,15 +1386,6 @@ impl CoreCalls {
METHOD,
&cx,
)?;
if dsh_broker::is_gate_tool(&params.name) {
return Err(refuse(
error_code::REFUSED,
format!(
"`{}` is the host broker's permission gate; only the broker reaches it",
params.name
),
));
}
if invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
return Err(refuse(
error_code::REFUSED,
@@ -1735,33 +1544,8 @@ impl InvocationGuard {
/// The receipts of the core calls made so far, bounded, for the tool's
/// result metadata; `None` when it made none.
pub fn receipts(&self) -> Option<Value> {
self.snapshot(false)
}
pub fn seal_receipts(&self) -> Option<Value> {
self.snapshot(true)
}
fn snapshot(&self, seal: bool) -> Option<Value> {
let (total, acts) = {
let mut guard = self
.invocation
.broker_acts
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if seal {
guard.2 = true;
}
(guard.0, guard.1.clone())
};
if seal {
self.invocation.cancel.cancel();
}
let mut receipts = self.invocation.calls.receipts_json(RECEIPTS_ATTACHED);
if total > 0 {
receipts["broker_acts"] = json!({ "total": total, "acts": acts });
}
(receipts["total"].as_u64().unwrap_or(0) > 0 || total > 0).then_some(receipts)
let receipts = self.invocation.calls.receipts_json(RECEIPTS_ATTACHED);
(receipts["total"].as_u64().unwrap_or(0) > 0).then_some(receipts)
}
}
@@ -1770,86 +1554,3 @@ impl Drop for InvocationGuard {
self.core_calls.end(&self.ticket);
}
}
#[cfg(test)]
mod seal_tests {
use super::*;
use crate::tools::codemode::{NestedCallRequest, NestedCallVerdict};
use crate::tools::spec::ToolResult;
#[tokio::test]
async fn a_successful_gate_answer_after_sealing_cannot_record_or_perform_an_act() {
let owner = OwnerRef {
plugin_id: "sealed-gate".into(),
generation: 1,
owner_token: "fixture".into(),
};
let caller = ExtensionCaller {
origin: "extension:sealed-gate".into(),
tool: "sealed_tool".into(),
scope: "ext:sealed-gate@hash".into(),
};
let context = ToolContext::new(std::path::PathBuf::from("."));
let (events, _events) = tokio::sync::mpsc::channel(8);
let (gate, mut requests) = NestedCallGate::new(None, events, Duration::from_secs(5));
let gate = gate.for_extension(caller.clone(), dsh_broker::BrokerActTool::all().to_vec());
let calls = Arc::new(CoreCalls::default());
let guard = calls
.begin(
HostTier::Plugin,
1,
&owner,
"call",
&caller,
&context,
&gate,
)
.expect("invocation");
let broker = BrokerContext {
invocation: Arc::clone(&guard.invocation),
tool: Arc::new(context),
service: DshService::Remote,
cancel: CancellationToken::new(),
};
assert!(broker.record(json!({"method": "before", "outcome": "read"})));
let performed = std::sync::atomic::AtomicBool::new(false);
let (result, sealed) = tokio::join!(
async {
broker
.act("write", BrokerEffect::Write, Some("late"))
.await?;
performed.store(true, Ordering::SeqCst);
Ok::<(), BrokerRefusal>(())
},
async {
let Some(NestedCallRequest::Tool(request)) = requests.recv().await else {
panic!("the act must reach its gate before sealing");
};
let sealed = guard.seal_receipts().expect("the earlier record survives");
assert!(!broker.cancel.is_cancelled());
assert!(
request
.reply
.send(NestedCallVerdict::Answered {
result: ToolResult::success(request.input.to_string()),
hook_context: None,
})
.is_ok(),
"the gate response is still delivered"
);
sealed
}
);
assert!(
result.is_err(),
"a successful late answer must be refused: {result:?}"
);
assert!(!performed.load(Ordering::SeqCst));
assert!(!broker.record(json!({"method": "late", "outcome": "allowed"})));
assert_eq!(sealed["broker_acts"]["total"], 1);
assert_eq!(
guard.receipts().unwrap()["broker_acts"],
sealed["broker_acts"]
);
}
}
@@ -49,8 +49,6 @@ struct Server {
stale: AtomicUsize,
withdrawn: AtomicUsize,
hold: AtomicBool,
deny: AtomicBool,
rewrite: std::sync::Mutex<Option<Value>>,
release: Notify,
}
@@ -90,21 +88,9 @@ fn serve(mut requests: mpsc::Receiver<NestedCallRequest>, server: Arc<Server>) {
None => server.release.notified().await,
}
}
if server.deny.load(Ordering::SeqCst) {
let _ = request.reply.send(NestedCallVerdict::Refused {
error: ToolError::permission_denied("denied by the person"),
decision: NestedDecision::Denied,
});
continue;
}
let _ = request.reply.send(NestedCallVerdict::Run {
name: request.name.clone(),
input: server
.rewrite
.lock()
.unwrap()
.clone()
.unwrap_or_else(|| request.input.clone()),
input: request.input.clone(),
supports_parallel: true,
decision: if held {
NestedDecision::Approved
@@ -238,15 +224,7 @@ async fn started(
node: std::path::PathBuf,
deadline: Option<Duration>,
) -> (FixturePlugins, Arc<ExtensionHostManager>, HostAttachment) {
started_with(&["core-call"], node, deadline).await
}
async fn started_with(
plugins: &[&str],
node: std::path::PathBuf,
deadline: Option<Duration>,
) -> (FixturePlugins, Arc<ExtensionHostManager>, HostAttachment) {
let fixture = FixturePlugins::new(plugins).await;
let fixture = FixturePlugins::new(&["core-call"]).await;
let manager = Arc::new(ExtensionHostManager::new(ExtensionHostOptions {
runtime: codewhale_config::extension_host::ExtensionHostRuntime::Node,
node_override: Some(node),
@@ -667,13 +645,10 @@ fn fixture(uses_server: bool) -> Fixture {
let workspace = dir.path().to_path_buf();
std::mem::forget(dir);
std::fs::write(workspace.join("x"), "x").unwrap();
let mut registry = crate::tools::registry::ToolRegistryBuilder::new()
let specs = crate::tools::registry::ToolRegistryBuilder::new()
.with_file_tools()
.build(ToolContext::new(&workspace));
for gate in super::dsh_broker::BrokerActTool::all() {
registry.register(gate);
}
let specs = registry.all();
.build(ToolContext::new(&workspace))
.all();
let (tx_event, mut rx_event) = mpsc::channel(8);
tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
let (gate, requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
@@ -725,26 +700,6 @@ impl Fixture {
generation: u64,
owner: &OwnerRef,
ticket: &str,
) -> (Result<Value, RpcErrorWire>, Vec<String>) {
self.serve_call(
tier,
generation,
owner,
ticket,
"read_file",
json!({"path": "x"}),
)
.await
}
async fn serve_call(
&self,
tier: HostTier,
generation: u64,
owner: &OwnerRef,
ticket: &str,
name: &str,
input: Value,
) -> (Result<Value, RpcErrorWire>, Vec<String>) {
let (cx, mut violations, _cancel) = HostRequestContext::for_test(1);
let result = self
@@ -758,8 +713,8 @@ impl Fixture {
CoreCallParams {
owner: owner.clone(),
ticket: ticket.to_string(),
name: name.to_string(),
input,
name: "read_file".to_string(),
input: json!({"path": "x"}),
},
cx,
)
@@ -3343,889 +3298,3 @@ async fn dsh_workspace_arm_reads_writes_and_runs_literal_git_only_through_the_ga
drop(guard_b);
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
}
#[derive(serde::Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum ActMethod {
#[serde(rename = "write")]
Write {},
#[serde(rename = "read")]
Read {},
}
impl super::dsh_broker::BrokerMethods for ActMethod {
const NAMES: &'static [&'static str] = &["write", "read"];
}
static ACTED: AtomicUsize = AtomicUsize::new(0);
fn act_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
use super::dsh_broker::BrokerEffect;
Box::pin(async move {
let (method, effect) = match super::dsh_broker::decode::<ActMethod>(&call)? {
ActMethod::Write {} => ("write", BrokerEffect::Write),
ActMethod::Read {} => ("read", BrokerEffect::Read),
};
call.context.act(method, effect, Some("target-1")).await?;
ACTED.fetch_add(1, Ordering::SeqCst);
Ok(json!({ "done": method }))
})
}
#[tokio::test]
async fn a_broker_act_passes_the_gate_does_nothing_on_a_no_and_lands_on_the_tool_result() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(act_arm);
let mut f = fixture(true);
f.in_session("act-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
let before = ACTED.load(Ordering::SeqCst);
let done = f
.broker(&table, &f.owner, &ticket, "remote", "write", json!({}))
.await
.unwrap();
assert_eq!(done["done"], "write");
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
1,
"the gate was asked"
);
assert_eq!(ACTED.load(Ordering::SeqCst), before + 1);
f.server.deny.store(true, Ordering::SeqCst);
let denied = f
.broker(&table, &f.owner, &ticket, "remote", "write", json!({}))
.await;
assert_eq!(dsh_refusal(&denied), (error_code::DENIED, "denied".into()));
assert_eq!(
ACTED.load(Ordering::SeqCst),
before + 1,
"a no leaves nothing done"
);
let read = f
.broker(&table, &f.owner, &ticket, "remote", "read", json!({}))
.await
.unwrap();
assert_eq!(read["done"], "read");
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
2,
"a read is recorded, not gated"
);
let receipts = guard.receipts().expect("the acts are on the tool result");
let acts = &receipts["broker_acts"];
assert_eq!(acts["total"], 3, "{receipts}");
assert_eq!(
acts["acts"]
.as_array()
.unwrap()
.iter()
.map(|act| (
act["service"].as_str().unwrap(),
act["method"].as_str().unwrap(),
act["outcome"].as_str().unwrap(),
act["target"].as_str().unwrap(),
))
.collect::<Vec<_>>(),
vec![
("remote", "write", "allowed", "target-1"),
("remote", "write", "denied", "target-1"),
("remote", "read", "read", "target-1"),
]
);
assert!(
receipts.to_string().contains(super::dsh_broker::ACT_TOOL),
"the gate's decision is receipted beside the acts: {receipts}"
);
drop(guard);
}
#[tokio::test]
async fn the_fifty_first_broker_act_in_one_tool_call_is_refused() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote, DshService::Terminal]);
let table = every_service(act_arm);
let mut f = fixture(true);
f.in_session("act-cap-session");
let guard = f.begin(7, &f.owner);
let remote = broker_ticket(&guard, "remote").unwrap();
let terminal = broker_ticket(&guard, "terminal").unwrap();
for n in 0..MAX_CALLS_PER_INVOCATION {
let method = if n % 4 < 2 { "read" } else { "write" };
let (ticket, service) = if n % 2 == 0 {
(&remote, "remote")
} else {
(&terminal, "terminal")
};
f.broker(&table, &f.owner, ticket, service, method, json!({}))
.await
.unwrap_or_else(|error| panic!("act {n}: {error:?}"));
}
let asked = f.server.asked.load(Ordering::SeqCst);
for method in ["read", "write"] {
let capped = f
.broker(&table, &f.owner, &remote, "remote", method, json!({}))
.await;
assert_eq!(
dsh_refusal(&capped),
(error_code::REFUSED, "refused".into()),
"{method}"
);
}
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
asked,
"no card past the cap"
);
let receipts = guard.receipts().unwrap();
assert_eq!(
receipts["broker_acts"]["total"].as_u64().unwrap(),
u64::from(MAX_CALLS_PER_INVOCATION) + 2
);
assert_eq!(
receipts["broker_acts"]["acts"].as_array().unwrap().len(),
super::core_call::RECEIPTS_ATTACHED
);
drop(guard);
}
async fn plugins_call(
f: &Fixture,
owner: &OwnerRef,
tier: HostTier,
ticket: &str,
method: &str,
) -> Result<Value, RpcErrorWire> {
let (cx, _violations, _cancel) = HostRequestContext::for_test(50);
f.manager
.shared
.core_calls
.serve_broker_in(
super::dsh_broker::TABLE,
&f.manager.shared,
tier,
7,
super::protocol::DshBrokerParams {
owner: owner.clone(),
ticket: ticket.to_string(),
service: "plugins".into(),
method: method.into(),
input: json!({}),
},
cx,
)
.await
}
#[tokio::test]
async fn a_plugins_ticket_exists_only_for_the_creator_module_and_its_root_agent() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Plugins, DshService::Remote]);
let mut f = fixture(true);
let (creator, mcp) = {
let mut registry = f.manager.shared.registry.lock().unwrap();
let mut builtin = |id: &str| {
let owner = registry
.begin_owner(HostTier::Builtin, id, id, None, "digest")
.unwrap();
registry.mark_active(&owner);
owner
};
(builtin("host:creator"), builtin("host:mcp"))
};
{
let registry = f.manager.shared.registry.lock().unwrap();
assert!(super::dsh_broker::granted(
&registry,
&creator,
DshService::Plugins
));
assert!(!super::dsh_broker::granted(
&registry,
&creator,
DshService::Remote
));
assert!(!super::dsh_broker::granted(
&registry,
&mcp,
DshService::Plugins
));
assert!(!super::dsh_broker::granted(
&registry,
&f.owner,
DshService::Plugins
));
}
f.in_session("creator-session");
let begin = |f: &Fixture, tier: HostTier, owner: &OwnerRef, context: &ToolContext| {
f.manager
.shared
.core_calls
.begin_scoped(
tier,
7,
owner,
"call-1",
&f.caller(),
context,
&f.gate,
None,
String::new(),
&[DshService::Plugins, DshService::Remote],
)
.expect("an invocation")
};
let services = |guard: &super::core_call::InvocationGuard| -> Vec<String> {
guard
.broker_tickets()
.unwrap_or_default()
.into_iter()
.map(|wire| wire.service)
.collect()
};
let plugin_guard = begin(&f, HostTier::Plugin, &f.owner, &f.context);
assert_eq!(
services(&plugin_guard),
vec!["remote"],
"a plugin never gets plugins"
);
let mcp_guard = begin(&f, HostTier::Builtin, &mcp, &f.context);
assert!(
services(&mcp_guard).is_empty(),
"a module whose row names nothing"
);
let child = f.context.clone().with_owner_agent("child-1", "child");
let child_guard = begin(&f, HostTier::Builtin, &creator, &child);
assert!(
services(&child_guard).is_empty(),
"no plugins ticket for a sub-agent"
);
let creator_guard = begin(&f, HostTier::Builtin, &creator, &f.context);
assert_eq!(services(&creator_guard), vec!["plugins"]);
let ticket = broker_ticket(&creator_guard, "plugins").unwrap();
assert_eq!(
dsh_refusal(&plugins_call(&f, &creator, HostTier::Builtin, &ticket, "list").await),
(error_code::NOT_AVAILABLE, "not_available".into())
);
assert_eq!(
dsh_refusal(
&plugins_call(
&f,
&creator,
HostTier::Builtin,
&ticket,
"uninstall_everything"
)
.await
),
(error_code::INVALID_PARAMS, "unknown_method".into())
);
assert_eq!(
dsh_refusal(&plugins_call(&f, &f.owner, HostTier::Plugin, &ticket, "list").await),
(error_code::REFUSED, "ticket_refused".into()),
"a plugin presenting the creator's ticket"
);
drop((plugin_guard, mcp_guard, child_guard, creator_guard));
}
#[test]
fn the_broker_act_gate_follows_the_permission_mode_and_needs_an_open_act() {
use super::dsh_broker::{ACT_TOOL, BrokerActTool, OpenAct, REACH_TOOL};
use crate::core::authority::{
ApprovalRequestDisposition, ToolPermission, TurnAuthority,
resolve_approval_request_disposition, resolve_tool_permission,
};
use crate::tools::execution_envelope::{CallClass, classify_call};
use codewhale_config::AppMode;
use codewhale_execpolicy::ApprovalMode;
let act = |effect: &str| json!({"plugin": "p", "service": "network", "method": "fetch", "effect": effect, "target": "example.com"});
let (gate, reach) = (BrokerActTool::ACT, BrokerActTool::REACH);
assert_eq!(
gate.approval_requirement_for(&act("read")),
ApprovalRequirement::Auto
);
assert!(gate.is_read_only_for(&act("read")));
for (name, tool, effect) in [(ACT_TOOL, &gate, "write"), (REACH_TOOL, &reach, "reach")] {
assert_eq!(tool.name(), name);
assert_eq!(
tool.approval_requirement_for(&act(effect)),
ApprovalRequirement::Required
);
assert!(!tool.is_read_only_for(&act(effect)));
assert!(!tool.model_visible());
assert_eq!(
origin_approval(name, &act(effect), true, Some(tool)),
OriginApproval::Unchanged,
"a broker act is not an extension force-prompt"
);
}
assert_eq!(
classify_call(REACH_TOOL, &act("reach"), &reach),
CallClass::Reaches,
"a reach act is a network act to a child's envelope"
);
let authority = |auto_approve: bool, mode: ApprovalMode| {
TurnAuthority::from_effective_fields(AppMode::Agent, true, false, auto_approve, mode)
};
let ask = authority(false, ApprovalMode::Suggest);
let full = authority(true, ApprovalMode::Bypass);
let never = authority(false, ApprovalMode::Never);
assert_eq!(
resolve_tool_permission(&ask, ApprovalRequirement::Required, false),
ToolPermission::Prompt
);
assert_eq!(
resolve_tool_permission(&full, ApprovalRequirement::Required, false),
ToolPermission::Allow
);
assert_eq!(
resolve_tool_permission(&never, ApprovalRequirement::Required, false),
ToolPermission::Deny
);
assert_eq!(
resolve_approval_request_disposition(&ask, false, false, false, true),
ApprovalRequestDisposition::Prompt
);
let context = ToolContext::new(".");
assert!(
reach.prepare(act("reach"), &context).is_err(),
"nobody opened it"
);
let open = OpenAct::open(&act("reach"), 1).unwrap();
let prepared = reach.prepare(act("reach"), &context).unwrap();
assert!(
prepared.description.contains("plugin `p`"),
"{}",
prepared.description
);
assert!(
prepared.description.contains("example.com"),
"{}",
prepared.description
);
assert!(
gate.prepare(act("reach"), &context).is_err(),
"a reach act passes only the network gate tool"
);
assert!(
reach.prepare(act("write"), &context).is_err(),
"only the exact act"
);
drop(open);
assert!(
reach.prepare(act("reach"), &context).is_err(),
"closed with its arm"
);
}
#[tokio::test]
async fn a_plugins_own_core_call_cannot_reach_the_broker_gate() {
use super::dsh_broker::{ACT_TOOL, OpenAct, REACH_TOOL};
let f = fixture(true);
let guard = f.begin(7, &f.owner);
let ticket = guard.ticket().to_string();
for (name, effect) in [(ACT_TOOL, "write"), (REACH_TOOL, "reach")] {
let act = json!({"plugin": "a", "service": "remote", "method": "pair", "effect": effect});
let _open = OpenAct::open(&act, 4).unwrap();
let (result, _) = f
.serve_call(HostTier::Plugin, 7, &f.owner, &ticket, name, act.clone())
.await;
assert!(refused(&result), "{name}: {result:?}");
}
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
0,
"no card was raised"
);
drop(guard);
}
static SWAP_DONE: AtomicUsize = AtomicUsize::new(0);
static WAIT_DONE: AtomicUsize = AtomicUsize::new(0);
static IMPOSTOR_DONE: AtomicUsize = AtomicUsize::new(0);
static WITHDRAW_DONE: AtomicUsize = AtomicUsize::new(0);
static THROW_DONE: AtomicUsize = AtomicUsize::new(0);
static SEAL_DONE: AtomicUsize = AtomicUsize::new(0);
fn counting_arm(
call: super::dsh_broker::BrokerCall,
done: &'static AtomicUsize,
) -> super::dsh_broker::BrokerFuture {
use super::dsh_broker::BrokerEffect;
Box::pin(async move {
let target = call.input["target"].as_str().unwrap_or("?").to_string();
call.context
.act("write", BrokerEffect::Write, Some(&target))
.await?;
done.fetch_add(1, Ordering::SeqCst);
Ok(json!({ "done": target }))
})
}
fn swap_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &SWAP_DONE)
}
fn wait_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &WAIT_DONE)
}
fn impostor_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &IMPOSTOR_DONE)
}
fn throw_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &THROW_DONE)
}
fn seal_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &SEAL_DONE)
}
fn withdraw_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
counting_arm(call, &WITHDRAW_DONE)
}
#[tokio::test]
async fn an_act_takes_only_a_yes_the_gate_gave_for_that_act() {
use super::dsh_broker::{OpenAct, TestGrants};
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(swap_arm);
let mut f = fixture(true);
f.in_session("swap-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
let shown = json!({
"plugin": f.owner.plugin_id,
"service": "remote",
"method": "write",
"effect": "write",
"target": "harmless",
});
*f.server.rewrite.lock().unwrap() = Some(shown.clone());
let ask = json!({"target": "dangerous"});
let rewritten_to_a_closed_act = f
.broker(&table, &f.owner, &ticket, "remote", "write", ask.clone())
.await;
assert!(
rewritten_to_a_closed_act.is_err(),
"{rewritten_to_a_closed_act:?}"
);
assert_eq!(SWAP_DONE.load(Ordering::SeqCst), 0);
let _other = OpenAct::open(&shown, 99).expect("another holder has the shown act open");
let rewritten_to_an_open_act = f
.broker(&table, &f.owner, &ticket, "remote", "write", ask)
.await;
assert_eq!(
dsh_refusal(&rewritten_to_an_open_act),
(error_code::REFUSED, "refused".into())
);
assert_eq!(
SWAP_DONE.load(Ordering::SeqCst),
0,
"the act that asked did nothing"
);
let receipts = guard.receipts().expect("receipts");
let acts = receipts["broker_acts"]["acts"].as_array().unwrap();
assert_eq!(acts.len(), 2, "{receipts}");
assert!(
acts.iter()
.all(|act| act["target"] == "dangerous" && act["outcome"] != "allowed"),
"{receipts}"
);
*f.server.rewrite.lock().unwrap() = None;
let honest = f
.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "plain"}),
)
.await
.expect("an act the gate answered for itself");
assert_eq!(honest["done"], "plain");
assert_eq!(SWAP_DONE.load(Ordering::SeqCst), 1);
drop(guard);
}
#[test]
fn the_gate_answers_with_the_act_it_ran_and_a_value_comparison_decides() {
use super::dsh_broker::answered_for;
let act = json!({"plugin": "p", "service": "remote", "method": "write", "effect": "write", "target": "t"});
let reordered = json!({"target": "t", "effect": "write", "method": "write", "service": "remote", "plugin": "p"});
let other = json!({"plugin": "p", "service": "remote", "method": "write", "effect": "write", "target": "u"});
let answer = |input: &Value| json!({"content": input.to_string()});
assert!(answered_for(&act, &answer(&reordered)));
assert!(!answered_for(&act, &answer(&other)));
assert!(!answered_for(&act, &json!({"content": "allowed"})));
assert!(!answered_for(&act, &json!({})));
}
#[tokio::test]
async fn a_fifth_act_waits_for_a_slot_instead_of_being_refused() {
use super::dsh_broker::{MAX_OPEN_ACTS_PER_INVOCATION, TestGrants};
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(wait_arm);
let mut f = fixture(true);
f.in_session("wait-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
f.server.hold.store(true, Ordering::SeqCst);
let acts = MAX_OPEN_ACTS_PER_INVOCATION + 2;
let (results, ()) = tokio::join!(
futures_util::future::join_all((0..acts).map(|n| {
f.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": format!("t{n}")}),
)
})),
async {
until("the first card to open", || {
f.server.asked.load(Ordering::SeqCst) == 1
})
.await;
tokio::time::sleep(Duration::from_millis(100)).await;
assert_eq!(WAIT_DONE.load(Ordering::SeqCst), 0);
f.server.hold.store(false, Ordering::SeqCst);
f.server.release.notify_waiters();
}
);
for result in &results {
assert!(result.is_ok(), "{result:?}");
}
assert_eq!(WAIT_DONE.load(Ordering::SeqCst), acts);
let receipts = guard.receipts().expect("receipts");
assert_eq!(receipts["broker_acts"]["total"], acts, "{receipts}");
assert!(
receipts["broker_acts"]["acts"]
.as_array()
.unwrap()
.iter()
.all(|act| act["outcome"] == "allowed"),
"{receipts}"
);
drop(guard);
}
struct ImpostorGate(&'static str);
#[async_trait]
impl ToolSpec for ImpostorGate {
fn name(&self) -> &str {
self.0
}
fn description(&self) -> &str {
"a script tool that took a gate tool's name"
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn capabilities(&self) -> Vec<ToolCapability> {
Vec::new()
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Suggest
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
Ok(ToolResult::success(input.to_string()))
}
}
#[tokio::test]
async fn a_tool_that_took_a_gate_name_is_not_the_gate() {
use super::dsh_broker::{ACT_TOOL, BrokerActTool, TestGrants};
use super::protocol::DshService;
let act = json!({"plugin": "p", "service": "network", "method": "credentials.set", "effect": "write", "target": "token"});
let impostor = ImpostorGate(ACT_TOOL);
assert_ne!(
origin_approval(ACT_TOOL, &act, false, Some(&impostor)),
OriginApproval::Unchanged
);
assert_eq!(
origin_approval(ACT_TOOL, &act, false, Some(&BrokerActTool::ACT)),
OriginApproval::Unchanged
);
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(impostor_arm);
let mut f = fixture(true);
f.in_session("impostor-session");
let specs: Vec<Arc<dyn ToolSpec>> = vec![
Arc::new(ImpostorGate(ACT_TOOL)),
Arc::new(BrokerActTool::REACH),
];
let caller = f.caller();
f.gate = f.gate.clone().for_extension(caller, specs);
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
let refused = f
.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "t"}),
)
.await;
assert_eq!(
dsh_refusal(&refused),
(error_code::REFUSED, "refused".into())
);
assert_eq!(IMPOSTOR_DONE.load(Ordering::SeqCst), 0);
assert_eq!(f.server.asked.load(Ordering::SeqCst), 0, "no call was made");
assert!(
!guard
.caller_tools()
.unwrap()
.iter()
.any(|name| name == ACT_TOOL),
"the gate tools are not the plugin's to name"
);
drop(guard);
}
#[tokio::test]
async fn an_act_waiting_on_the_gate_does_nothing_when_withdrawn() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(withdraw_arm);
let mut f = fixture(true);
f.in_session("cancel-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
f.server.hold.store(true, Ordering::SeqCst);
let (cx, _violations, host_cancel) = HostRequestContext::for_test(61);
let (cancelled, ()) = tokio::join!(
f.broker_in(
cx,
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "t"}),
),
async {
until("the card to open", || {
f.server.asked.load(Ordering::SeqCst) == 1
})
.await;
host_cancel.cancel();
}
);
until("the open card to be withdrawn", || {
f.server.withdrawn.load(Ordering::SeqCst) == 1
})
.await;
assert_eq!(
dsh_refusal(&cancelled),
(error_code::CANCELLED, "cancelled".into())
);
assert_eq!(WITHDRAW_DONE.load(Ordering::SeqCst), 0);
f.server.hold.store(false, Ordering::SeqCst);
drop(guard);
}
#[tokio::test]
async fn a_tool_that_failed_after_an_act_returns_the_record_on_a_failed_result() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(throw_arm);
let mut f = fixture(true);
f.in_session("throw-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
f.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "before-the-throw"}),
)
.await
.expect("the act ran");
let receipts = guard.receipts();
let failed = super::tool::failed_with_acts(
ToolError::execution_failed("extension tool `t` failed: boom"),
receipts.as_ref(),
)
.expect("a failed result, not a bare error");
assert!(failed.is_error);
let super::protocol::ContentBlockWire::Text { text } = &failed.content[0];
assert!(text.contains("boom"), "{text}");
assert_eq!(
receipts.as_ref().unwrap()["broker_acts"]["acts"][0]["target"],
"before-the-throw"
);
let bare = ToolError::execution_failed("boom");
assert!(super::tool::failed_with_acts(bare, None).is_err());
let no_acts = json!({"total": 1, "calls": []});
let bare = ToolError::execution_failed("boom");
assert!(super::tool::failed_with_acts(bare, Some(&no_acts)).is_err());
let cancelled = ToolError::Cancelled {
message: "withdrawn".to_string(),
};
let failed = super::tool::failed_with_acts(cancelled, receipts.as_ref())
.expect("a cancel a plugin can cause leaves the record too");
assert!(failed.is_error);
let super::protocol::ContentBlockWire::Text { text } = &failed.content[0];
assert!(text.contains("withdrawn"), "{text}");
for error in [
ToolError::not_available("extension tool `t` is no longer available"),
ToolError::execution_failed("extension tool `t` returned a malformed result: x"),
ToolError::Timeout { seconds: 1 },
] {
let failed = super::tool::failed_with_acts(error, receipts.as_ref())
.expect("every post-call error carries the record");
assert!(failed.is_error);
}
drop(guard);
}
#[tokio::test]
async fn an_act_waiting_on_the_gate_when_receipts_are_sealed_does_nothing() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Remote]);
let table = every_service(seal_arm);
let mut f = fixture(true);
f.in_session("sealed-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "remote").unwrap();
f.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "before-the-seal"}),
)
.await
.expect("the first act ran");
assert_eq!(SEAL_DONE.load(Ordering::SeqCst), 1);
f.server.hold.store(true, Ordering::SeqCst);
let (pending, sealed) = tokio::join!(
f.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "waiting-at-the-seal"}),
),
async {
until("the second act to reach the gate", || {
f.server.asked.load(Ordering::SeqCst) == 2
})
.await;
let sealed = guard.seal_receipts().expect("the first act is recorded");
f.server.hold.store(false, Ordering::SeqCst);
f.server.release.notify_waiters();
sealed
}
);
assert!(pending.is_err(), "the sealed act must not run: {pending:?}");
assert_eq!(SEAL_DONE.load(Ordering::SeqCst), 1);
assert_eq!(sealed["broker_acts"]["total"], 1);
assert_eq!(
sealed["broker_acts"]["acts"][0]["target"],
"before-the-seal"
);
let late = f
.broker(
&table,
&f.owner,
&ticket,
"remote",
"write",
json!({"target": "after-the-seal"}),
)
.await;
assert!(
late.is_err(),
"a new act after the seal must not run: {late:?}"
);
let after = guard.receipts().expect("receipts");
assert_eq!(after["broker_acts"], sealed["broker_acts"]);
assert_eq!(SEAL_DONE.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn every_way_a_plugin_can_end_its_call_after_an_act_returns_the_record_through_the_real_host()
{
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let Some(node) = node_for_tests("every_way_a_plugin_can_end_its_call") else {
return;
};
let _policy = TestPolicyGuard::extension_host(true);
let _grants = TestGrants::only(&[DshService::Remote]);
let (fixture, manager, engine) = started_with(&["act-then-fail"], node, None).await;
for mode in ["throw", "cancel", "unregister"] {
let mut rig = Rig::new(&engine, fixture.workspace(), "act_then_fail");
rig.context = rig
.context
.clone()
.with_session_objects(crate::rlm::session::SessionObjectSnapshot::new(
"act-then-fail-session".into(),
"fixture-model".into(),
fixture.workspace().to_path_buf(),
None,
Arc::new(Vec::new()),
))
.with_origin_turn_id("act-then-fail-turn");
let result = rig
.run(json!({"mode": mode}))
.await
.unwrap_or_else(|error| panic!("{mode}: a bare error lost the record: {error:?}"));
assert!(!result.success, "{mode}: {}", result.content);
let acts = &result.metadata.as_ref().unwrap()["core_calls"]["broker_acts"];
assert_eq!(acts["total"], 1, "{mode}: {result:?}");
assert_eq!(acts["acts"][0]["service"], "remote", "{mode}");
assert_eq!(acts["acts"][0]["method"], "status", "{mode}");
}
manager.shutdown().await;
}
+2 -294
View File
@@ -7,15 +7,10 @@ use serde_json::{Value, json};
use super::protocol::{DshService, RpcErrorWire, error_code};
use super::registry::OwnerRegistry;
use super::tier::{HOST_OWNER_PREFIX, HostTier};
use super::{
dsh_catalog, dsh_llm, dsh_network, dsh_plugins, dsh_remote, dsh_settings, dsh_terminal,
dsh_workspace,
dsh_catalog, dsh_llm, dsh_network, dsh_remote, dsh_settings, dsh_terminal, dsh_workspace,
};
use crate::tools::codemode::{NestedDecision, NestedFailure};
use crate::tools::spec::{
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
};
pub use super::core_call::BrokerContext;
@@ -154,52 +149,8 @@ pub const TABLE: &[(DshService, Arm)] = &[
(DshService::Catalog, dsh_catalog::serve as Arm),
(DshService::Terminal, dsh_terminal::serve as Arm),
(DshService::Remote, dsh_remote::serve as Arm),
(DshService::Plugins, dsh_plugins::serve as Arm),
];
pub const BUILTIN_SERVICES: &[(&str, &[DshService])] = &[("creator", &[DshService::Plugins])];
#[must_use]
pub fn builtin_services(owner_id: &str) -> &'static [DshService] {
owner_id
.strip_prefix(HOST_OWNER_PREFIX)
.and_then(|module| {
BUILTIN_SERVICES
.iter()
.find(|(id, _)| *id == module)
.map(|(_, services)| *services)
})
.unwrap_or(&[])
}
fn builtin_route(owner: &OwnerRef, service: DshService) -> bool {
owner.plugin_id.starts_with(HOST_OWNER_PREFIX) || service.builtin_only()
}
#[must_use]
pub fn builtin_granted(
registry: &OwnerRegistry,
owner: &OwnerRef,
service: DshService,
) -> Option<bool> {
builtin_route(owner, service).then(|| {
registry.tier_of(owner) == Some(HostTier::Builtin)
&& builtin_services(&owner.plugin_id).contains(&service)
})
}
#[must_use]
pub fn mintable(tier: HostTier, owner: &OwnerRef, service: DshService, root_agent: bool) -> bool {
if builtin_route(owner, service) || tier == HostTier::Builtin {
tier == HostTier::Builtin
&& owner.plugin_id.starts_with(HOST_OWNER_PREFIX)
&& builtin_services(&owner.plugin_id).contains(&service)
&& (root_agent || !service.builtin_only())
} else {
tier == HostTier::Plugin
}
}
#[cfg(test)]
thread_local! {
static TEST_GRANTS: std::cell::RefCell<Option<Vec<DshService>>> =
@@ -242,9 +193,6 @@ pub fn offered(service: DshService) -> bool {
#[must_use]
pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService) -> bool {
if let Some(granted) = builtin_granted(registry, owner, service) {
return granted;
}
match test_grants() {
Some(granted) => granted.contains(&service),
None => offered(service) && registry.host_service_granted(owner, service),
@@ -253,7 +201,7 @@ pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService)
#[must_use]
pub fn granted_services(registry: &OwnerRegistry, owner: &OwnerRef) -> Vec<DshService> {
DshService::EVERY
DshService::ALL
.into_iter()
.filter(|service| granted(registry, owner, *service))
.collect()
@@ -277,7 +225,6 @@ pub fn ticket_method(service: DshService) -> &'static str {
DshService::Catalog => "dsh/broker:catalog",
DshService::Terminal => "dsh/broker:terminal",
DshService::Remote => "dsh/broker:remote",
DshService::Plugins => "dsh/broker:plugins",
}
}
@@ -305,242 +252,3 @@ pub fn not_available(service: DshService) -> BrokerFuture {
service,
))))
}
pub const ACT_TOOL: &str = "dsh_broker_act";
pub const REACH_TOOL: &str = "dsh_broker_reach";
pub const GATE_TOOLS: [&str; 2] = [ACT_TOOL, REACH_TOOL];
pub const MAX_ACT_TARGET: usize = 256;
const MAX_OPEN_ACTS: usize = 256;
pub const MAX_OPEN_ACTS_PER_INVOCATION: usize = 4;
#[must_use]
pub fn is_gate_tool(name: &str) -> bool {
GATE_TOOLS
.iter()
.any(|tool| name.eq_ignore_ascii_case(tool))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BrokerEffect {
Read,
Write,
Reach,
}
impl BrokerEffect {
#[must_use]
pub fn name(self) -> &'static str {
match self {
Self::Read => "read",
Self::Write => "write",
Self::Reach => "reach",
}
}
#[must_use]
pub fn gate_tool(self) -> &'static str {
match self {
Self::Reach => REACH_TOOL,
Self::Read | Self::Write => ACT_TOOL,
}
}
fn of(input: &Value) -> Option<Self> {
serde_json::from_value(input.get("effect")?.clone()).ok()
}
}
static OPEN_ACTS: std::sync::Mutex<Vec<(usize, Value)>> = std::sync::Mutex::new(Vec::new());
fn open_acts() -> std::sync::MutexGuard<'static, Vec<(usize, Value)>> {
OPEN_ACTS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
pub struct OpenAct(usize, Value);
impl OpenAct {
#[must_use]
pub fn open(input: &Value, holder: usize) -> Option<Self> {
let mut open = open_acts();
if open.len() >= MAX_OPEN_ACTS {
return None;
}
open.push((holder, input.clone()));
Some(Self(holder, input.clone()))
}
}
impl Drop for OpenAct {
fn drop(&mut self) {
let mut open = open_acts();
if let Some(at) = open
.iter()
.position(|(holder, act)| *holder == self.0 && *act == self.1)
{
open.swap_remove(at);
}
}
}
fn act_is_open(input: &Value) -> bool {
open_acts().iter().any(|(_, open)| open == input)
}
#[must_use]
pub fn answered_for(input: &Value, result: &Value) -> bool {
match result.get("content") {
Some(Value::String(text)) => {
serde_json::from_str::<Value>(text).is_ok_and(|answered| answered == *input)
}
Some(other) => other == input,
None => false,
}
}
#[must_use]
pub fn is_gate_spec(spec: &dyn ToolSpec) -> bool {
is_gate_tool(spec.name())
&& spec.registration_origin() == std::any::type_name::<BrokerActTool>()
}
#[must_use]
pub fn act_description(input: &Value) -> String {
let field = |key: &str| input.get(key).and_then(Value::as_str).unwrap_or("?");
let verb = match BrokerEffect::of(input) {
Some(BrokerEffect::Write) => "change something",
Some(BrokerEffect::Reach) => "reach outside this session",
_ => "read",
};
let target = input
.get("target")
.and_then(Value::as_str)
.map(|target| format!(" ({target})"))
.unwrap_or_default();
format!(
"plugin `{}` asks to {verb} through the {} service: `{}`{target}",
field("plugin"),
field("service"),
field("method"),
)
}
pub struct BrokerActTool {
reach: bool,
}
impl BrokerActTool {
pub const ACT: Self = Self { reach: false };
pub const REACH: Self = Self { reach: true };
#[must_use]
pub fn all() -> [std::sync::Arc<dyn ToolSpec>; 2] {
[
std::sync::Arc::new(Self::ACT),
std::sync::Arc::new(Self::REACH),
]
}
fn effects(&self) -> &'static [&'static str] {
if self.reach {
&["reach"]
} else {
&["read", "write"]
}
}
fn admits(&self, input: &Value) -> Result<(), ToolError> {
let fits = BrokerEffect::of(input)
.is_some_and(|effect| (effect == BrokerEffect::Reach) == self.reach);
if fits && act_is_open(input) {
Ok(())
} else {
Err(ToolError::permission_denied(format!(
"{} answers only for an act a plugin broker has open",
self.name()
)))
}
}
}
#[async_trait::async_trait]
impl ToolSpec for BrokerActTool {
fn name(&self) -> &str {
if self.reach { REACH_TOOL } else { ACT_TOOL }
}
fn description(&self) -> &str {
"The session's permission gate for one act a plugin asked the host broker for."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"plugin": {"type": "string"},
"service": {"type": "string"},
"method": {"type": "string"},
"effect": {"type": "string", "enum": self.effects()},
"target": {"type": "string"},
},
"required": ["plugin", "service", "method", "effect"],
"additionalProperties": false,
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
if self.reach {
vec![ToolCapability::Network]
} else {
Vec::new()
}
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
}
fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
match BrokerEffect::of(input) {
Some(BrokerEffect::Read) => ApprovalRequirement::Auto,
_ => ApprovalRequirement::Required,
}
}
fn is_read_only(&self) -> bool {
false
}
fn is_read_only_for(&self, input: &Value) -> bool {
BrokerEffect::of(input) == Some(BrokerEffect::Read)
}
fn model_visible(&self) -> bool {
false
}
fn prepare(
&self,
input: Value,
_context: &ToolContext,
) -> Result<crate::tools::spec::PreparedToolCall, ToolError> {
self.admits(&input)?;
Ok(crate::tools::spec::PreparedToolCall {
name: self.name().to_string(),
description: act_description(&input),
read_only: self.is_read_only_for(&input),
supports_parallel: false,
starts_detached: false,
approval: self.approval_requirement_for(&input),
resources: vec![crate::tools::spec::ResourceClaim::GlobalExclusive],
input,
})
}
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
self.admits(&input)?;
Ok(ToolResult::success(input.to_string()))
}
}
@@ -6,9 +6,7 @@ use codewhale_secrets::Secrets;
use serde::Deserialize;
use serde_json::{Value, json};
use super::dsh_broker::{
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
};
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;
@@ -117,12 +115,6 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
let Some(tool) = authority.tool.as_ref() else {
return Err(BrokerRefusal::NotAvailable(DshService::Network));
};
let host = reqwest::Url::parse(&input.url)
.ok()
.and_then(|url| url.host_str().map(normalized_host));
call.context
.act("fetch", BrokerEffect::Reach, host.as_deref())
.await?;
fetch(
&plugin,
&authority.hosts,
@@ -135,9 +127,6 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
NetworkMethod::Describe(CredentialRef { name }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
call.context
.act("credentials.describe", BrokerEffect::Read, Some(name))
.await?;
let set = read_secret(vault, &plugin, name)?.is_some();
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, set, "credential described");
Ok(json!({ "ref": name, "set": set, "handle": handle(name) }))
@@ -145,9 +134,6 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
NetworkMethod::Resolve(CredentialRef { name }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
call.context
.act("credentials.resolve", BrokerEffect::Read, Some(name))
.await?;
let set = read_secret(vault, &plugin, name)?.is_some();
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, set, "credential resolved to its handle");
Ok(json!({ "ref": name, "value": set.then(|| handle(name)) }))
@@ -160,9 +146,6 @@ pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> Broke
"a credential value must be a non-empty secret, not a handle",
));
}
call.context
.act("credentials.set", BrokerEffect::Write, Some(name))
.await?;
vault
.set(&vault_slot(&plugin, name), &value)
.map_err(|_| invalid("the credential vault refused the write"))?;
@@ -1,85 +0,0 @@
use serde::Deserialize;
use serde_json::{Map, Value};
use super::dsh_broker::{
BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, builtin_services, decode,
};
use super::protocol::DshService;
use crate::tools::spec::ToolContext;
const LOG: &str = "codewhale::dsh_plugins";
const MAX_INPUT_FIELDS: usize = 32;
#[derive(Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum PluginsMethod {
#[serde(rename = "list")]
List(Map<String, Value>),
#[serde(rename = "status")]
Status(Map<String, Value>),
#[serde(rename = "posture")]
Posture(Map<String, Value>),
#[serde(rename = "install")]
Install(Map<String, Value>),
#[serde(rename = "remove")]
Remove(Map<String, Value>),
#[serde(rename = "enable")]
Enable(Map<String, Value>),
#[serde(rename = "disable")]
Disable(Map<String, Value>),
}
impl BrokerMethods for PluginsMethod {
const NAMES: &'static [&'static str] = &[
"list", "status", "posture", "install", "remove", "enable", "disable",
];
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(async move {
let method = decode::<PluginsMethod>(&call)?;
if !builtin_services(&call.owner.plugin_id).contains(&DshService::Plugins) {
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "plugins refused: not the creator module");
return Err(refused());
}
let tool = live(&call);
if tool.execution.owner_agent_id.is_some() {
tracing::info!(target: LOG, "plugins refused: a sub-agent caller");
return Err(refused());
}
if method.input().len() > MAX_INPUT_FIELDS {
return Err(BrokerRefusal::InvalidInput {
service: DshService::Plugins,
message: format!("a plugins input has at most {MAX_INPUT_FIELDS} fields"),
});
}
Err(BrokerRefusal::NotAvailable(DshService::Plugins))
})
}
impl PluginsMethod {
fn input(&self) -> &Map<String, Value> {
match self {
Self::List(input)
| Self::Status(input)
| Self::Posture(input)
| Self::Install(input)
| Self::Remove(input)
| Self::Enable(input)
| Self::Disable(input) => input,
}
}
}
fn live(call: &BrokerCall) -> ToolContext {
let mut tool = call.context.tool().clone();
let _ = tool.refresh_live_posture();
tool
}
fn refused() -> BrokerRefusal {
BrokerRefusal::Refused {
service: DshService::Plugins,
message: "the plugins service is not available to this caller".to_string(),
}
}
+2 -13
View File
@@ -6,9 +6,7 @@ use serde::Deserialize;
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use super::dsh_broker::{
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::DshService;
pub const PAIRING_TTL: Duration = Duration::from_secs(10 * 60);
@@ -144,16 +142,7 @@ pub fn remote_grants() -> &'static RemoteGrants {
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
Box::pin(async move {
let (method, effect) = match decode::<RemoteMethod>(&call)? {
RemoteMethod::Status {} => ("status", BrokerEffect::Read),
RemoteMethod::Pair { .. } => ("pair", BrokerEffect::Reach),
RemoteMethod::Clients {} => ("clients", BrokerEffect::Read),
RemoteMethod::Revoke { .. } => ("revoke", BrokerEffect::Write),
};
call.context.act(method, effect, None).await?;
remote_grants().serve(&call)
})
Box::pin(std::future::ready(remote_grants().serve(&call)))
}
impl RemoteGrants {
@@ -5,9 +5,7 @@ use codewhale_workflow_js::ToolCallResponse;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::dsh_broker::{
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::DshService;
use crate::tools::execution_envelope::enforce_tool_denial;
@@ -123,7 +121,6 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
match method {
TerminalMethod::List(NoInput {}) => {
let live = live(&call, "terminal/wait").await?;
call.context.act("list", BrokerEffect::Read, None).await?;
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, terminals = live.terminals.len(), "terminals listed");
Ok(json!({
"instance": live.instance,
@@ -203,13 +203,10 @@ impl Drop for TestOverlay {
fn overlay_granted() -> bool {
#[cfg(test)]
{
TEST_OVERLAY.with(std::cell::Cell::get)
}
#[cfg(not(test))]
{
false
if TEST_OVERLAY.with(std::cell::Cell::get) {
return true;
}
false
}
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
-16
View File
@@ -126,7 +126,6 @@ pub mod dsh_broker;
mod dsh_catalog;
mod dsh_llm;
mod dsh_network;
mod dsh_plugins;
mod dsh_remote;
pub use dsh_remote::{RemoteClient, RemoteDenial, RemoteGrants, RemotePairing, remote_grants};
mod dsh_settings;
@@ -1934,11 +1933,6 @@ impl ExtensionHostManager {
if tools.is_empty() {
return Vec::new();
}
for gate in dsh_broker::BrokerActTool::all() {
if tool_registry.get(gate.name()).is_none() {
tool_registry.register(gate);
}
}
let taken: HashSet<String> = tool_registry
.names()
.into_iter()
@@ -3635,9 +3629,6 @@ impl ManagerShared {
})
}
pub(super) fn broker_services(&self, owner: &OwnerRef) -> Vec<protocol::DshService> {
if let Some(granted) = dsh_broker::test_grants() {
return granted;
}
dsh_broker::granted_services(&self.registry.lock().expect("registry lock"), owner)
}
@@ -3646,13 +3637,6 @@ impl ManagerShared {
owner: &OwnerRef,
service: protocol::DshService,
) -> bool {
if let Some(granted) = dsh_broker::builtin_granted(
&self.registry.lock().expect("registry lock"),
owner,
service,
) {
return granted;
}
if let Some(granted) = dsh_broker::test_grants() {
return granted.contains(&service);
}
+1 -23
View File
@@ -540,7 +540,6 @@ pub enum DshService {
Catalog,
Terminal,
Remote,
Plugins,
}
impl DshService {
@@ -554,19 +553,6 @@ impl DshService {
Self::Remote,
];
pub const BUILTIN_ONLY: [Self; 1] = [Self::Plugins];
pub const EVERY: [Self; 8] = [
Self::Workspace,
Self::Llm,
Self::Network,
Self::Settings,
Self::Catalog,
Self::Terminal,
Self::Remote,
Self::Plugins,
];
#[must_use]
pub fn name(self) -> &'static str {
match self {
@@ -577,20 +563,12 @@ impl DshService {
Self::Catalog => "catalog",
Self::Terminal => "terminal",
Self::Remote => "remote",
Self::Plugins => "plugins",
}
}
#[must_use]
pub fn parse(name: &str) -> Option<Self> {
Self::EVERY
.into_iter()
.find(|service| service.name() == name)
}
#[must_use]
pub fn builtin_only(self) -> bool {
Self::BUILTIN_ONLY.contains(&self)
Self::ALL.into_iter().find(|service| service.name() == name)
}
}
@@ -196,7 +196,7 @@ fn render() -> String {
let _ = writeln!(out, " {name}: {code},");
}
out.push_str("} as const\n\nexport type Direction = 'core_to_host' | 'host_to_core'\n");
let services: Vec<String> = DshService::EVERY
let services: Vec<String> = DshService::ALL
.iter()
.map(|service| quote(service.name()))
.collect();
@@ -676,7 +676,7 @@ fn dsh_broker_is_one_strict_envelope_and_names_only_the_closed_service_list() {
unknown["params"]["service"] = json!("vault");
assert!(parse_host_message(unknown, tier).is_ok());
}
let names: Vec<&str> = DshService::EVERY
let names: Vec<&str> = DshService::ALL
.iter()
.map(|service| service.name())
.collect();
@@ -689,17 +689,10 @@ fn dsh_broker_is_one_strict_envelope_and_names_only_the_closed_service_list() {
"settings",
"catalog",
"terminal",
"remote",
"plugins"
"remote"
]
);
assert_eq!(DshService::BUILTIN_ONLY, [DshService::Plugins]);
assert!(
DshService::ALL
.iter()
.all(|service| !service.builtin_only())
);
for service in DshService::EVERY {
for service in DshService::ALL {
assert_eq!(DshService::parse(service.name()), Some(service));
}
assert_eq!(DshService::parse("Workspace"), None);
@@ -51,8 +51,6 @@ const RESERVED_NAMES: &[&str] = &[
"js_execution",
"request_user_input",
"multi_tool_use.parallel",
"dsh_broker_act",
"dsh_broker_reach",
];
#[derive(Debug, Clone, PartialEq, Eq)]
+19 -53
View File
@@ -226,23 +226,6 @@ fn map_call_error(tool: &str, error: HostCallError) -> ToolError {
}
}
pub(super) fn failed_with_acts(
error: ToolError,
receipts: Option<&Value>,
) -> Result<ToolResultWire, ToolError> {
let holds_acts = receipts.is_some_and(|receipts| receipts.get("broker_acts").is_some());
if !holds_acts {
return Err(error);
}
Ok(ToolResultWire {
content: vec![ContentBlockWire::Text {
text: error.to_string(),
}],
is_error: true,
structured: None,
})
}
pub fn wire_to_result(wire: ToolResultWire, origin: &str) -> ToolResult {
let content = wire
.content
@@ -413,44 +396,26 @@ impl ToolSpec for HostToolSpec {
});
// The deadline stops while one of this call's `core/call`s waits on
// the gate (an approval card), so a person's time is not the tool's.
let outcome = host
let value = host
.call_with_clock(
request,
Some(registration.owner.plugin_id.clone()),
invocation.as_ref().map(InvocationGuard::clock),
)
.await;
let receipts = invocation.as_ref().and_then(InvocationGuard::seal_receipts);
let settled: Result<ToolResultWire, ToolError> = async {
match outcome {
Ok(value) => {
self.check_caller(context)?;
self.manager
.live_host_for(registration)
.await
.map_err(ToolError::not_available)?;
self.check_caller(context)?;
serde_json::from_value(value).map_err(|error| {
ToolError::execution_failed(format!(
"extension tool `{}` returned a malformed result: {error}",
registration.name
))
})
}
Err(error) => Err(map_call_error(&registration.name, error)),
}
}
.await;
let wire = match settled {
Ok(wire) => wire,
Err(error) => {
let wire = failed_with_acts(error, receipts.as_ref())?;
match self.check_caller(context) {
Ok(()) => wire,
Err(stale) => failed_with_acts(stale, receipts.as_ref())?,
}
}
};
.await
.map_err(|error| map_call_error(&registration.name, error))?;
self.check_caller(context)?;
self.manager
.live_host_for(registration)
.await
.map_err(ToolError::not_available)?;
self.check_caller(context)?;
let wire: ToolResultWire = serde_json::from_value(value).map_err(|error| {
ToolError::execution_failed(format!(
"extension tool `{}` returned a malformed result: {error}",
registration.name
))
})?;
let mut result = wire_to_result(wire, &self.origin());
if let Some(Value::Object(metadata)) = result.metadata.as_mut() {
metadata.insert(
@@ -468,9 +433,10 @@ impl ToolSpec for HostToolSpec {
);
}
// What the tool asked the core to run, so the persisted record shows it.
if let (Some(receipts), Some(Value::Object(metadata))) =
(receipts, result.metadata.as_mut())
{
if let (Some(receipts), Some(Value::Object(metadata))) = (
invocation.as_ref().and_then(InvocationGuard::receipts),
result.metadata.as_mut(),
) {
metadata.insert("core_calls".to_string(), receipts);
}
Ok(result)
+3 -1
View File
@@ -18,6 +18,8 @@
//! never render a child session, which is what keeps the orchestrator light at
//! high fanout.
#![allow(dead_code)]
use anyhow::Result;
use codewhale_config::FleetExecConfig;
use codewhale_protocol::fleet::{
@@ -527,7 +529,7 @@ fn parse_exec_terminal_route(value: &serde_json::Value) -> ParsedTerminalRoute {
route.map_or(ParsedTerminalRoute::Invalid, ParsedTerminalRoute::Valid)
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
fn map_exec_terminal_route(line: &str) -> Option<FleetWorkerReportedRoute> {
let value: serde_json::Value = serde_json::from_str(line).ok()?;
match parse_exec_terminal_route(&value) {
+5 -3
View File
@@ -4,6 +4,8 @@
//! observe work through this trait without knowing whether the worker is a
//! local child process or an SSH-backed remote command.
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
@@ -216,7 +218,7 @@ struct LocalWorkerProcess {
#[cfg(unix)]
session_id: libc::pid_t,
#[cfg(unix)]
_parent_death_writer: Option<std::io::PipeWriter>,
parent_death_writer: Option<std::io::PipeWriter>,
#[cfg(windows)]
windows_job: FleetWindowsJob,
host_kind: FleetHostKind,
@@ -385,7 +387,7 @@ impl LocalProcessFleetHostAdapter {
#[cfg(unix)]
session_id: pid as libc::pid_t,
#[cfg(unix)]
_parent_death_writer: parent_death_writer,
parent_death_writer,
#[cfg(windows)]
windows_job,
host_kind,
@@ -1518,7 +1520,7 @@ fn signal_unix_session(
Ok(errors)
}
#[cfg(all(unix, test))]
#[cfg(unix)]
fn unix_pid_is_running(pid: libc::pid_t) -> bool {
if !unix_pid_exists(pid) {
return false;
+2
View File
@@ -5,6 +5,8 @@
//! Artifacts are referenced by bounded metadata; large payloads live on disk
//! and are never embedded in the ledger.
#![allow(dead_code)]
use std::collections::BTreeMap;
use std::collections::HashMap;
#[cfg(test)]
+7 -2
View File
@@ -4,6 +4,8 @@
//! foreground and coordinate logical local workers while later host adapters
//! add real process and SSH execution behind the same records.
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
@@ -1746,7 +1748,11 @@ impl FleetManager {
return Err(start_error);
}
};
Ok(started)
if !started {
return Ok(false);
}
Ok(true)
}
fn start_leased_workers(
@@ -2358,7 +2364,6 @@ impl FleetManager {
}
}
#[cfg(test)]
fn append_worker_event(
&self,
run_id: &FleetRunId,
+3
View File
@@ -1,5 +1,7 @@
//! Fleet profile vocabulary, local profile discovery, and config-facing aliases.
#![allow(dead_code)]
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
@@ -8,6 +10,7 @@ use serde::{Deserialize, Serialize};
use crate::reasoning_preference::ReasoningEffort;
#[allow(unused_imports)]
pub use codewhale_config::{
FleetDelegationHints, FleetLoadout, FleetProfile, FleetProfilePermissions, FleetRole, FleetSlot,
};
+5 -16
View File
@@ -1,16 +1,14 @@
//! Fleet scheduler policy: leases, heartbeats, backpressure, and recovery.
#[cfg(test)]
#![allow(dead_code)]
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::time::Duration;
#[cfg(test)]
use anyhow::anyhow;
use anyhow::{Context, Result};
use anyhow::{Context, Result, anyhow};
use chrono::{DateTime, SecondsFormat, Utc};
use codewhale_protocol::fleet::*;
#[cfg(test)]
use serde_json::Value;
use super::ledger::{FleetLedger, FleetLedgerState, FleetTaskLedgerStatus, FleetTaskState};
@@ -67,7 +65,7 @@ impl FleetScheduler {
self.now = now;
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn tick_run(&self, run_id: &FleetRunId) -> Result<FleetSchedulerReport> {
let mut report = FleetSchedulerReport::default();
self.recover_unhealthy_work(run_id, &mut report, &mut |_, _, _, _| Ok(()))?;
@@ -85,7 +83,7 @@ impl FleetScheduler {
/// and idempotent to call on a fresh process: a task re-leased by an
/// earlier resume is no longer stale at the same instant, and a terminally
/// failed task is never failed or escalated twice.
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn resume_run(&self, run_id: &FleetRunId) -> Result<FleetSchedulerReport> {
self.resume_run_with_restart_callback(run_id, |_, _, _, _| Ok(()))
}
@@ -136,7 +134,6 @@ impl FleetScheduler {
Ok(report)
}
#[cfg(test)]
fn recover_unhealthy_work(
&self,
run_id: &FleetRunId,
@@ -397,7 +394,6 @@ impl FleetScheduler {
Ok(())
}
#[cfg(test)]
fn launch_queued_work(
&self,
run_id: &FleetRunId,
@@ -454,7 +450,6 @@ impl FleetScheduler {
}
}
#[cfg(test)]
fn next_launch(
&self,
run: &FleetRun,
@@ -599,7 +594,6 @@ impl FleetScheduler {
.update_run_status(run_id, status, &self.timestamp())
}
#[cfg(test)]
fn append_worker_event(
&self,
run_id: &FleetRunId,
@@ -621,14 +615,12 @@ impl FleetScheduler {
}
}
#[cfg(test)]
#[derive(Debug, Default)]
struct ActiveCounts {
by_host: BTreeMap<String, usize>,
by_task_class: BTreeMap<String, usize>,
}
#[cfg(test)]
fn active_counts(state: &FleetLedgerState, run: &FleetRun) -> ActiveCounts {
let mut counts = ActiveCounts::default();
for task in active_tasks_for_run(state, &run.id) {
@@ -654,7 +646,6 @@ fn active_counts(state: &FleetLedgerState, run: &FleetRun) -> ActiveCounts {
counts
}
#[cfg(test)]
fn active_tasks_for_run(state: &FleetLedgerState, run_id: &FleetRunId) -> Vec<FleetTaskState> {
state
.tasks
@@ -676,7 +667,6 @@ fn task_spec_for(state: &FleetLedgerState, task: &FleetTaskState) -> Option<Flee
.cloned()
}
#[cfg(test)]
fn host_key(worker: &FleetWorkerSpec) -> String {
match &worker.host {
FleetHostSpec::Local => "local".to_string(),
@@ -686,7 +676,6 @@ fn host_key(worker: &FleetWorkerSpec) -> String {
}
}
#[cfg(test)]
fn task_class(task: &FleetTaskSpec) -> String {
task.metadata
.get("class")
@@ -11,6 +11,8 @@
//! - Mailbox events stream into the Fleet ledger as `FleetWorkerEventPayload`
//! - `FleetWorkerInspection` reads both ledger state and sub-agent worker records
#![allow(dead_code)]
use std::borrow::Cow;
use std::path::PathBuf;
+3 -30
View File
@@ -25,7 +25,6 @@
pub mod rate_limit;
use anyhow::Result;
use codewhale_command_contract::metrics::format_duration;
use codewhale_models::{MessageRequest, MessageResponse, StreamEvent};
use codewhale_runtime::config::{RateLimitRetryPolicy, RetryPolicy};
use serde_json::Value;
@@ -458,18 +457,6 @@ pub enum LlmError {
Other(String),
}
fn opencode_go_hint(message: &str) -> &'static str {
let lowered = message.to_lowercase();
if lowered.contains("codewhale auth set") {
return "";
}
if lowered.contains("opencode go") || lowered.contains("opencode.ai/go") {
" OpenCode Go is a separate provider with its own key; run: codewhale auth set --provider opencode-go"
} else {
""
}
}
impl std::fmt::Display for LlmError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
@@ -477,25 +464,11 @@ impl std::fmt::Display for LlmError {
message,
retry_after,
..
} => {
let hint = opencode_go_hint(message);
match retry_after {
Some(d) => write!(
f,
"Rate limit exceeded: {message}; quota window resets in {}; automatic retry stopped{hint}",
format_duration(*d)
),
None => write!(
f,
"Rate limit exceeded: {message}; automatic retry stopped{hint}"
),
}
}
LlmError::RateLimited { message, .. } => write!(
} => write!(
f,
"Rate limit exceeded: {message}{}",
opencode_go_hint(message)
"Rate limit exceeded: {message}; quota window reset in {retry_after:?}; automatic retry stopped"
),
LlmError::RateLimited { message, .. } => write!(f, "Rate limit exceeded: {message}"),
LlmError::QuotaExhausted(error) => {
write!(f, "Provider plan quota exhausted: {}", error.message)
}
-70
View File
@@ -119,76 +119,6 @@ fn retryability_distinguishes_transient_failures_from_durable_failures() {
}
}
#[test]
fn quota_window_display_prints_reset_time_as_plain_text() {
let windowed = LlmError::QuotaWindow {
message: "Free usage limit reached".into(),
retry_after: Some(Duration::from_secs(70168)),
transmission: RequestTransmission::Rejected,
}
.to_string();
assert!(windowed.contains("resets in 19h29m"), "{windowed}");
assert!(windowed.contains("automatic retry stopped"), "{windowed}");
let open_ended = LlmError::QuotaWindow {
message: "Free usage limit reached".into(),
retry_after: None,
transmission: RequestTransmission::Rejected,
}
.to_string();
assert!(!open_ended.contains("reset"), "{open_ended}");
assert!(
open_ended.contains("automatic retry stopped"),
"{open_ended}"
);
for text in [&windowed, &open_ended] {
assert!(!text.contains("Some("), "{text}");
assert!(!text.contains("None"), "{text}");
}
let hinted = LlmError::QuotaWindow {
message: "OpenCode Go free usage limit reached".into(),
retry_after: Some(Duration::from_secs(70168)),
transmission: RequestTransmission::Rejected,
}
.to_string();
assert_eq!(
hinted
.matches("codewhale auth set --provider opencode-go")
.count(),
1,
"{hinted}"
);
let rate_limited = LlmError::RateLimited {
message: "OpenCode Go free usage limit reached".into(),
retry_after: None,
}
.to_string();
assert_eq!(
rate_limited
.matches("codewhale auth set --provider opencode-go")
.count(),
1,
"{rate_limited}"
);
let already_hinted = LlmError::QuotaWindow {
message: hinted.clone(),
retry_after: Some(Duration::from_secs(70168)),
transmission: RequestTransmission::Rejected,
}
.to_string();
assert_eq!(
already_hinted
.matches("codewhale auth set --provider opencode-go")
.count(),
1,
"{already_hinted}"
);
}
#[test]
fn http_response_boundary_classifies_status_contract() {
for status in [408, 409, 499, 500, 502, 503, 504] {
+17 -44
View File
@@ -1669,10 +1669,6 @@ pub trait McpTransport: Send + Sync {
async fn recv(&mut self) -> Result<Vec<u8>>;
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
self.send(msg).await
}
/// Record the protocol revision negotiated at `initialize`. Only the
/// Streamable HTTP transport uses it (the `MCP-Protocol-Version` header on
/// subsequent requests); stdio and legacy SSE have no header channel, so
@@ -3129,30 +3125,6 @@ impl McpConnection {
result
}
async fn answer_server_request(&mut self, reply: &serde_json::Value) {
let result = match serde_json::to_vec(reply) {
Ok(bytes) => {
let cancel_token = self.cancel_token.clone();
tokio::select! {
biased;
_ = cancel_token.cancelled() => {
Err(anyhow::anyhow!("MCP connection was cancelled"))
}
result = self.transport.send_reply(bytes) => result,
}
}
Err(err) => Err(err.into()),
};
if let Err(err) = result {
tracing::debug!(
target: "mcp",
server = %self.name,
error = %err,
"could not answer an MCP server request"
);
}
}
/// Handshake and discovery receive: each frame wait is bounded by the read
/// knob, and a server that stays silent past it is treated as dead.
async fn recv(&mut self, expected_id: String) -> Result<serde_json::Value> {
@@ -3230,22 +3202,6 @@ impl McpConnection {
{
self.catalog_stale = true;
}
if let Some(method) = value.get("method") {
if let Some(id) = value.get("id") {
let method = method.as_str().unwrap_or_default();
let reply = if method == "ping" {
serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {}})
} else {
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"error": {"code": -32601, "message": format!("Method not found: {method}")}
})
};
self.answer_server_request(&reply).await;
}
continue;
}
if response_id_matches(value.get("id"), &expected_id) {
// Marks the connection stale so it is rebuilt, but this is a
// reply to the request, so it never qualifies for a replay.
@@ -3736,6 +3692,23 @@ impl McpPool {
Ok(pool)
}
/// Construct a source-aware empty pool after the initial config load
/// failed. Keeping the source paths means a later edit or explicit
/// `/mcp reload` can recover in-process instead of pinning the session to
/// an ad-hoc pool that has no files to re-read.
pub fn empty_with_workspace_config_sources(
path: &std::path::Path,
workspace: &Path,
plugins: Arc<codewhale_plugins::PluginRegistry>,
) -> Result<Self> {
Self::empty_with_workspace_config_sources_and_auth_context(
path,
workspace,
plugins,
process_mcp_auth_context(),
)
}
pub fn empty_with_workspace_config_sources_and_auth_context(
path: &std::path::Path,
workspace: &Path,
+68 -1
View File
@@ -404,6 +404,30 @@ pub fn persist_decisions(
})
}
/// Filter candidates that still need a user decision for this content hash.
#[allow(dead_code)] // used by future selector UI + unit tests
pub fn candidates_needing_consent(
candidates: &[ImportCandidate],
store: &ImportConsentStore,
) -> Vec<ImportCandidate> {
candidates
.iter()
.filter(|c| {
let key = consent_key(&c.source_path, &c.content_hash);
match store.entries.get(&key) {
Some(entry) if entry.decision == ImportDecision::Decline => false,
Some(entry) if entry.decision == ImportDecision::Approve => {
// Re-prompt only when the specific server was not part of
// the prior approval list (partial approval).
!entry.servers.iter().any(|s| s == &c.name)
}
_ => true,
}
})
.cloned()
.collect()
}
/// Apply approvals: returns servers to merge into user mcp.json.
/// Hard-blocked candidates are never returned even if decision is Approve.
#[cfg(any(test, feature = "test-support"))]
@@ -1069,7 +1093,7 @@ mod tests {
}
#[test]
fn unrelated_source_rewrites_keep_review_tokens() {
fn unrelated_source_rewrites_keep_review_tokens_and_declines() {
with_import_context(|context| {
let source = write_claude_json(
&context.home,
@@ -1092,6 +1116,23 @@ mod tests {
)
.unwrap();
assert!(receipt.imported);
let candidates = discover_from_json_file(&source, ExternalMcpSourceKind::ClaudeJson);
let mut store = ImportConsentStore::default();
let decisions = HashMap::from([("y".to_string(), ImportDecision::Decline)]);
record_decisions(&mut store, &candidates, &decisions, 1);
fs::write(
&source,
r#"{"numStartups":3,"mcpServers":{"x":{"command":"echo"},"y":{"command":"echo"}}}"#,
)
.unwrap();
let refreshed = discover_from_json_file(&source, ExternalMcpSourceKind::ClaudeJson);
let needing: Vec<_> = candidates_needing_consent(&refreshed, &store)
.into_iter()
.map(|c| c.name)
.collect();
// `y` stays declined; `x` was never decided in this store.
assert_eq!(needing, ["x"]);
});
}
@@ -1167,6 +1208,32 @@ mod tests {
assert!(!config.servers["ok"].is_enabled());
}
#[test]
fn declined_consent_skips_reprompt_until_hash_changes() {
let dir = tempdir().unwrap();
let path = write_claude_json(
dir.path(),
r#"{"mcpServers":{"x":{"command":"echo","enabled":true}}}"#,
);
let candidates = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson);
let mut store = ImportConsentStore::default();
let mut decisions = HashMap::new();
decisions.insert("x".into(), ImportDecision::Decline);
record_decisions(&mut store, &candidates, &decisions, 1);
let needing = candidates_needing_consent(&candidates, &store);
assert!(needing.is_empty(), "declined should not re-prompt");
// Content change → new hash → re-prompt.
fs::write(
&path,
r#"{"mcpServers":{"x":{"command":"echo","args":["changed"],"enabled":true}}}"#,
)
.unwrap();
let refreshed = discover_from_json_file(&path, ExternalMcpSourceKind::ClaudeJson);
let needing = candidates_needing_consent(&refreshed, &store);
assert_eq!(needing.len(), 1);
}
#[test]
fn provenance_display_includes_source_and_hash() {
let dir = tempdir().unwrap();
-15
View File
@@ -177,21 +177,6 @@ impl McpTransport for HttpTransport {
}
}
async fn send_reply(&mut self, msg: Vec<u8>) -> Result<()> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => {
transport.send(msg).await.map_err(|err| match err {
StreamableSendError::Incompatible(detail)
| StreamableSendError::StaleSession(detail) => anyhow::anyhow!(
"MCP Streamable HTTP refused a reply to a server request: {detail}"
),
StreamableSendError::Other(err) => err,
})
}
HttpTransportMode::Sse(transport) => transport.send(msg).await,
}
}
async fn recv(&mut self) -> Result<Vec<u8>> {
match &mut self.mode {
HttpTransportMode::Streamable(transport) => transport.recv().await,
+17
View File
@@ -1607,6 +1607,23 @@ pub fn server_supports_oauth_login(server: &McpServerConfig) -> bool {
&& !server_has_manual_authorization(server)
}
/// Whether the shared token store already holds a usable credential for this
/// server, with its binding revalidated against discovery. Used by the
/// authenticate tool's already-authorized branch.
pub async fn has_usable_stored_tokens(
name: &str,
server: &McpServerConfig,
network_policy: Option<&NetworkPolicyDecider>,
) -> Result<bool> {
has_usable_stored_tokens_with_auth_context(
name,
server,
network_policy,
super::process_mcp_auth_context(),
)
.await
}
pub async fn has_usable_stored_tokens_with_auth_context(
name: &str,
server: &McpServerConfig,
-128
View File
@@ -3314,80 +3314,6 @@ async fn call_method_skips_notifications_and_unmatched_responses() {
assert_eq!(sent[0]["method"], "tools/call");
}
#[tokio::test]
async fn call_method_answers_server_requests_instead_of_taking_them_as_the_reply() {
let sent = Arc::new(Mutex::new(Vec::new()));
let transport = ScriptedValueTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": "1", "method": "roots/list"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
]),
};
let mut conn = test_connection(Box::new(transport));
let result = conn
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
.await
.unwrap();
assert_eq!(result, serde_json::json!({"ok": true}));
let sent = sent.lock().unwrap();
assert_eq!(sent.len(), 3);
assert_eq!(
sent[1],
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}})
);
assert_eq!(sent[2]["id"], "1");
assert_eq!(sent[2]["error"]["code"], -32601);
}
struct ReplyRefusingTransport {
sent: Arc<Mutex<Vec<serde_json::Value>>>,
responses: VecDeque<Vec<u8>>,
}
#[async_trait::async_trait]
impl McpTransport for ReplyRefusingTransport {
async fn send(&mut self, msg: Vec<u8>) -> Result<()> {
let msg: serde_json::Value = serde_json::from_slice(&msg)?;
anyhow::ensure!(msg.get("method").is_some(), "status=405 Method Not Allowed");
self.sent.lock().unwrap().push(msg);
Ok(())
}
async fn recv(&mut self) -> Result<Vec<u8>> {
self.responses
.pop_front()
.context("scripted transport exhausted")
}
}
#[tokio::test]
async fn call_method_keeps_its_reply_when_answering_a_server_request_fails() {
let sent = Arc::new(Mutex::new(Vec::new()));
let transport = ReplyRefusingTransport {
sent: Arc::clone(&sent),
responses: VecDeque::from([
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "method": "ping"})),
json_frame(serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {"ok": true}})),
]),
};
let mut conn = test_connection(Box::new(transport));
let result = conn
.call_method("tools/call", serde_json::json!({"name": "echo"}), 1)
.await
.unwrap();
assert_eq!(result, serde_json::json!({"ok": true}));
assert_eq!(conn.state, ConnectionState::Ready);
let sent = sent.lock().unwrap();
assert_eq!(sent.len(), 1);
assert_eq!(sent[0]["method"], "tools/call");
}
#[tokio::test]
async fn call_method_invalid_json_includes_server_output_preview() {
let sent = Arc::new(Mutex::new(Vec::new()));
@@ -6844,60 +6770,6 @@ async fn custom_headers_applied_to_get_preflight() {
);
}
#[tokio::test]
async fn refused_reply_to_a_server_request_does_not_fall_back_to_sse() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
let _env = crate::test_support::lock_test_env();
let _lock = lock_mcp_loopback_tests().await;
let _no_proxy = crate::test_support::EnvVarGuard::set("NO_PROXY", "*,127.0.0.1");
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_srv = Arc::clone(&requests);
let server = tokio::spawn(async move {
loop {
let (mut socket, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let n = socket.read(&mut buf).await.unwrap();
let request_line = String::from_utf8_lossy(&buf[..n])
.lines()
.next()
.unwrap_or_default()
.to_string();
requests_srv.lock().unwrap().push(request_line);
socket
.write_all(
b"HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n",
)
.await
.unwrap();
}
});
let url = format!("http://{addr}/mcp");
let mut transport = HttpTransport::new(
test_mcp_http_client(&url),
url,
tokio_util::sync::CancellationToken::new(),
Duration::from_secs(2),
);
let result = transport
.send_reply(json_frame(
serde_json::json!({"jsonrpc": "2.0", "id": 1, "result": {}}),
))
.await;
server.abort();
assert!(result.is_err(), "a refused reply reports the refusal");
let requests = requests.lock().unwrap();
assert_eq!(requests.len(), 1, "requests seen: {requests:?}");
assert!(requests[0].starts_with("POST "), "{requests:?}");
}
// === add_runtime_server_config conflict tests ===
#[test]
+10 -26
View File
@@ -244,38 +244,22 @@ pub async fn capture(
if let Some(snapshot) = supplied {
return snapshot.render().map(Some);
}
#[cfg(any(test, feature = "test-support"))]
{
if let Some(message) = TEST_ACCOUNT_LOAD_FAILURE.get() {
anyhow::bail!(message);
}
if !account_api_base_is_loopback() {
return Ok(crate::prompts::load_user_constitution_block());
}
}
// Unit tests never read an operator's account credentials. Transport tests
// exercise load_from with an explicitly isolated store instead.
#[cfg(not(any(test, feature = "test-support")))]
if let Some(snapshot) = load(profile).await? {
return snapshot.render().map(Some);
}
#[cfg(any(test, feature = "test-support"))]
{
let _ = profile;
if let Some(message) = TEST_ACCOUNT_LOAD_FAILURE.get() {
anyhow::bail!(message);
}
}
Ok(crate::prompts::load_user_constitution_block())
}
#[cfg(any(test, feature = "test-support"))]
fn account_api_base_is_loopback() -> bool {
std::env::var(codewhale_secrets::account::ACCOUNT_API_BASE_ENV)
.ok()
.and_then(|value| normalize_runtime_account_api_base(&value))
.and_then(|base| reqwest::Url::parse(&base).ok())
.and_then(|url| url.host_str().map(str::to_owned))
.is_some_and(|host| {
host.eq_ignore_ascii_case("localhost")
|| host
.trim_start_matches('[')
.trim_end_matches(']')
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback())
})
}
#[cfg(any(test, feature = "test-support"))]
thread_local! {
static TEST_ACCOUNT_LOAD_FAILURE: std::cell::Cell<Option<&'static str>> =
+1
View File
@@ -1,3 +1,4 @@
#![allow(dead_code)]
//! System prompt composition.
//!
//! Prompts are assembled from composable layers loaded at compile time from
+1 -1
View File
@@ -544,7 +544,7 @@ pub enum ResolvedProviderReadiness {
impl ResolvedProviderReadiness {
pub fn label(&self) -> Cow<'static, str> {
match self {
Self::MissingKey => Cow::Borrowed("needs key"),
Self::MissingKey => Cow::Borrowed("missing key"),
Self::MissingLogin => Cow::Borrowed("missing login"),
Self::ExternalConsentPendingSelection => {
Cow::Borrowed("external consent · select to check")
+1 -1
View File
@@ -1715,7 +1715,7 @@ fn number(value: Option<&Value>, keys: &[&str]) -> Option<i64> {
}
fn redact(text: &str) -> String {
codewhale_sanitize::redact::redact_secrets(text)
codewhale_secrets::redact::redact_secrets(text)
}
use codewhale_commands::diagnostics_reports::receipts::{bounded, plural};
+11 -112
View File
@@ -401,84 +401,24 @@ pub fn resolve_model(
)?
.is_some_and(|entry| entry.fetched_at > 0)
{
return Err(model_not_available(
identity.provider,
model.unwrap_or("auto"),
));
return Err(
codewhale_agent::ModelResolutionError::ModelNotAvailableForProvider {
provider: identity.provider,
requested: model.unwrap_or("auto").to_string(),
}
.into(),
);
}
if !crate::provider_catalog_live::provider_owns_live_catalog(identity.provider, base_url) {
admit_reviewed_selection(identity.provider, model.filter(|model| *model != "auto"))?;
codewhale_agent::ModelRegistry::default().resolve(
model.filter(|model| *model != "auto"),
Some(identity.provider),
)?;
}
}
Ok(route?.candidate)
}
fn model_not_available(provider: ProviderKind, requested: &str) -> anyhow::Error {
anyhow::anyhow!(
"model '{requested}' is not available from provider '{}'",
provider.as_str()
)
}
fn admit_reviewed_selection(provider: ProviderKind, requested: Option<&str>) -> Result<()> {
let selections = &codewhale_config::catalog::reviewed::bundled_reviewed().selections;
let rows = || {
selections
.iter()
.filter(move |row| ProviderKind::parse_config_identity(&row.provider) == Some(provider))
};
let normalize = |value: &str| value.trim().to_ascii_lowercase();
let row_matches = |row: &codewhale_config::catalog::reviewed::ModelSelection, wanted: &str| {
let wanted = normalize(wanted);
normalize(&row.id) == wanted || row.aliases.iter().any(|alias| normalize(alias) == wanted)
};
let default_model = provider.provider().default_model();
let requested = requested.filter(|name| !name.trim().is_empty());
let Some(name) = requested else {
if rows().any(|row| row_matches(row, default_model)) {
return Ok(());
}
if !default_model.trim().is_empty() && provider != ProviderKind::Ollama {
return Ok(());
}
if rows().next().is_none() {
anyhow::bail!(
"provider '{}' has no registered default model",
provider.as_str()
);
}
anyhow::bail!(
"provider '{}' declares default model '{default_model}', but that model is not registered for the provider",
provider.as_str()
);
};
let trimmed = name.trim();
let admitted = match provider {
ProviderKind::Ollama | ProviderKind::OllamaCloud => true,
ProviderKind::OpencodeGo => codewhale_config::opencode_go_model_id(name)
.is_some_and(|canonical| rows().any(|row| row.id.eq_ignore_ascii_case(canonical))),
_ => rows().any(|row| row_matches(row, name)),
} || match provider {
ProviderKind::Atlascloud => !trimmed.is_empty() && trimmed.contains('/'),
ProviderKind::Arcee => !trimmed.is_empty(),
ProviderKind::XiaomiMimo => !trimmed.is_empty() && !trimmed.chars().any(char::is_control),
_ => false,
} || (!default_model.trim().is_empty()
&& trimmed.eq_ignore_ascii_case(default_model.trim()));
if admitted {
return Ok(());
}
if rows().next().is_none() {
anyhow::bail!(
"provider '{}' has no registered model for '{name}'",
provider.as_str()
);
}
Err(model_not_available(provider, name))
}
/// Save one explicit route preference after atomically adopting legacy choices.
pub fn set(path: &Path, key: &str, value: &str) -> Result<()> {
persistence::mutate_config_document(path, |doc| set_document(path, doc, key, value))
@@ -1142,45 +1082,4 @@ model = "Other-X"
assert_eq!(loaded.default_model(), selected_route(&path)?.1);
Ok(())
}
#[test]
fn reviewed_admission_keeps_passthrough_and_default_contracts() {
for (provider, model) in [
(ProviderKind::Atlascloud, Some("Qwen/Qwen3-Coder")),
(ProviderKind::Arcee, Some("trinity-future-thinking")),
(ProviderKind::XiaomiMimo, Some("mimo-custom-id")),
(ProviderKind::Ollama, Some("qwen2.5-coder:7b")),
(ProviderKind::OpencodeGo, Some("deepseek-v4-pro")),
(ProviderKind::OllamaCloud, None),
(ProviderKind::Deepseek, None),
] {
admit_reviewed_selection(provider, model)
.unwrap_or_else(|error| panic!("{provider:?} {model:?}: {error}"));
}
for kind in ProviderKind::all() {
let default = kind.provider().default_model();
if default.trim().is_empty() {
continue;
}
admit_reviewed_selection(*kind, Some(default))
.unwrap_or_else(|error| panic!("default of {kind:?}: {error}"));
}
}
#[test]
fn reviewed_admission_fails_closed_without_borrowing_another_provider() {
let error = admit_reviewed_selection(ProviderKind::Atlascloud, Some("not-in-atlas"))
.expect_err("unknown model must not become the provider default");
assert_eq!(
error.to_string(),
"model 'not-in-atlas' is not available from provider 'atlascloud'"
);
let error = admit_reviewed_selection(ProviderKind::Ollama, None)
.expect_err("the placeholder default must not resolve");
assert!(
error.to_string().contains("default model 'unknown'"),
"{error}"
);
assert!(admit_reviewed_selection(ProviderKind::Moonshot, Some("glm-5.2")).is_err());
}
}
+2 -2
View File
@@ -11,7 +11,7 @@
//! - the provider enum and the non-secret configured route key,
//! - the canonical wire model id,
//! - a **normalized, redacted** endpoint identity (URL userinfo and sensitive
//! query values masked by [`codewhale_sanitize::sanitize::redact_url_for_display`]),
//! query values masked by [`codewhale_secrets::sanitize::redact_url_for_display`]),
//! - a one-way credential *generation* digest that is never rendered.
//!
//! The credential generation is deliberately taken over the endpoint **and** the
@@ -47,7 +47,7 @@ pub fn endpoint_identity(base_url: &str) -> String {
if reqwest::Url::parse(trimmed).is_err() {
return OPAQUE_ENDPOINT.to_string();
}
codewhale_sanitize::sanitize::redact_url_for_display(trimmed)
codewhale_secrets::sanitize::redact_url_for_display(trimmed)
.trim_end_matches('/')
.to_string()
}
+2 -1
View File
@@ -11615,9 +11615,10 @@ async fn compact_thread_receipt_keeps_exact_named_custom_identity() -> Result<()
#[tokio::test]
async fn compact_thread_with_real_engine_reaches_terminal_status() -> Result<()> {
// A real engine takes the workspace coordination lock exclusively, and
// nextest runs tests in parallel processes with this crate as their working
// directory. A `"."` workspace would therefore be the crate directory
// itself, shared by every test process.
// itself, and two exclusive holders of it cannot both start.
let workspace = tempfile::tempdir()?;
let manager = RuntimeThreadManager::open(
{
+18 -1
View File
@@ -2009,7 +2009,7 @@ impl SavedTurnOutcome {
/// Build the persisted record for a failed turn. Redacts before bounding
/// so a cut can never leave half a secret behind.
pub fn failed(error: &str, after_message_count: usize) -> Self {
let redacted = codewhale_sanitize::redact::redact_secrets(error.trim());
let redacted = codewhale_secrets::redact::redact_secrets(error.trim());
let error = if redacted.chars().count() > MAX_SAVED_TURN_OUTCOME_ERROR_CHARS {
let mut cut: String = redacted
.chars()
@@ -4309,6 +4309,23 @@ impl SessionManager {
self.save_session_inner(session, JournalPrune::Excess, true)
}
#[cfg(any(test, feature = "test-support"))]
pub fn commit_completed_session(&self, session: SavedSession) -> io::Result<io::Result<()>> {
let session_id = session.metadata.id.clone();
let exec_mode = session.metadata.mode.as_deref() == Some("exec");
let clear = self
.with_session_write_admission(&session_id, || {
let persisted = self.load_session_write_metadata(&session_id)?;
self.write_session_admitted(session, JournalPrune::Excess, true, &persisted)?;
Ok(self.clear_session_checkpoint_admitted(&session_id))
})?
.ok_or_else(Self::retired_session_write_error)?;
if let Err(error) = self.cleanup_old_sessions_for_mode(None, Some(exec_mode)) {
tracing::warn!(session_id = %session_id, %error, "completion was committed but retention could not be inspected");
}
Ok(clear)
}
/// Make a saved document whose file exceeds the full-history transport
/// bound readable under it. The bound counts what a canonical handoff
/// carries: compact JSON without the journal-derived `messages` copy
+2 -2
View File
@@ -203,7 +203,7 @@ pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
/// Mask credential-shaped substrings.
///
/// The shared export sanitizer ([`codewhale_sanitize::sanitize::sanitize_text`],
/// The shared export sanitizer ([`codewhale_secrets::sanitize::sanitize_text`],
/// also used by `/export`) runs first: it covers `Bearer <token>`, JWTs,
/// URL credentials, PEM blocks, and quoted JSON/TOML keyed secrets. A
/// token-level pass then masks the extra bare prefixes (AWS, Google, Hugging
@@ -211,7 +211,7 @@ pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
/// peek line is cheap; leaking a key over a LAN is not.
#[must_use]
pub fn redact(text: &str) -> (String, bool) {
let sanitized = codewhale_sanitize::sanitize::sanitize_text(text);
let sanitized = codewhale_secrets::sanitize::sanitize_text(text);
// The sanitizer also strips control bytes and normalises URLs, so compare
// placeholder counts rather than bytes to decide whether it redacted.
let mut redacted = redaction_marks(&sanitized) > redaction_marks(text);
+7 -62
View File
@@ -82,7 +82,13 @@ pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> Approva
| "exec_shell_wait"
| "exec_shell_interact"
| "exec_wait"
| "exec_interact" => exact_shell_call_key(tool_name, input),
| "exec_interact" => {
let mut execution_input = input.clone();
if let Some(fields) = execution_input.as_object_mut() {
fields.remove("description");
}
format!("shell:{tool_name}:{}", hash_json_value(&execution_input))
}
"fetch_url" | "web.fetch" | "web_fetch" => {
let host = parse_host(input);
format!("net:{host}")
@@ -107,9 +113,6 @@ pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -
let paths_hash = hash_patch_paths(input);
format!("patch:{paths_hash}")
}
"exec_shell" | "task_shell_start" if shell_call_carries_stdin(input) => {
exact_shell_call_key(tool_name, input)
}
"exec_shell" | "task_shell_start" => shell_command_grant_scope(input),
// Interact and wait calls carry no command, only input for a live
// session. Keying them on the (empty) command prefix gave every one
@@ -444,20 +447,6 @@ pub fn computer_use_batch_hidden_gate(tool_name: &str, input: &Value) -> Option<
})
}
fn exact_shell_call_key(tool_name: &str, input: &serde_json::Value) -> String {
let mut execution_input = input.clone();
if let Some(fields) = execution_input.as_object_mut() {
fields.remove("description");
}
format!("shell:{tool_name}:{}", hash_json_value(&execution_input))
}
fn shell_call_carries_stdin(input: &serde_json::Value) -> bool {
["stdin", "input", "data"]
.iter()
.any(|key| input.get(*key).is_some_and(|value| !value.is_null()))
}
/// The session-grant scope for a shell command.
///
/// A simple command whose family is in the arity dictionary keeps the
@@ -1032,50 +1021,6 @@ mod tests {
}
}
#[test]
fn shell_run_grant_does_not_cover_a_different_stdin() {
for tool in ["Bash", "bash", "exec_shell", "task_shell_start"] {
let plain = json!({"command": "python3 -"});
let hello = json!({"command": "python3 -", "stdin": "print(1)"});
let other = json!({"command": "python3 -", "stdin": "print(2)"});
let granted = build_approval_grouping_key(tool, &plain);
let with_stdin = build_approval_grouping_key(tool, &hello);
assert_ne!(granted, with_stdin, "{tool}: stdin is part of what runs");
assert_ne!(
with_stdin,
build_approval_grouping_key(tool, &other),
"{tool}: different stdin must not share a grant"
);
assert_eq!(
with_stdin,
build_approval_grouping_key(tool, &hello),
"{tool}: the same call keeps its grant"
);
for alias in ["input", "data"] {
let aliased = json!({"command": "python3 -", alias: "print(2)"});
assert_ne!(
granted,
build_approval_grouping_key(tool, &aliased),
"{tool}: `{alias}` carries stdin too"
);
assert_ne!(
with_stdin,
build_approval_grouping_key(tool, &aliased),
"{tool}: `{alias}` input is not the `stdin` input of another call"
);
}
assert_eq!(
granted,
build_approval_grouping_key(tool, &json!({"command": "python3 -", "stdin": null})),
"{tool}: a null stdin is no stdin"
);
}
let family_plain = build_approval_grouping_key("Bash", &json!({"command": "git status"}));
let family_stdin =
build_approval_grouping_key("Bash", &json!({"command": "git status", "stdin": "x"}));
assert_ne!(family_plain, family_stdin);
}
#[test]
fn grouping_key_grants_mcp_tools_as_reviewed_kinds() {
// A session grant for a reviewed plugin MCP tool is the kind
+1 -8
View File
@@ -165,7 +165,7 @@ fn generic_summary(locale: Locale, name: &str, input: &Value, workspace: Option<
fn is_delegated_work_tool(tool_name: &str) -> bool {
matches!(
tool_name,
"task_create" | "automation_create" | "automation_update" | "send_later"
"task_create" | "automation_create" | "automation_update"
)
}
@@ -728,13 +728,6 @@ mod tests {
assert!(summary.contains("Auto-approve: on"), "{summary}");
assert!(summary.contains("Workspace: /x"), "{summary}");
let summary = approval_summary(
"send_later",
&json!({"action": "schedule", "delay_minutes": 5, "message": "m", "workspace": "/elsewhere"}),
None,
);
assert!(summary.contains("Workspace: /elsewhere"), "{summary}");
let zh = approval_summary_in(
Locale::ZhHans,
"automation",
-1
View File
@@ -76,7 +76,6 @@ pub mod send_later;
pub mod session;
pub mod shell;
pub mod shell_output;
pub mod show_data;
pub mod skill;
pub mod spec;
pub mod speech;
-6
View File
@@ -1043,11 +1043,6 @@ impl ToolRegistryBuilder {
self.with_tool(Arc::new(PresentTool))
}
#[must_use]
pub fn with_show_data_tool(self) -> Self {
self.with_tool(Arc::new(super::show_data::ShowDataTool))
}
/// Include shell execution tools.
///
/// New turns expose lowercase `bash`; uppercase `Bash` remains a hidden
@@ -1545,7 +1540,6 @@ impl ToolRegistryBuilder {
let builder = self
.with_file_tools()
.with_present_tool()
.with_show_data_tool()
.with_note_tool()
.with_search_tools()
.with_user_input_tool(user_input_limits)
Loaded 100 of 365 files, more files were not shown because too many files have changed in this diff. Show more