Compare commits

..
Author SHA1 Message Date
codewhale-agent[bot] 624ef93f17 chore(catalog): refresh the Models.dev offline seed
Generated by .github/workflows/catalog-refresh.yml from models.dev. No LLM wrote these rows.
2026-11-10 22:14:08 +00:00
976 changed files with 23705 additions and 70084 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": {
-6
View File
@@ -65,12 +65,6 @@ crates/engine/extension-host/dist/** text eol=lf
crates/engine/tests/fixtures/extension_host/** text eol=lf
crates/engine/extension-host/src/dsh/dsh-message-compat.js text eol=lf
# DeepSeek Harness creator pins are compared byte for byte against recorded
# SHA-256 values (UPSTREAM.json) and embedded with include_bytes!(); a CRLF
# Windows checkout must not rewrite any of them.
crates/skills/assets/dsh-creator/** text eol=lf
crates/engine/extension-host/src/dsh/upstream/creator/** text eol=lf
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
*.rs text eol=lf
+8 -51
View File
@@ -4,12 +4,11 @@
# alias = Display Name <[email protected]>
#
# The right-hand side must use GitHub's numeric noreply address so a harvested
# commit authored with it, or a Co-authored-by trailer in the commit's final
# trailer block, links to the contributor's account. GitHub resolves the
# address by its numeric id, so the id must be the contributor's own
# (`gh api users/<login> --jq .id`). The left-hand side may be a GitHub login,
# old-style noreply address, raw email from a contributor commit, or local
# machine email seen in older harvested history.
# commit authored with it links to the contributor's account. Only commit
# authorship puts someone in the repository's contributor list; a co-author
# trailer shows on their profile but not there. The left-hand side may be a
# GitHub login, old-style noreply address, raw email from a contributor commit,
# or local machine email seen in older harvested history.
hmbown = Hmbown <[email protected]>
[email protected] = Hmbown <[email protected]>
@@ -51,7 +50,6 @@ Oliver-ZPLiu = Oliver-ZPLiu <[email protected]>
idling11 = idling11 <[email protected]>
Hanmiao Li = idling11 <[email protected]>
[email protected] = idling11 <[email protected]>
894876246 = idling11 <[email protected]>
angziii = angziii <[email protected]>
aboimpinto = aboimpinto <[email protected]>
Paulo Aboim Pinto = aboimpinto <[email protected]>
@@ -81,24 +79,19 @@ Vince = Liu-Vince <[email protected]>
[email protected] = Liu-Vince <[email protected]>
LI-Jialu = LI-Jialu <[email protected]>
JiarenWang = JiarenWang <[email protected]>
wdw8276 = kitty <3972439+mars-base@users.noreply.github.com>
mars-base = kitty <[email protected]>
[email protected] = kitty <[email protected]>
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
pengyou200902 = pengyou200902 <[email protected]>
linzhiqin2003 = linzhiqin2003 <[email protected]>
LING71671 = LING71671 <[email protected]>
[email protected] = LING71671 <[email protected]>
JasonOA888 = JasonOA888 <[email protected]>
Inference1 = Inference1 <[email protected]>
hongqitai = hongqitai <[email protected]>
gordonlu = gordonlu <[email protected]>
angus-guo = gus <217034332+im[email protected]>
imgusguo = gus <217034332+im[email protected]>
[email protected] = gus <[email protected]>
angus-guo = gus <217034332+angus-[email protected]>
gus.guo@tec-do.com = gus <217034332+angus-[email protected]>
gaord = gaord <[email protected]>
Ben Gao = gaord <[email protected]>
[email protected] = gaord <[email protected]>
bengao168 = gaord <[email protected]>
MXAntian = MXAntian <[email protected]>
[email protected] = MXAntian <[email protected]>
DarrellThomas = Darrell Thomas <[email protected]>
@@ -254,7 +247,6 @@ [email protected] = zhuowp <[email protected]>
[email protected] = 秋月凉梦 <[email protected]>
asto18089 = asto18089 <[email protected]>
[email protected] = asto18089 <[email protected]>
xiechimon = Xmon Dai <[email protected]>
yrk111222 = Rongkun Yan <[email protected]>
VincentCorleone = Vincent Corleone <[email protected]>
@@ -286,38 +278,3 @@ Felixkw12 = Felix Wunderlich <[email protected]>
[email protected] = Felix Wunderlich <[email protected]>
Felix Wunderlich = Felix Wunderlich <[email protected]>
[email protected] = luochen211 <[email protected]>
AiurArtanis = AiurArtanis <[email protected]>
[email protected] = AiurArtanis <[email protected]>
Apeiron0w0 = Apeiron <[email protected]>
[email protected] = Apeiron <[email protected]>
[email protected] = ChaceLyee2101 <[email protected]>
AmosLeeeeeeee = ChaceLyee2101 <[email protected]>
ddlw = ddlw <[email protected]>
mo-vic = movic <[email protected]>
[email protected] = movic <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
[email protected] = merchloubna70-dot <[email protected]>
xulongzhe = luola <[email protected]>
[email protected] = luola <[email protected]>
ZhulongNT = SONG ZHENRUI <[email protected]>
[email protected] = SONG ZHENRUI <[email protected]>
xiluoduyu = xiluoduyu <[email protected]>
[email protected] = xiluoduyu <[email protected]>
wlon = Stephen Xu <[email protected]>
[email protected] = Stephen Xu <[email protected]>
krisclarkdev = Kristopher Clark <[email protected]>
THatch26 = THatch26 <[email protected]>
jeoor = Kayro <[email protected]>
harvey2011888 = harvey2011888 <[email protected]>
duanchao-lab = DC <[email protected]>
dumbjack = dumbjack <[email protected]>
[email protected] = dumbjack <[email protected]>
AquaZhang = IceTear <[email protected]>
disappear00 = idontknow <[email protected]>
namezzb = sailorZ <[email protected]>
alphapku-ux = Alpha Wu <[email protected]>
BX166 = BX166 <[email protected]>
c020627 = c020627 <[email protected]>
[email protected] = Parman Mohammadalizadeh <[email protected]>
Parman Mohammadalizadeh = Parman Mohammadalizadeh <[email protected]>
+6
View File
@@ -1,2 +1,8 @@
# Default owner for everything in the repo.
* @Hmbown
# AI code review is advisory and not wired through CODEOWNERS: GitHub CODEOWNERS
# only accepts users and teams, not bots. @Hmbown stays the human code owner.
# - Claude: .github/workflows/claude-review.yml (GitHub Actions).
# - Codex/ChatGPT: the ChatGPT Codex cloud integration (chatgpt.com/codex ->
# connect GitHub -> enable Code review), authed by the ChatGPT subscription.
-19
View File
@@ -1,19 +0,0 @@
# Getting help with Codewhale
- **A question about using Codewhale:** ask in
[Discussions Q&A](https://github.com/codewhale-hq/Codewhale/discussions/categories/q-a)
or on [Discord](https://discord.gg/37gfS3ksug). The
[user guide](../docs/GUIDE.md) and the [documentation index](../docs/README.md)
answer many questions already.
- **A bug, a crash or behavior that surprised you:** open a
[bug report](https://github.com/codewhale-hq/Codewhale/issues/new?template=bug_report.yml).
`codewhale doctor` prints the version and setup details a report needs.
- **An idea or feedback:** open an
[issue](https://github.com/codewhale-hq/Codewhale/issues/new/choose), or talk it
through first in
[Ideas](https://github.com/codewhale-hq/Codewhale/discussions/categories/ideas).
- **A security problem:** report it privately as described in the
[security policy](SECURITY.md), never in a public issue.
The [contributing guide](../CONTRIBUTING.md) explains where each kind of help
goes and how a pull request lands.
+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: |
+2 -7
View File
@@ -14,8 +14,7 @@ name: Main landing gate
# This job is deliberately narrow. It runs `scripts/preflight.sh --check`,
# which is the checked-in local preflight for exactly the class of breakage that
# reaches `main` unnoticed: rustfmt, the CHANGELOG slice, README locale stamps,
# the dead-code and blocking-calls budgets, generated-asset drift (including
# the website's generated facts, agent docs and feature registry), and the
# the dead-code and blocking-calls budgets, generated-asset drift, and the
# feature receipts. Measured at 45 s on a warm Linux runner.
#
# Since 2026-11-08 (founder) the `full-ci` job also dispatches `ci.yml` on every
@@ -48,13 +47,9 @@ jobs:
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.100.0
toolchain: stable
components: rustfmt
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Preflight
env:
BEFORE_SHA: ${{ github.event.before }}
+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
+2 -2
View File
@@ -21,7 +21,7 @@ jobs:
node-version: '22'
cache: npm
cache-dependency-path: pet/package-lock.json
- uses: dtolnay/rust-toolchain@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'
+6 -5
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'
+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
-4
View File
@@ -612,7 +612,6 @@ jobs:
set -euo pipefail
node web/scripts/sync-latest-release.mjs
node web/scripts/derive-facts.mjs
node web/scripts/derive-release-docs.mjs
- name: Prove the record passes the web fact gates
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -621,7 +620,6 @@ jobs:
node web/scripts/sync-latest-release.mjs --check
node web/scripts/check-cloud-facts.mjs
node web/scripts/check-facts.mjs
node web/scripts/check-release-docs.mjs
- name: Propose the record to the default branch
env:
TAG: ${{ needs.resolve.outputs.tag }}
@@ -635,8 +633,6 @@ jobs:
docs/cloud-facts/stable.json
web/lib/facts.generated.ts
web/data/agent-docs.generated.json
web/data/agent-docs.release.json
web/data/agent-docs.release-index.json
web/data/features.generated.json
)
if git diff --quiet -- "${paths[@]}"; then
+4 -3
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
@@ -76,8 +76,9 @@ jobs:
- name: Audit website with verified build dependency mitigation
if: matrix.name == 'web'
run: npm run audit:dependencies -- --report "${{ runner.temp }}/npm-audit.json"
# The VS Code extension's packager dependency override is recorded in
# docs/dependency-maintenance.md; any reported vulnerability fails the job.
# The VS Code extension's packager toolchain carries one unfixed high
# advisory that never runs for users (DEPENDENCY_SECURITY.md at the repo
# root). It is admitted by name; anything else fails the job.
- name: Audit extension with reviewed packager advisory acceptance
if: matrix.name == 'vscode'
run: npm run audit:dependencies -- --report "${{ runner.temp }}/npm-audit.json"
-319
View File
@@ -1,319 +0,0 @@
name: Web maintainer
on:
schedule:
- cron: '43 6 * * *'
workflow_dispatch:
inputs:
lane:
description: Which lane to run
required: true
default: all
type: choice
options:
- all
- derived
- copy-freshness
- link-health
- docs-drift
permissions: {}
concurrency:
group: web-maintainer
cancel-in-progress: false
env:
WEB_MAINTAINER_CLI_VERSION: v0.10.2
jobs:
plan:
name: Plan
if: github.repository == 'codewhale-hq/Codewhale' && github.ref == 'refs/heads/main' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions: {}
outputs:
lanes: ${{ steps.plan.outputs.lanes }}
derived: ${{ steps.plan.outputs.derived }}
steps:
- id: plan
env:
INPUT_LANE: ${{ inputs.lane || 'all' }}
run: |
derived=false
lanes='[]'
case "$INPUT_LANE" in
all) derived=true; lanes='["copy-freshness","link-health","docs-drift"]' ;;
derived) derived=true ;;
copy-freshness|link-health|docs-drift) lanes="[\"${INPUT_LANE}\"]" ;;
*) echo "::error::unknown lane"; exit 1 ;;
esac
echo "derived=${derived}" >> "$GITHUB_OUTPUT"
echo "lanes=${lanes}" >> "$GITHUB_OUTPUT"
echo "Web maintainer plan: derived=${derived} lanes=${lanes}" >> "$GITHUB_STEP_SUMMARY"
derived:
name: Derived files
needs: plan
if: needs.plan.result == 'success' && needs.plan.outputs.derived == 'true' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Install web dependencies
working-directory: web
run: npm ci --no-audit --no-fund
- name: Regenerate derived files
working-directory: web
env:
GH_TOKEN: ${{ github.token }}
run: |
node scripts/sync-latest-release.mjs
node scripts/derive-facts.mjs
node scripts/derive-changelog.mjs
node scripts/derive-install.mjs
node scripts/render-terminal-capture.mjs
- name: Check what changed
id: drift
run: |
mkdir -p "$RUNNER_TEMP/out"
if [ -n "$(git ls-files --others --exclude-standard)" ]; then
echo "::error::regeneration produced untracked files"
git ls-files --others --exclude-standard
exit 1
fi
git diff --name-status | node web/maintainer/check-allowlist.mjs web/maintainer/derived.txt derived
if [ -n "$(git diff --summary)" ]; then
echo "::error::regeneration created, deleted or changed modes"
exit 1
fi
git diff --binary > "$RUNNER_TEMP/out/change.patch"
if [ -s "$RUNNER_TEMP/out/change.patch" ]; then
echo "drift=true" >> "$GITHUB_OUTPUT"
git diff --stat >> "$GITHUB_STEP_SUMMARY"
else
echo "drift=false" >> "$GITHUB_OUTPUT"
echo "Derived files are current." >> "$GITHUB_STEP_SUMMARY"
fi
- name: Run the web checks on the regenerated tree
if: steps.drift.outputs.drift == 'true'
working-directory: web
run: |
npm run check
npx vitest run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: steps.drift.outputs.drift == 'true'
with:
name: web-maintainer-derived
path: ${{ runner.temp }}/out/change.patch
retention-days: 3
if-no-files-found: error
lane:
name: Lane ${{ matrix.lane }}
needs: plan
if: needs.plan.result == 'success' && needs.plan.outputs.lanes != '[]' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
contents: read
strategy:
fail-fast: false
max-parallel: 3
matrix:
lane: ${{ fromJSON(needs.plan.outputs.lanes) }}
env:
LANE: ${{ matrix.lane }}
steps:
- name: Require the DeepSeek key
env:
KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
run: |
if [ -z "$KEY" ]; then
echo "::error::WEB_MAINTAINER_DEEPSEEK_API_KEY is not set. Create a dedicated DeepSeek key with a provider-side monthly cap and add it as that repository secret, or unset WEB_MAINTAINER_ENABLED. See web/maintainer/README.md."
exit 1
fi
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
persist-credentials: false
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 22
- name: Prove the command sandbox works or stop
run: |
sudo apt-get install -y --no-install-recommends bubblewrap util-linux >/dev/null
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 >/dev/null || true
probe="$GITHUB_WORKSPACE/.sandbox-probe"
rm -f "$probe" /usr/local/sandbox-probe
if ! timeout 10 bash -c 'exec 3<>/dev/tcp/api.deepseek.com/443'; then
echo "::error::the runner cannot reach api.deepseek.com, so the network denial cannot be proven."
exit 1
fi
if ! /usr/bin/bwrap --unshare-all --ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
--bind "$GITHUB_WORKSPACE" "$GITHUB_WORKSPACE" --chdir "$GITHUB_WORKSPACE" -- \
bash -c 'touch .sandbox-probe && ! touch /usr/local/sandbox-probe 2>/dev/null && ! timeout 10 bash -c "exec 3<>/dev/tcp/api.deepseek.com/443" 2>/dev/null'; then
echo "::error::bubblewrap did not confine a child on this runner (writes outside the workspace or outbound network were not denied). Refusing to give a model a shell."
exit 1
fi
if [ ! -e "$probe" ] || [ -e /usr/local/sandbox-probe ]; then
echo "::error::sandbox probe left unexpected state. Refusing to give a model a shell."
exit 1
fi
rm -f "$probe"
echo "Sandbox probe passed: workspace-only writes, no outbound network." >> "$GITHUB_STEP_SUMMARY"
- name: Stage trusted tooling and the pinned CLI
run: |
mkdir -p "$RUNNER_TEMP/trusted" "$RUNNER_TEMP/home"
cp web/maintainer/allowlist.txt web/maintainer/check-allowlist.mjs "$RUNNER_TEMP/trusted/"
cp "web/maintainer/lanes/${LANE}.md" "$RUNNER_TEMP/trusted/prompt.md"
CODEWHALE_VERSION="$WEB_MAINTAINER_CLI_VERSION" CODEWHALE_INSTALL_DIR="$RUNNER_TEMP/bin" sh web/public/install.sh
"$RUNNER_TEMP/bin/codewhale" --version
- name: Probe outbound links
if: matrix.lane == 'link-health'
run: node web/maintainer/link-report.mjs "$GITHUB_WORKSPACE/.git/maintainer-input/link-report.json"
- name: Record the git state the model must not change
run: |
git config --local --list | sort > "$RUNNER_TEMP/git-config.before"
find .git/hooks -type f ! -name '*.sample' > "$RUNNER_TEMP/hooks.before"
test ! -s "$RUNNER_TEMP/hooks.before"
- name: Run the lane on DeepSeek
timeout-minutes: 25
env:
WEB_MAINTAINER_DEEPSEEK_API_KEY: ${{ secrets.WEB_MAINTAINER_DEEPSEEK_API_KEY }}
run: |
env -i \
PATH="$PATH" \
HOME="$RUNNER_TEMP/home" \
CODEWHALE_HOME="$RUNNER_TEMP/home/.codewhale" \
CODEWHALE_SECRET_BACKEND=file \
CODEWHALE_MEMORY=false \
CODEWHALE_TELEMETRY=false \
DEEPSEEK_API_KEY="$WEB_MAINTAINER_DEEPSEEK_API_KEY" \
"$RUNNER_TEMP/bin/codewhale" \
--no-project-config \
--skip-onboarding \
--workspace "$GITHUB_WORKSPACE" \
exec \
--provider deepseek \
--model deepseek-flash \
--reasoning-effort low \
--auto \
--sandbox workspace-write \
--max-turns 40 \
--output-format stream-json \
-- "$(cat "$RUNNER_TEMP/trusted/prompt.md")" \
> "$RUNNER_TEMP/exec.jsonl"
echo "Model run finished: $(wc -l < "$RUNNER_TEMP/exec.jsonl") stream events." >> "$GITHUB_STEP_SUMMARY"
- name: Enforce the path allowlist
id: scope
run: |
if git status --porcelain --untracked-files=all --ignored | grep -qv '^ M '; then
echo "::error::the lane left untracked, ignored, staged or non-edit entries"
git status --porcelain --untracked-files=all --ignored | grep -v '^ M '
exit 1
fi
git config --local --list | sort | diff - "$RUNNER_TEMP/git-config.before" || { echo "::error::the lane changed .git/config"; exit 1; }
find .git/hooks -type f ! -name '*.sample' | diff - "$RUNNER_TEMP/hooks.before" || { echo "::error::the lane added git hooks"; exit 1; }
git diff --name-status | node "$RUNNER_TEMP/trusted/check-allowlist.mjs" "$RUNNER_TEMP/trusted/allowlist.txt" "$LANE"
if [ -n "$(git diff --summary)" ]; then
echo "::error::the lane created, deleted or changed modes"
exit 1
fi
mkdir -p "$RUNNER_TEMP/out"
git diff --binary > "$RUNNER_TEMP/out/change.patch"
if [ ! -s "$RUNNER_TEMP/out/change.patch" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Lane ${LANE}: no change proposed." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ "$(wc -c < "$RUNNER_TEMP/out/change.patch")" -gt 262144 ]; then
echo "::error::patch exceeds 256 KiB"
exit 1
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
{ echo "Lane ${LANE} proposes:"; echo '```text'; git diff --stat; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
- name: Fresh checkout for verification
if: steps.scope.outputs.changed == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
path: verify
persist-credentials: false
- name: Run the web checks on the proposal in a clean tree without secrets
if: steps.scope.outputs.changed == 'true'
env:
PATCH: ${{ runner.temp }}/out/change.patch
run: |
cd verify
git apply --index --whitespace=nowarn "$PATCH"
cd web
npm ci --no-audit --no-fund
npm run check
npx vitest run
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: steps.scope.outputs.changed == 'true'
with:
name: web-maintainer-${{ matrix.lane }}
path: ${{ runner.temp }}/out/change.patch
retention-days: 3
if-no-files-found: error
publish:
name: Open pull requests
needs: [plan, derived, lane]
if: always() && needs.plan.result == 'success' && vars.WEB_MAINTAINER_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
continue-on-error: true
with:
pattern: web-maintainer-*
path: ${{ runner.temp }}/patches
- name: Look for proposed patches
id: patches
run: |
if [ -n "$(find "$RUNNER_TEMP/patches" -name change.patch -size +0c 2>/dev/null)" ]; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
echo "No lane proposed a change; no pull request opened." >> "$GITHUB_STEP_SUMMARY"
fi
- if: steps.patches.outputs.found == 'true'
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3
id: app-token
with:
app-id: ${{ vars.CODEWHALE_APP_ID }}
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
permission-contents: write
permission-pull-requests: write
- if: steps.patches.outputs.found == 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Open or update one pull request per lane
if: steps.patches.outputs.found == 'true'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
APP_ID: ${{ vars.CODEWHALE_APP_ID }}
ARTIFACTS_DIR: ${{ runner.temp }}/patches
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
mkdir -p "$RUNNER_TEMP/trusted"
cp web/maintainer/allowlist.txt web/maintainer/derived.txt web/maintainer/check-allowlist.mjs web/maintainer/publish-prs.sh "$RUNNER_TEMP/trusted/"
TRUSTED_DIR="$RUNNER_TEMP/trusted" bash "$RUNNER_TEMP/trusted/publish-prs.sh"
+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
-14
View File
@@ -14,17 +14,3 @@ Hunter Bown <[email protected]> Cursor Agent <[email protected]>
Hunter Bown <[email protected]> cursoragent <[email protected]>
Hunter Bown <[email protected]> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
Hunter Bown <[email protected]> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
ddlw <[email protected]> <[email protected]>
dmitri-0 <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
Paulo Aboim Pinto <[email protected]> <[email protected]>
AiurArtanis <[email protected]> <[email protected]>
Apeiron <[email protected]> <[email protected]>
ChaceLyee2101 <[email protected]> <[email protected]>
movic <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
merchloubna70-dot <[email protected]> <[email protected]>
luola <[email protected]> <[email protected]>
SONG ZHENRUI <[email protected]> <[email protected]>
xiluoduyu <[email protected]> <[email protected]>
+3 -10
View File
@@ -259,16 +259,9 @@ prefer merging the contributor's PR itself). When a contributor's work
lands as our commit, the contributor is that commit's **author** (`git commit
--author` with their GitHub-linked address), it carries `Harvested from PR #N by
@handle`, and maintainers or agents go in `Co-authored-by`. GitHub's contributor
count takes commit authors and the `Co-authored-by` trailers in a commit's final
trailer block, and only addresses linked to an account: a trailer cut off by a
blank line, an unlinked email, or a noreply address carrying someone else's
numeric id credits nobody or the wrong person. Harvested commits authored by
maintainer identities without such a trailer left their contributors off it,
which is why `docs/CONTRIBUTORS.md` carries the full wall
(`scripts/contributor-wall.py`). Credit lost in history that cannot change is
restored by a later commit with one trailer per person, naming each pull
request; `scripts/check-contributor-credit.py` fails the release window when a
harvest, a trailer or a noreply id would leave someone uncounted.
list counts commit authors only: about 200 harvested commits authored by
maintainer identities left their contributors off it, which is why
`docs/CONTRIBUTORS.md` carries the full wall (`scripts/contributor-wall.py`).
A maintainer closes their PR by hand with a thank-you naming the commit;
nothing closes a PR automatically. Canonical human identities come from
`.github/AUTHOR_MAP`.
+19 -108
View File
@@ -7,127 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Removed (crate API):** the unwired `codewhale-hooks` dispatcher and its stdout, JSONL and Unix-socket sinks; `codewhale-core`'s `session`, `journal` and `ids` modules and the `hooks` argument of `Runtime::new`; `codewhale-runtime::resource_telemetry`; ten unreferenced public functions; and the duplicate `codewhale-agent` crate. `hook_sinks.unix_socket_path` still parses and is ignored. CLI, TUI and app-server behavior is unchanged.
- **First-run setup:** installer downloads retry up to three times and remove partial files after a failed attempt. Folder trust uses 1 to trust, 2 to continue untrusted, and 3 or Esc to quit; letters do nothing. Provider pickers consistently say "needs key", and the unconfigured custom placeholder says "needs endpoint" while configured-route errors remain visible.
- **Quota-window reset text:** a rate-limited turn now gives the reset time in plain words such as "19h29m" instead of Rust debug output like `Some(70168s)`, and leaves the reset clause out when the provider gives no reset time. An OpenCode Go message points at that provider's own key with `codewhale auth set --provider opencode-go`, without repeating a hint the message already carries. (CW-8209)
- **Usage-reporting collector:** accepts runtime schema 6 alongside existing versions and keeps schema 6 reports first-party only.
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
- **Renaming sessions with Chinese titles:** the session picker no longer rejects a Chinese, Japanese, Korean or emoji title of more than about 33 characters as "Title must be 1–100 characters". It now counts characters, as the message says and as `/rename` and the Runtime API already did, so any title up to 100 characters is accepted. Thanks [@luochen211](https://github.com/luochen211) (PR 7322).
- **Non-ASCII file names:** `/diff`, the `@git` and `@diff` mentions and the Runtime API workspace diff now show paths with Chinese, emoji and other non-ASCII characters as written instead of as quoted octal escapes, and the turn context no longer lists the workspace root as an empty "Active paths" entry. Thanks [@luochen211](https://github.com/luochen211) (PR 7317).
- **Session names after a crash, and keep-forever snapshot retention:** a session name saved right after a crash that cut off the previous session-index line is no longer lost, because the new entry now starts on its own line; and a very large `[snapshots] max_age_days` (such as `9223372036854775807` for "keep forever") no longer wraps around and deletes every undo snapshot at session start. Thanks [@luochen211](https://github.com/luochen211) (PR 7316).
- **Blank environment variables:** exporting a variable empty (for example `CODEWHALE_MODEL=` from a compose `${VAR:-}` template) no longer hides its legacy alias. `DEEPSEEK_MODEL`, `DEEPSEEK_BASE_URL`, `KIMI_BASE_URL`, `DEEPSEEK_FORCE_HTTP1`, `DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS` and the other provider alias variables now apply as they do when the primary is unset. Thanks [@luochen211](https://github.com/luochen211) (PR 7315).
- **MCP and language servers that send requests mid-call:** a tool call no longer fails with "neither a result nor an error" when an MCP server sends a `ping` or other request that reuses the call's id while the call is running, and a language server's `workspace/configuration` or similar request no longer ends a pending LSP request. Codewhale now answers `ping`, answers other server requests with "Method not found" (it never advertised them), keeps waiting for the real reply, and a refused answer over Streamable HTTP no longer drops the connection to legacy SSE. Thanks [@luochen211](https://github.com/luochen211) (PR 7319).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (#7293).
- **Qwen Code as an external agent:** Codewhale now finds an installed Qwen Code CLI (`qwen`, 0.6.1 or newer) and can run it as a session over ACP with `qwen --acp`. `GET /v1/agents/external` lists it, an older install is reported as needing `npm i -g @qwen-code/qwen-code@latest`, and sign-in stays inside Qwen Code (`/auth`). Thanks [@MannXo](https://github.com/MannXo) (PR 7293).
- **Telemetry setting default:** the settings schema API reports telemetry's default as on, matching what Codewhale does when you have not chosen.
- **Auto routing abstention:** when the decision model marks a tier, effort or shadow answer `abstained: true`, Auto no longer acts on it, even with `min_confidence = 0`: an abstained tier takes the local fallback, an abstained effort keeps the tier and drops only the effort, and the routing receipt lists the abstained questions.
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
- **Footer clock:** The footer clock and phase now say "thinking", matching the transcript.
- **Paused stream:** A paused stream now shows the thinking it has already received instead of waiting for the next token.
- **Sign-in notice:** The "Account not loaded" notice now appears once per sign-in in the footer, and Esc dismisses it; it no longer lands in the transcript.
- **Usage reporting:** Installers now run their closing diagnostic with usage reporting and update checks off and print how to turn usage reporting off. `codewhale doctor` now says a saved opt-out comes from the config file, not the environment, and help and English notices call the feature "usage reporting".
- **Long tool names:** Tools whose encoded name is longer than the route's limit are now left out with a visible warning instead of failing every request with a 400. The limit is 64 characters on OpenAI and on other OpenAI-compatible providers that document no larger limit, and 128 on Anthropic-style routes, DeepSeek, Ollama, Ollama Cloud, vLLM, SGLang and custom routes. Forcing such a tool gives an error that names the tool and the limit, and a session that already called one on a route with a documented limit (Anthropic, OpenAI, Codex, DeepSeek) is refused with a message saying to switch route or start a new session. A forced tool_choice in any chat form now names the encoded tool, and a tool whose real name looks like an encoded escape can no longer be mistaken for another tool.
- **Start after a hard kill:** The Engine now also starts after a hard kill when its home was spelled through a symbolic link (for example under /tmp on macOS) or, on macOS and Windows, in a different letter case: a dead owner's record for the deleted data directory is discarded instead of refusing every start.
- **Context window labels:** the model picker and fleet capability badges now show `1.5M` and `2.1M` for 1,500,000- and 2,100,000-token windows instead of `1.50M` and `2.10M`. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7295).
- **REPL output limit:** REPL output is now cut to its byte limit, so Chinese, Japanese, Korean or emoji output no longer comes back up to three times longer than the limit, and the cut never splits a character. Thanks [@Aboysky](https://github.com/Aboysky) (PR 7296).
- **Git status with renamed files:** the Git and Changes views no longer fail with "git status failed" or show a phantom row when a file is renamed on the worktree side (for example a moved file marked with `git add -N`), including when the original name starts with non-ASCII text such as Chinese. Thanks [@luochen211](https://github.com/luochen211) (PR 7310).
- **Skill install and import:** `/skill install` now works out an archive's wrapper directory from every entry instead of the first one, so a GitHub repository laid out as `<name>/SKILL.md` installs instead of failing with "missing SKILL.md in archive", a repository-root `SKILL.md` is chosen ahead of a nested `skills/<name>/SKILL.md`, and a flat tarball that lists `scripts/run.sh` before `SKILL.md` no longer installs without its `SKILL.md`. Importing an external skill that contains dotfiles such as `.DS_Store`, `.gitignore` or `.config/` no longer fails with "imported content digest mismatch"; the dotfiles are copied, and the source's own `.trusted` and `.installed-from` are still left behind. Thanks [@luochen211](https://github.com/luochen211) (PR 7311).
- **Workflow authoring:** a declarative Workflow or structured `workflow` plan whose goal or prompt mentions words such as "async", "import" or "open(" is no longer rejected as an unsupported construct. Fleet TOML values written as 'single-quoted' strings, or with escapes such as `\"`, are now read without their quotes. Workflow scripts can no longer reach the internal `__workflow_slot_dropped` binding, which had let a script mark a clean run as Degraded. Thanks [@luochen211](https://github.com/luochen211) (PR 7312).
- **Tool output escape stripping:** tool output no longer shows a stray character after a character-set escape such as the `ESC ( B` that `tput sgr0` prints, and a stray `ESC [` no longer hides the Chinese, Japanese or Korean text after it. Coloured tool output now keeps only well-formed colour sequences. Thanks [@luochen211](https://github.com/luochen211) (PR 7313).
- **Patches with short hunk headers:** `apply_patch` now reads every line of a hunk when the `@@` header counts are too small. Lines past twice the header counts used to be dropped without an error, so a long hunk, or a new file written under `@@ -0,0 +1 @@`, could lose its later lines. A header with an enormous count no longer crashes the parser, and stray text after such a hunk now fails as a context mismatch. Thanks [@luochen211](https://github.com/luochen211) (PR 7287).
- **Script tools with `env -S` shebangs:** a script tool whose first line is `#!/usr/bin/env -S python3 -u` now starts `python3 -u` instead of failing to launch a program named `-S`. Other `env` options, the attached `-Sprogram` form and `env -S` quoting are still not interpreted. Thanks [@luochen211](https://github.com/luochen211) (PR 7288).
- **Timeline summaries:** a thread or task timeline summary that already fits its length limit is now shown in full, instead of having its last characters replaced by `...`. Thanks [@luochen211](https://github.com/luochen211) (PR 7289).
- **RLM context names:** `rlm_open` without a `name` now keeps non-ASCII letters and digits from the file name, so `docs/报告.txt` and `docs/总结.txt` open as `报告_txt` and `总结_txt` instead of both becoming `txt` and the second open failing with "already exists". Names derived from ASCII file names are unchanged. Thanks [@luochen211](https://github.com/luochen211) (PR 7290).
- **Web fetch and `web.run` entities:** text a page escaped on purpose now stays escaped. A page that writes `&amp;lt;br&amp;gt;` to show a literal `&lt;br&gt;` now reads as `&lt;br&gt;` in fetched text, `web.run` lines, page titles and meta descriptions; before, it was decoded twice into `<br>`. Both tools now use the search scraper's single-pass decoder, so numeric references such as `&#8212;` and names such as `&mdash;` are decoded there too. Thanks [@luochen211](https://github.com/luochen211) (PR 7291).
- **Simplified Chinese punctuation and /status:** in the zh-Hans interface, the `/router` setup and test screens, label prompts and usage lines now use full-width punctuation (`:;,()`) instead of ASCII marks next to Chinese text, and `/status` values start in one column under Chinese labels instead of starting at a different column for each label. Thanks [@luochen211](https://github.com/luochen211) (PR 7314).
- **Git tool on older git:** the Git tool's `review_scope` action no longer passes `--no-lazy-fetch`, which git older than 2.44 rejects, so it failed on every call there. It sets `GIT_NO_LAZY_FETCH=1` instead, which keeps the same protection on newer git and is ignored by older git.
- **Plugins from a shell:** `codewhale plugins trust <name>` prints the same capability review as `/plugin trust` with its bundle review token and, in a terminal, asks once. `--review-token <token> --yes` trusts without a prompt while that token still matches the installed content and capabilities; the token ties trust to the exact content, does not show that anyone read the review, and is not the host-service review token. A `--yes` trust is recorded as the command line and grants no host service. Every command the agent runs (shell, persistent terminal, verifier and test runs, the Python kernel, MCP servers it starts) now carries `CODEWHALE_AGENT_PROJECT` with the session's project, while hooks and configured MCP servers do not; with it, a trust or enable is recorded as the agent's, for that project only, even when a prompt is answered through a terminal, and the agent may trust only a plugin it installed from a folder inside the project. `plugins enable`, `disable` and `revoke` work the same way, each with `--json`, and `plugins status` shows who approved. The agent's shell commands that run them go through the session's approval like any other shell command, so a Full Access agent can trust and enable a plugin it wrote; the mark is a label the agent could remove in Full Access. (CW-7891)
- **Sandbox:** a managed configuration can set `tool_launcher` (Linux) to run every sandboxed tool command as another user, in front of bubblewrap; commands then run inside the sandbox or not at all, masks are computed for that user, `/tmp` is private per command, and `GET /v1/runtime/info` reports `sandbox.kind` and `sandbox.tool_launcher`. Without the key nothing changes. A managed configuration named by `CODEWHALE_MANAGED_CONFIG_PATH` or `managed_config_path` is applied on every load, and one that is missing or cannot be read stops the load instead of loading with no managed layer; a blank `CODEWHALE_MANAGED_CONFIG_PATH` is now ignored instead of switching the managed layer off, and a config file's `managed_config_path` can no longer point away from an existing file at the default managed path. Fleet local workers inherit the managed-config variable, and `codewhale serve --mcp` does not expose shell tools when the managed configuration it loads sets `tool_launcher`. A configuration whose provider is declared only by a plugin fails to load under a managed configuration, with an error that says to declare the provider table or pick a built-in provider.
- **Receipts:** a terminal session's receipt (`/receipts`, `codewhale receipts`) now shows the provider's token counts and the same cost report `/cost` prints, saying "unknown" and why when the route's price is not known, never a guess. A Runtime thread's receipt shows token counts only; its cost line follows in a later release. `codewhale metrics` now counts usage saved in terminal sessions instead of reporting none. Sessions saved by 0.10.2 carry only a combined token total.
- **ACP server on the official SDK:** The ACP server (`codewhale serve --acp`) now runs on the official Agent Client Protocol SDK. Its messages follow the official v1 types, and it no longer advertises the non-standard `modelSelection` capability or session `models` field. Session list entries carry `createdAt` and `messageCount` under `_meta.codewhale`, terminal sign-in methods are advertised only to clients that enabled terminal authentication, a notification is never answered, a permission request always carries text, and session list and configuration requests are served while a turn runs, response ids are echoed unchanged for every client (the Zed-only string rewrite is gone), and `initialize` without `protocolVersion` is refused with `-32602`.
- **External agents over ACP:** Codewhale now drives external agents through the official Agent Client Protocol SDK. Request ids on the wire are strings, and an agent's error is treated as a sign-in problem only when it carries the protocol's auth-required code.
- **Creator skills:** Codewhale now ships DeepSeek Harness's four plugin-authoring skills (`cordis-plugin-development`, `editing-cordis-compositions`, `cordis-composition-reference`, `agent-experience`) bundled, unmodified, with their reference and template files and DeepSeek's MIT notice beside each. They stay hidden from the model until Creator mode. A bundled skill can now carry files; `load_skill` prints its base directory and a short Codewhale host-notes block, and a file you edited is never overwritten on upgrade.
- **Conversation modes (Engine):** `GET /v1/modes` lists two built-in modes, `standard` (default) and `creator`, each with `id`, `name`, `description`, `source` and `available` (plus `reason` when not; Creator is listed unavailable, "not ready in this build", until it loads its own tools, and then also when no Node.js or Bun plugin runtime is found). A thread's mode is `agent_preset`, set on `POST /v1/threads` or by `PATCH /v1/threads/{id}` until the thread has a turn, saved on the thread record and the terminal session file and shown in `GET /v1/threads/summary`; records without it load as `standard`. An unknown or unavailable id, a change after the first turn, or a mode together with another vendor's agent is refused with `error.code` `unknown_mode`, `mode_unavailable`, `mode_locked` or `mode_not_supported_with_executor`. Source only: Creator loads nothing extra yet and no menu uses it.
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
- **Website home page, rebuilt around one first screen:** the headline stays "The model can be anyone's. The agent should be yours.", centred, with one plain sentence under it that says what Codewhale is (an open-source coding agent for your terminal, written in Rust, working with any model and able to split a task across several agents), the install command, and one wide picture of a real terminal session that runs off the bottom of the first screen. A row of facts under the picture takes its counts of provider routes and models from the published release, and the facts row lists many agents on one task only while the release has it. The route of where the product is going moves lower and smaller, and the home page names no other coding agents. This replaces the earlier reorganization of the home page, so the community panel whose GitHub numbers filled in from the browser is no longer on it, and the dated OrcaRouter offer strip, English only, with its referral disclosure, stays below the first screen until it ends; the models and contribute pages keep their own content. The new text in the 17 non-English languages is machine-drafted and is labelled as such in the footer; the Arabic page now reads the opening sentence right to left.
- **Website models and plugins:** /models leads with recently added models, three ways to connect (DeepSeek, first-party providers, any OpenAI-compatible endpoint) and a catalog grouped by maker; Ollama and OpenRouter are listed but no longer featured. /plugins leads with "Everything is a plugin" (credited to DeepSeek Harness) and shows MCP connectors from the connection catalog.
- **Model catalog:** Claude Fable 5.2 (`claude-fable-5-2`) and Gemini 4 Carbon (`gemini-4-carbon`) are in the bundled catalog, and a daily catalog-refresh workflow proposes new first-party models from models.dev as a pull request. The workflow has not run in GitHub Actions yet.
- **Gemini 4 output limit:** Gemini 4 Carbon and Gemini 4 Argon now allow 65,536 output tokens per reply, up from 32,768, matching Google's published limit.
- **Anthropic xhigh effort:** Choosing xhigh effort on an Anthropic model that documents it, such as Claude Fable 5.2 or Claude Opus 4.8, now sends xhigh instead of max, so the model uses and bills the level you picked.
- **Public model list:** The public model list gains GPT-6.1 Terra, Kimi K3.1 Pro (and its high-speed variant), MiniMax M3.1 Flash and Mistral Large 4, each marked unreviewed.
- **Model Studio Token Plan:** Token Plan users can now pick `qwen4-max-preview` on both the OpenAI and Anthropic dialects. It was added from the model table Alibaba publishes on its Token Plan pricing page and has not been exercised against the live endpoint. Alibaba publishes no context window, output limit, tool calling or thinking control for it, so Codewhale records image input only and sends it no thinking switch on either dialect. The Coding Plan does not list it.
- **Website brand and app pages:** the header, footer and favicon use the black-and-white Codewhale mark, keeping the colored whale for character moments. New /app and /docs/account pages say plainly that the desktop app is a development build, the browser app is not open yet, and how sign-in works where it exists. The terminal demo was recaptured from the 0.10.3 source.
- **DSH plugin client review:** the DSH importer now reviews a plugin's client (`dsh.client`) files: the review lists every file the client loads with its SHA-256 and which modules the host must provide, and says exactly why a client stays disabled (for example an npm package that is not in the bundle, or a module loaded by a computed specifier). Plugin UI is still not shown; that needs the Engine client bootstrap.
- **`codewhale exec` prompt input:** `codewhale exec "prompt"` no longer hangs when stdin is an open pipe that sends nothing (it continues after 2 s and says so, and a slow producer can be included by piping the prompt as well), and a flag typed after the prompt in full-width or typographic-dash form is rejected like `--auto` instead of being sent to the model as text. Tested on Unix only; no Windows, hosted CI or real-provider run.
- **Website docs:** documentation pages no longer stack a second "Codewhale documentation" header under the site navigation; the line saying which release the docs describe now sits under the breadcrumb.
- **Website languages:** the Roadmap and Contribute pages now show their titles, introductions and actions in all 18 site languages instead of English outside English and Chinese. The translations have had no native review yet.
- **Responses dialect profiles:** Codex, DeepSeek and Concentrate Responses behavior now comes from one checked dialect table instead of per-provider code, a custom `wire = "responses"` provider can set `responses_profile = "standard"` (the default when omitted), and preview shows the profile each Responses request used. A Responses client whose route cannot be resolved now fails when it is created rather than at its first request ([#7261](https://github.com/codewhale-hq/Codewhale/pull/7261)). Thanks [@SamhandsomeLee](https://github.com/SamhandsomeLee).
- **Read-only builtins over the Runtime API:** `POST /v1/threads/{id}/commands/builtin` runs `/system`, `/tokens` or `/cost` against a thread and returns the same answer the terminal prints, including the effective system prompt the engine sends. Any other command name answers 404, a thread whose engine is not loaded or has no live context estimate answers 409, and the route never starts an engine or writes settings ([#7277](https://github.com/codewhale-hq/Codewhale/pull/7277)). Verified by the app-server route tests and terminal-host tests; no editor client uses it yet. Thanks [@gaord](https://github.com/gaord).
- **Automations:** a run missed while Codewhale was closed now waits on reopen for Run now or Skip instead of catching up silently; each automation can choose "If missed: ask" (default), "run once" or "skip". Tested in the engine only; no HTTP route tests, no desktop client yet, and a Mac asleep longer than the grace window counts as closed.
- **Website maintainer:** a scheduled `web-maintainer` workflow proposes website upkeep as pull requests only: regenerated facts, and three DeepSeek lanes (stale version mentions, moved links, docs drift) that may edit only allowlisted copy files and must pass `npm run check` and the web tests. It is off until the repository variable `WEB_MAINTAINER_ENABLED` is set and a capped `WEB_MAINTAINER_DEEPSEEK_API_KEY` secret exists, and it has never run. The Worker's triage, PR-review, stale, duplicate and curate (Today's Dispatch drafting) cron tasks are removed; the digest stays.
- **Website measurement:** the first-party ingest now accepts a closed website funnel schema while preserving existing runtime and browser batches. Reader confirmations remain self-reports, new website data stays first-party, and no deployment or live conversion gain is established by the source change.
- **Website facts:** the website now reads the installed version, asset list and repository numbers from the published release and a dated snapshot instead of the source tree, shows nothing rather than 0 when a live number cannot be read, stamps its facts revision and date together, no longer calls the desktop app native, says when the release was read, and drops the price from its structured data. This is a source change and is not deployed yet.
- **Website surface status:** the status of the terminal, browser, desktop, cloud and accounts surfaces now comes from typed states with copy in every locale, the desktop download appears only for a signed, notarized release with a checksum, the cloud-invitation and plan switches are committed values that default to off, the partner strip expires at render time, and repository numbers show when they were read. This is a source change and is not deployed yet.
- **Website capabilities:** the website now lists a capability as available only when the published release has it, labels features that exist only in source as not yet released, keeps held features (cloud computers beyond invite-only, other vendors' agents over ACP, and plans) hidden behind committed switches that default to off, and prints provider route, catalog model and tool counts as they are at the published release tag instead of on main. This is not deployed yet.
- **Installer uninstall lines:** the installers now print the uninstall command, the update-mode sentence and the shell-profile marker sentence only when the Codewhale they just installed reports an `uninstall` command, and older releases such as a pinned v0.10.2 get the manual removal steps instead of a command that would be sent to the model as a prompt. (CW-8029) The installers reach people through the website, so this is not deployed yet.
- **Installer PATH ending:** when `codewhale` is not on PATH, the installers' last lines are now the copy-paste `export PATH=...; hash -r` command and `codewhale --version`, and when it is on PATH the last line is `Next: codewhale --version`; the Computer Use notice moves above them and shows on macOS only. (CW-8030) The installers reach people through the website, so this is not deployed yet.
- **Installer PATH setup:** when the install directory is not on PATH, the macOS and Linux installers now add Codewhale's marked three-line PATH block to your shell profile, print exactly which file and lines they added and the command that removes them, and skip this with `--no-modify-path` or `CODEWHALE_NO_MODIFY_PATH=1`; the Windows archive installer adds the directory to your user PATH the same way and skips it with `-NoModifyPath`. Release archives carry this from 0.10.3 on, and the Windows installer has not been run yet. (CW-8036) The website serves install.sh, so this reaches people only when the website is next deployed.
- **Website and docs:** installation now leads to a version check and a verifiable first task, article search finds text and commands with section links, and GitHub star links show the fetched count. The hero reuses the generated drone whale, and control/tool references distinguish the unpublished source candidate from v0.10.2; these website changes still await deployment.
- **External agent options:** `GET /v1/agents/external/{id}/options?cwd=` returns the models, modes and options an external agent itself offers for a folder, and which Codewhale actions it will not carry. A saved agent model the agent no longer offers is refused with a stable `choice_not_offered` code instead of being ignored, per-turn settings an agent cannot honor are refused as `unsupported_control`, and each agent turn records the context window and tokens the agent reported, with cost shown as unknown (CW-7879). Probing starts a real agent session in the folder, which the agent may keep in its own history. Mode and config choices are listed but cannot yet be chosen through thread creation. Verified on Linux by unit and local HTTP tests against a fixture agent; no real installed agent, macOS, Windows or hosted CI run.
- **External agent interrupts:** Interrupting a turn while an external agent waits for approval now answers the agent's permission request as cancelled. (CW-7958)
- **External agent quota:** The Runtime API route `GET /v1/agents/external/quota` reports each external agent's quota as unknown with a reason (flag off, consent missing, not implemented). The new `agents.quota.<agent>` and `agents.quota.<agent>.consent` settings, saved through `POST /v1/config`, record the opt-in and consent (at most 32 agent entries). No agent credential is read. (CW-7993)
### Changed
- **Docs for agents match the release:** `/llms-full.txt` and `/docs-markdown/*` now serve the documents as they were at the published release tag, from a committed snapshot that is checked against the published release.
- **Extension host (experimental): trusted plugins ask for review once more.** With `extension_host` turned on, plugins you already trusted are shown for review again after this update, because the capability table they were reviewed against gained the `remote` service. No plugin can use that service yet: none is granted it.
- **Website text for agents:** llms.txt now gives agents the install steps for the released version, the current release and the state of each part of Codewhale, from the same content as the pages. `/docs-markdown/install.md` serves those install steps as a page, and the main-branch documents beside it now say which release they can run ahead of.
- **Website design:** codewhale.net now uses the desktop app's 5.3 light and dark colour roles and Geist / Geist Mono, replacing the warm-paper palette and Shannon Sans; the vendored design copy is synced to 5.3.0 and the whale-road ocean bands are unchanged.
- ACP: editors can start, list, load, prompt and cancel Codewhale sessions and approve or reject tool permissions through the Runtime; prompts with image or audio blocks and relative session directories are now refused instead of ignored, and edit and read tools report the right kind. Sessions an editor drives now run the plugin tools (TypeScript and DSH, when the extension host is on) and the MCP servers configured in Codewhale; their calls show as ACP tool calls and ask through the session's permission setting, and `initialize` reports the state under `agentCapabilities._meta.codewhale`. MCP servers an editor passes in `mcpServers` and configured shell hooks still do not run in ACP sessions. A real-editor journey is not yet proved.
- Extension host: the `sessions` and `systemPrompt` services now come from a reviewed DSH adapter instead of inline shims, and `sessions.flush` refuses an empty checkpoint ticket. Session reads and events and the `agent/pre-step` hook are still not available to plugins. (CW-7959)
- Extension host: DSH service adapters now have one ticketed `dsh/broker` request to the core, each ticket is bound to the plugin, the service and the session, and every service (workspace, llm, network, settings, catalog, terminal and remote) refuses with a typed `not_available` until its broker lands. Nothing new is offered to plugins yet. (CW-8012)
- **Plugin host services and who approved:** a plugin can now be granted a Codewhale host service, and only by a review that shows it. The review names each service and the plugins it is shared with, and every review records who approved it: you, the agent (in Full Access, in Auto-Review, or from a shell command carrying the agent's mark), or the command line with `--yes`; who turned the plugin on is recorded too. Plugins share one process, so a service works only while every loaded plugin of the same kind holds the same grant. A plugin the agent approved is active only in its own project and its code does not run beside the plugins you reviewed until you review it. No service is offered yet, and a plugin reviewed before this release holds no grant until it is reviewed again. Once a plugin has been reviewed or turned on in this release, Codewhale 0.10.2 and earlier cannot read the plugin state file and turn every plugin off when run on the same home folder. (CW-8012)
- Fleet member starts are now durable: replaying an operation key after a timeout or restart returns the original worker, and GET /v1/threads/{id}/members/operations/{key} reports the outcome. A member start now answers with `state` `accepted` (200) or `unknown` (202) instead of `started` or `queued`. Verified by local HTTP tests on Linux; a real Engine restart is simulated, not exercised. (CW-7964)
- Plugin activation now says when a requested service belongs to the Codewhale core and is not exposed to plugins yet. No plugin, provider or installed-app run exercised it; checked by the extension-host tests only.
- Codex App Server v2 contract record (paused, not part of 0.10.3): the selected Codex v2 contract is frozen as a generated manifest in the protocol crate, with typed Rust and TypeScript shapes checked against the pinned Codex schema. It changes no wire behavior and adds no dispatch, so nothing is reachable through it yet.
- **Website copy:** The site's headline is now "The model can be anyone's. The agent should be yours.", with its subhead, belief and labelled product map in every language.
- **Website routes:** Pages that repeated each other now redirect to the one page that does the job, the header and footer list only pages that exist under their current names, the sitemap drops redirected and catalogue pages, and machine-drafted translations say so.
- **Website privacy:** The website no longer counts anything when your browser sends Global Privacy Control or Do Not Track, and the privacy page says exactly what is counted and how it is keyed.
- Plugin UI: the Runtime API now serves reviewed DSH client closures on six seats (customize.plugins.detail, conversation.composer.dock, conversation.chat.node, workpane.panel, sidebar.section, command.row) with coded refusals, drops a plugin's UI grants the moment it is disabled, revoked or uninstalled, and adds a plugin route bridge that refuses until a plugin web server exists. No real DSH package has been served end to end yet, and a client that imports `@deepseek-ai/dsh-client-*` modules is still refused. (CW-7956)
### Fixed
- **Starting after a hard kill:** the Engine now starts when its process was killed and its data directory was then deleted. A socket owner record whose process is dead and whose data directory, the one this start selected, no longer exists is discarded instead of failing every start with "Failed to resolve ... tasks/runtime". A home reached through a symbolic link, such as one under macOS /tmp, is not covered yet.
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
- The install page now gives the exact steps for the released version on every platform, including what the installer adds to PATH, how to turn off usage counting, and how to remove it, and the install command no longer gets cut off on phones.
- After choosing "explore offline" on first run, connecting a provider or Custom endpoint with /provider now takes effect immediately; messages are no longer held as "Saved for later" until restart, and a message held while offline returns to the composer.
- The pet browser view opens with a one-time code instead of the long-lived owner token, so the token no longer appears in the URL or the launcher command line. The code lasts 30 seconds and works once; the owner token itself is still stored in the cookie and connection file and is not rotated. Not run in a real browser.
- On Windows with Caps Lock on, Ctrl+E moves to the end of the line again instead of opening the file tree; the file tree now needs an explicit Shift (Ctrl+Shift+E or Cmd+Shift+E), so a terminal that reports an uppercase E with Ctrl but no Shift flag can no longer toggle it. The Caps Lock key event is simulated from the input library source; no native Windows run.
- External agents installed through npm on Windows (`claude.cmd`, `codex.cmd`, ...) are now found instead of the unrunnable shell shim, `GET /v1/agents/external` says whether an agent answered with an unsupported protocol rather than reporting every failure as no answer, and a hung `--version` or handshake probe no longer leaves the agent's child processes running. Verified on macOS and Linux; the Windows code has never been compiled or run, and no agent row is qualified by this change.
- The Runtime API's `POST /v1/git/push` now pushes exactly one branch at one commit (`<commit>:refs/heads/<branch>`, no follow-tags or submodule recursion), so `push.default`, `remote.<name>.push` and `push.followTags` can no longer publish other branches or tags. Clients can bind a push to a named branch and expected HEAD with `branch` + `expect.head` (advertised as `named_push` in `GET /v1/git`); a stale branch or HEAD answers `409 git_state_changed` and pushes nothing. A bare push from a detached HEAD or an unborn branch is now refused, and the desktop app has not yet adopted the named form. Verified on macOS by unit tests against a local bare remote; no real network remote, Linux, Windows or hosted CI run.
- Cancelling or timing out a read-only Git tool (git_status, git_diff, git_commit_plan, git_log, git_show, git_blame, git_merge_tree) now stops the git process and anything it started, instead of leaving it running in the background. Verified on macOS by unit tests; Linux, Windows and hosted CI are not yet run, and the filter-override probe is still not contained.
- TUI: the launch resume confirmation no longer treats Meta+Enter, Meta+Y or Hyper+Enter as "resume"; only a plain Enter/Y or Shift+Y answers it. Verified by the focused unit tests only; not tried in a terminal that sends Meta or Hyper.
- TUI: mentioning a huge directory or a far-away line range (`@file:N-M`) now stops after a bounded scan and says the result is partial instead of claiming end of file. Verified by the focused `file_mention` unit tests; no real 64 MiB file was tried.
- Runtime `patch-undo` now restores a turn's own file changes in the default (Ask) posture without trust mode or Full Access; later edits and changes the turn did not make are still refused with nothing changed, and the `restore_requires_trust` error code is gone ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)). Verified by app-server and TUI unit tests on Linux; no Windows `/undo` or process-restart run.
- **Website terminal frames:** The site can render terminal frames captured from the released version, and chooses each frame's caption from where it was captured.
- **Website docs truth:** Documentation pages now describe the released version first and show what exists only in source in a labelled section that switches on when a release carries it.
- **Website Arabic pages:** On Arabic pages, English text that has no translation yet keeps its own direction and language, so punctuation, commands and dates no longer appear reversed.
- A DSH plugin whose package includes a `.gitignore` at its root now installs instead of being refused as a hidden file; nested `.gitignore` files, other hidden files, credential files and links are still refused. (CW-8023)
- `install.bat` in the Windows release archive now passes its arguments to `install.ps1`, so `install.bat -NoModifyPath` declines the user PATH edit like the PowerShell installer does, and the install guide says so. It has not been run on Windows. The batch file reaches people in the next release archive, and the guide sentence when the website is next deployed, because the site serves the guide but not `install.bat`. (CW-8046)
## [0.10.3] - 2026-11-08
@@ -136,12 +37,11 @@ until the matching tag, packages, checksums, and release assets exist.
### Work in progress and known limits
This source candidate is not the final 0.10.3 tag. There is no
`0.10.3-beta.2`: the next release is the full 0.10.3, and its final tag waits
for the full scope list. Codex App Server v2 is paused and is not part of
0.10.3; the legacy JSON-RPC dialect is not retired in this release. The coarse
Engine split and DSH all-33 compatibility stay in 0.10.3, and none of this
work is declared complete here. Items moved to 0.10.4 by name are marked below.
This source candidate is not the final 0.10.3 tag. The 2026-11-08 scope
decision keeps App Server v2, the coarse Engine split and DSH all-33
compatibility in 0.10.3; the final tag waits for that full list. The near-term
`0.10.3-beta.2` is a GitHub prerelease for the Boat image and desktop, not this
tag. Items moved to 0.10.4 by name are marked below.
- **Terminal lifetime (0.10.3 work item):** deleting a conversation now ends the
terminal sessions and background jobs owned by that Runtime thread. The
@@ -684,14 +584,14 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **Listing installed agent CLIs no longer blocks the runtime.** `GET /v1/agents/external` looks for each binary with `tokio::fs` instead of synchronous file calls on a runtime worker.
- **A failed custom-provider switch leaves the provider you were on.** When saving a custom provider succeeds but switching to it fails (its identity cannot be resolved, or the engine refuses the route), the next request keeps going to the provider the footer shows instead of the half-configured one.
- **A zero page size over stdio still advances.** `thread/loaded/list` on the App Server's stdio transport treats a requested limit of `0` as `1`, so a client that sends `0` reaches the next page. `GET /v1/threads` is unchanged.
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did, including when such a tool is forced through an object-form `tool_choice`. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)). Thanks [@ychu824](https://github.com/ychu824) for the report.
- **Anthropic and Messages-compatible routes accept any tool name.** Tool names that Anthropic's pattern rejects, such as `web.run` from a loaded tool group, are encoded on the wire and decoded in replies, as the chat and responses routes already did. Before, one such name made every request fail with "tools.N.custom.name: String should match pattern", which stopped sub-agents that had loaded it.
- **A large sub-agent fan-out no longer exhausts the usage journal.** The per-session limit of live usage receipts rises from 64 to 256, above the workflow ceiling of 128, so a full fan-out keeps its cost receipts instead of running with cost unknown.
- **A turn that stalls before it starts is now bounded.** The stall watchdog covers the setup before the first model request, so a wedged start is logged, cancelled after 180 seconds with a stated reason, and leaves a stall record instead of waiting silently. A warning names the case where all four blocking-work slots are held. Refs #6184.
- **One unreadable saved session no longer empties the recent-sessions list.** The launch card and ACP `session/list` show the readable sessions and log the failure. Refs #7095.
- **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.
@@ -728,6 +628,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.
@@ -756,6 +662,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.
@@ -1144,7 +1056,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
- **[@Statter](https://github.com/Statter)** — reported black-on-black text on terminals that do not report their background ([#7043](https://github.com/codewhale-hq/Codewhale/issues/7043)).
- **[@eugenicum](https://github.com/eugenicum)** — asked for a visible Full Access flag for `serve --acp` ([#6992](https://github.com/codewhale-hq/Codewhale/issues/6992)).
- **[@luestr](https://github.com/luestr)** — reported file and conversation rollback diverging ([#6970](https://github.com/codewhale-hq/Codewhale/issues/6970)), the Linux multiline send key ([#7093](https://github.com/codewhale-hq/Codewhale/issues/7093)) and session export guidance ([#6996](https://github.com/codewhale-hq/Codewhale/issues/6996)).
- **[@ychu824](https://github.com/ychu824)** — reported every Anthropic request failing with a 400 once `tool_search` added a tool whose name contains a dot, such as `web.run` ([#7252](https://github.com/codewhale-hq/Codewhale/issues/7252)).
## [0.10.2] - 2026-10-22
Generated
+51 -174
View File
@@ -19,48 +19,6 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "agent-client-protocol"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32339d83e679c8092ec69d2f18e84448f4acf09b46a753e48f3e9265f920acb1"
dependencies = [
"agent-client-protocol-derive",
"agent-client-protocol-schema",
"anyhow",
"futures",
"futures-concurrency",
"rustc-hash",
"serde",
"serde_json",
"tracing",
"uuid",
]
[[package]]
name = "agent-client-protocol-derive"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05a39fd0d81daab854d25144e82e31006b87c554818beb695f0edb576e9c54c4"
dependencies = [
"quote",
"syn 3.0.9",
]
[[package]]
name = "agent-client-protocol-schema"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fd595be39fc4f9c8170872e5d10d81b44cb6dca44bcc66daba0d34412d4237d"
dependencies = [
"anyhow",
"derive_more",
"serde",
"serde_json",
"serde_with",
"strum",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -643,15 +601,6 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.13.0"
@@ -912,6 +861,14 @@ dependencies = [
"zeroize",
]
[[package]]
name = "codewhale-agent"
version = "0.10.3"
dependencies = [
"codewhale-config",
"serde",
]
[[package]]
name = "codewhale-agent-contract"
version = "0.10.3"
@@ -925,8 +882,6 @@ dependencies = [
name = "codewhale-app-server"
version = "0.10.3"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"async-stream",
"async-trait",
@@ -935,6 +890,7 @@ dependencies = [
"chrono",
"clap",
"codewhale-account-cli",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
"codewhale-build-support",
@@ -1010,7 +966,7 @@ version = "0.10.3"
dependencies = [
"chrono",
"jsonschema",
"schemars 1.2.2",
"schemars",
"serde",
"serde_json",
]
@@ -1040,7 +996,6 @@ dependencies = [
"codewhale-paths",
"codewhale-release",
"codewhale-runtime",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-state",
"codewhale-telemetry",
@@ -1110,13 +1065,12 @@ dependencies = [
"codewhale-execpolicy",
"codewhale-paths",
"codewhale-protocol",
"codewhale-sanitize",
"codewhale-secrets",
"fd-lock",
"libc",
"ring",
"rust_decimal",
"schemars 1.2.2",
"schemars",
"semver",
"serde",
"serde_json",
@@ -1134,6 +1088,7 @@ name = "codewhale-core"
version = "0.10.3"
dependencies = [
"codewhale-config",
"codewhale-hooks",
"codewhale-protocol",
"codewhale-state",
"regex",
@@ -1142,6 +1097,7 @@ dependencies = [
"sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
"tokio",
"uuid",
]
@@ -1167,15 +1123,15 @@ dependencies = [
name = "codewhale-engine"
version = "0.10.3"
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",
@@ -1210,6 +1166,7 @@ dependencies = [
"codewhale-util",
"codewhale-workflow",
"codewhale-workflow-js",
"cucumber",
"dirs 7.0.0",
"encoding_rs",
"fd-lock",
@@ -1228,14 +1185,16 @@ dependencies = [
"objc2-foundation",
"parking_lot",
"portable-pty",
"pretty_assertions",
"proc-macro2",
"regex",
"reqwest",
"ring",
"rio-vt",
"rmcp",
"rusqlite",
"rustls",
"schemars 1.2.2",
"schemars",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -1247,7 +1206,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",
@@ -1291,11 +1252,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",
@@ -1415,7 +1379,7 @@ version = "0.10.3"
dependencies = [
"codewhale-config",
"codewhale-runtime",
"codewhale-sanitize",
"codewhale-secrets",
"dirs 7.0.0",
"regex",
"tempfile",
@@ -1490,7 +1454,6 @@ dependencies = [
"codewhale-protocol",
"codewhale-sanitize",
"serde_json",
"unicode-width",
]
[[package]]
@@ -1592,12 +1555,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",
@@ -1865,7 +1828,9 @@ dependencies = [
"reqwest",
"rio-vt",
"rquickjs",
"rusqlite",
"rustls",
"schemars",
"semver",
"serde",
"serde_json",
@@ -1875,6 +1840,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",
@@ -1898,7 +1864,7 @@ version = "0.10.3"
dependencies = [
"codewhale-palette",
"codewhale-ratatui",
"codewhale-sanitize",
"codewhale-secrets",
"crossterm",
"ratatui",
"similar",
@@ -1978,7 +1944,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2423,9 +2389,6 @@ name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]]
name = "derive_more"
@@ -2505,7 +2468,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2646,7 +2609,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2749,7 +2712,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -2896,19 +2859,6 @@ dependencies = [
"futures-sink",
]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
dependencies = [
"fixedbitset 0.5.7",
"futures-core",
"futures-lite",
"pin-project",
"smallvec",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -3773,7 +3723,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
"indexmap 2.14.0",
"indexmap",
"slab",
"tokio",
"tokio-util",
@@ -3800,12 +3750,6 @@ dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -4212,17 +4156,6 @@ dependencies = [
"quick-error",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -4972,7 +4905,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5413,7 +5346,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset 0.5.7",
"hashbrown 0.15.5",
"indexmap 2.14.0",
"indexmap",
]
[[package]]
@@ -6145,13 +6078,13 @@ dependencies = [
"async-trait",
"chrono",
"futures",
"indexmap 2.14.0",
"indexmap",
"libc",
"oauth2",
"pastey",
"pin-project-lite",
"reqwest",
"schemars 1.2.2",
"schemars",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -6193,7 +6126,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a2ea9adb03b669c933482ff740b683cd36be7cef23891657a8ccb850401d286"
dependencies = [
"convert_case 0.12.0",
"indexmap 2.14.0",
"indexmap",
"proc-macro-crate",
"proc-macro2",
"quote",
@@ -6318,15 +6251,15 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.1.6"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags 2.13.1",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6382,7 +6315,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6451,18 +6384,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
@@ -6632,7 +6553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"foldhash 0.2.0",
"indexmap 2.14.0",
"indexmap",
"itoa",
"memchr",
"serde",
@@ -6692,39 +6613,6 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [
"base64 0.23.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serial2"
version = "0.2.38"
@@ -7192,7 +7080,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -7224,7 +7112,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.60.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -7377,7 +7265,6 @@ dependencies = [
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
@@ -7386,16 +7273,6 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
@@ -7537,7 +7414,7 @@ version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap 2.14.0",
"indexmap",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -7570,7 +7447,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.14.0",
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
@@ -7584,7 +7461,7 @@ version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap 2.14.0",
"indexmap",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
@@ -8343,7 +8220,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
+1 -2
View File
@@ -1,6 +1,7 @@
[workspace]
members = [
"crates/account-cli",
"crates/agent",
"crates/agent-contract",
"crates/app-server",
"crates/app-server-protocol",
@@ -72,8 +73,6 @@ repository = "https://github.com/codewhale-hq/CodeWhale"
warnings = "deny"
[workspace.dependencies]
agent-client-protocol = { version = "=5.2.0", default-features = false }
agent-client-protocol-schema = { version = "=1.17.0", default-features = false }
anyhow = "1.0.100"
async-trait = "0.1.89"
axum = { version = "0.8.5", features = ["json", "ws"] }
+20
View File
@@ -0,0 +1,20 @@
# Extension packager dependency security
The VS Code extension's packaging toolchain uses `@vscode/vsce` →
`@azure/identity` → `@azure/msal-node`. MSAL Node versions before 7.1.0 are
affected by [GHSA-xcx6-jx8p-x3h5](https://github.com/AzureAD/microsoft-authentication-library-for-js/security/advisories/GHSA-xcx6-jx8p-x3h5).
The extension manifest overrides Azure Identity's MSAL Node dependency to
`^7.1.2`; its lockfile resolves 7.1.2. Keep the override until Azure Identity
requires a patched version itself.
`npm run audit:dependencies -- --report <path>` audits the complete lockfile
and keeps the raw report. Every reported vulnerability blocks the check.
Incomplete reports, count mismatches and critical findings also fail. There
is no advisory exception.
After changing this dependency, materialize the lockfile with `npm ci`, run
the extension's existing `npm test` checks and build a local package with
`npm run package`. Packaging uses `vsce package --no-dependencies`, which
excludes development dependencies from the VSIX. A local package build does
not establish that a publishing authentication flow works; verify that flow
only when publication is separately authorized.
+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
+203 -205
View File
@@ -1,25 +1,21 @@
<div align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="brand/mark-reversed.svg">
<img src="brand/mark-mono.svg" alt="Codewhale" width="72" height="72">
<source media="(prefers-color-scheme: dark)" srcset="brand/wordmark-inverted.svg">
<img src="brand/wordmark.svg" alt="Codewhale" width="320">
</picture>
<h1>The model can be anyone's. The agent should be yours.</h1>
**An open-source coding agent for your terminal that works with any model.**
Codewhale is an open-source coding agent for your terminal, written in Rust.
It works with any model, can split one task across several agents, and every
line of it is yours to read.
Give Codewhale a task and it reads the repository, edits files, and runs
commands until the task is done or it needs input from you. It works with more
than 40 built-in providers, DeepSeek V4.1 Flash by default, and with models running on
your own machine. `/receipts` lists the files it changed, the commands it ran,
and the web and MCP calls it made.
```bash
curl -fsSL https://codewhale.net/install.sh | sh
```
This installs the latest release on macOS or Linux, and it needs no account.
[Windows, npm and Cargo](#install) are below.
[![Latest release](https://img.shields.io/github/v/release/codewhale-hq/Codewhale?label=release)](https://github.com/codewhale-hq/Codewhale/releases/latest)
[![CI](https://github.com/codewhale-hq/Codewhale/actions/workflows/ci.yml/badge.svg)](https://github.com/codewhale-hq/Codewhale/actions/workflows/ci.yml)
[![CI](https://github.com/codewhale-hq/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/codewhale-hq/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
@@ -27,41 +23,63 @@ This installs the latest release on macOS or Linux, and it needs no account.
[简体中文](docs/i18n/README.zh-CN.md) · [日本語](docs/i18n/README.ja-JP.md) · [Tiếng Việt](docs/i18n/README.vi.md) · [Bahasa Indonesia](docs/i18n/README.id.md) · [한국어](docs/i18n/README.ko-KR.md) · [Español](docs/i18n/README.es-419.md) · [Português](docs/i18n/README.pt-BR.md) · [Русский](docs/i18n/README.ru.md) · [Українська](docs/i18n/README.uk.md) · [Français](docs/i18n/README.fr.md) · [Deutsch](docs/i18n/README.de.md) · [繁體中文](docs/i18n/README.zh-TW.md) · [हिन्दी](docs/i18n/README.hi.md) · [Türkçe](docs/i18n/README.tr.md) · [Italiano](docs/i18n/README.it.md) · [Polski](docs/i18n/README.pl.md) · [العربية](docs/i18n/README.ar.md) · [Català](docs/i18n/README.ca.md)
<img src="web/public/codewhale-tui-0102-demo.png" width="760" alt="Codewhale in a terminal after a demo task. It reports that it added a --json flag to export.py, that all 3 local tests passed and that a delegated review found no issues. The task list below shows three completed steps, and the status line shows Full Access and the local website-demo provider.">
<img src="web/public/codewhale-tui-0102-demo.png" alt="A Codewhale terminal session" width="760">
<sub>A development build of 0.10.3, which is not released yet, with a scripted test model, in Full Access inside a throwaway workspace. The edit, three tests and a sub-agent review ran for real.</sub>
<sub>A 0.10.2 development build with a scripted test model, in Full Access inside a throwaway workspace. The edit, three tests and a sub-agent review ran for real.</sub>
</div>
<p align="center">
<a href="https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67" rel="sponsored">
<img src="web/public/codewhale-x-orcarouter.webp" alt="Codewhale × OrcaRouter — +30% DeepSeek v4.1 Flash usage" width="760">
</a>
</p>
[View the Codewhale × OrcaRouter offer](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67). Codewhale earns a referral fee from OrcaRouter.
## Install
The command above puts checksum-verified `codewhale` and its short alias
`codew` in `~/.local/bin`. If your shell then says `command not found`,
follow the PATH note the installer prints
([details](docs/INSTALL.md#put-it-on-your-path)). `codewhale update` upgrades
it.
macOS and Linux:
```bash
winget install --id HunterBown.CodeWhale --exact --source winget # Windows x64
npm install -g codewhale # wraps the same release binaries
cargo install codewhale-cli --locked # builds from source; can trail GitHub Releases
curl -fsSL https://codewhale.net/install.sh | sh
```
Prebuilt binaries cover Linux, macOS and Windows on x64 and arm64. Scoop, Nix,
Homebrew on Linux, Android and Termux (preview) and manual downloads are in the
[installation guide](docs/INSTALL.md), and containers are in
[Docker](docs/DOCKER.md). Use one install method per machine; otherwise two
copies end up on your `PATH` and whichever comes first wins.
The installer puts checksum-verified `codewhale` and its short alias `codew`
in `~/.local/bin`. If your shell then says `command not found`, run the PATH
line the installer prints ([details](docs/INSTALL.md#put-it-on-your-path)).
`codewhale update` upgrades it. These commands install the latest release.
This page follows `main`, so a feature marked with a version arrives in that
release; the [changelog](CHANGELOG.md) lists what each release adds.
<details>
<summary><b>Windows, npm, Cargo, and other ways to install</b></summary>
```bash
winget install HunterBown.CodeWhale # Windows x64 (or Scoop, or the installer from GitHub Releases)
npm install -g codewhale # wraps the same release binaries
cargo install codewhale-cli --locked # crates.io; can trail GitHub Releases
```
Prebuilt binaries cover Linux, macOS, and Windows on x64 and arm64. Nix,
Homebrew on Linux, Android/Termux (preview), and manual downloads are in the
[installation guide](docs/INSTALL.md), and containers in
[Docker](docs/DOCKER.md). Stick to one install method per machine; otherwise
two copies end up on `PATH` and whichever comes first wins.
</details>
## Quickstart
1. Commit or stash your work, then run `codewhale` from the root of a git
repository. By default it applies file edits there without asking and asks
before each shell command that is not read-only.
2. Run `/provider` (or press `F3`), pick a provider, paste its API key and
choose a model. DeepSeek is the default provider. On a first launch with no
key, Codewhale asks for a provider when you send your first message, and it
offers a local model when a local Ollama server answers.
repository. By default (**Accept Edits**) it edits files in the repository
without asking and asks before each command; `Shift+Tab` steps the
permission dial when you want it to ask more or less.
2. Run `/provider` (or press `F3`), pick a provider, paste its API key, and
choose a model. DeepSeek is the default provider. With no key, pick a local
runtime instead. If no provider is configured and Ollama is already serving
a chat model, Codewhale uses it automatically; from 0.10.2 it offers it when
you send your first message.
3. Type a task:
```text
@@ -70,161 +88,94 @@ Fix the failing tests and explain what changed.
Read-only commands run straight away. For any other shell command Codewhale
asks first: `y` runs it once, `a` allows commands like it for the rest of the
conversation, and `n` refuses. `/undo` takes back your last request and its
file changes, and `/help` lists commands and keys.
conversation, `n` refuses. `/undo` takes back your last request and its file
changes. `/help` lists commands and keys.
For scripts and CI, `codewhale exec` runs one task without the interface. Read
[headless runs](docs/INSTALL.md#headless-scripts-ci) before you give it tools
with `--auto`, because `--auto` uses Full Access unless you add a sandbox.
For scripts and CI, `codewhale exec` runs one task without the interface. By
default, `--auto` uses Full Access: tools run without asking, shell commands
have no Codewhale sandbox, and trust mode lets file tools write outside the
workspace. Add `--sandbox workspace-write` to keep the shell sandbox and
file-tool workspace restrictions, with trust mode off:
```bash
codewhale exec --auto --sandbox workspace-write "fix the failing tests and explain what changed"
```
## Modes and permissions
A fresh install starts in Work mode with the Accept Edits permission. One dial
switches both: `Shift+Tab` steps Ask → Accept Edits → Plan → Auto-Review →
Full Access.
| | Switch with | Options |
| --- | --- | --- |
| Mode | the Plan stop on `Shift+Tab`, or `/mode` | **Plan** reads and searches but changes nothing. **Work** (every other stop) edits files and runs commands; start a goal with `/goal` when a request needs more than one turn. |
| Permission | `Shift+Tab` | **Ask** asks before every file edit and every shell command that is not read-only. **Accept Edits** applies file edits in your git repository without asking and still asks before commands. **Auto-Review** lets rules and a reviewer model approve routine calls and refuses high-risk ones; switch to Ask to approve those yourself. **Full Access** never asks, and commands can reach anything on this computer; repository rules and managed policy still apply. |
See the [modes and permissions guide](docs/MODES.md) for the details.
## What it does
Give Codewhale a task and it reads the repository, edits files and runs
commands until the task is done or it needs your input. The
[latest release notes](https://github.com/codewhale-hq/Codewhale/releases/latest)
list the newest changes, and the [changelog](CHANGELOG.md) has every
version.
### New in 0.10.2
### Any model
- `/pet on` turns your terminal into an animated whale that shows what the
agent is doing, while the message box stays live. Its 980 particles spell
the current step in English, Chinese or Japanese.
- `/workbar terminal` shows the shells the agent is running as they run. Type
into one, or press `Ctrl+N` for a shell of your own (macOS and Linux).
- `/undo` takes back your whole last request in one step, including every file
it changed, and `/diff` works in folders without Git.
- When a plan is ready, Codewhale asks whether to start Work with Ask or
Auto-Review, or keep planning.
- `/color` gives each chat its own composer color, saved with the session.
- `web_search` can use DeepSeek's new Search API: set
`[search] provider = "deepseek"` with a DeepSeek key.
- Outside Full Access, a `curl` or `wget` that would upload a credential never
runs without your approval, and a command that hides invisible Unicode is
refused. `write` no longer replaces a file the agent has not read, or one
that changed after it read it.
### Models
More than 40 providers are built in, including Anthropic, OpenAI, DeepSeek,
Google, Mistral, Moonshot, OpenRouter and xAI. Any OpenAI-compatible endpoint
works, and so do local models through Ollama, vLLM or SGLang. The default
model is DeepSeek's `deepseek-flash`, which now serves DeepSeek V4.1 Flash
Google, Mistral, Moonshot, OpenRouter, and xAI. Any OpenAI-compatible endpoint
works, and so do local models through Ollama, vLLM, or SGLang. The default is
DeepSeek's `deepseek-flash`, which DeepSeek moved to DeepSeek-V4.1-Flash-1017
on 2026-10-17; 0.10.1 runs it without an update
([details](docs/PROVIDERS.md#deepseek-v41-flash)).
`/model` switches models between turns, and `/model auto` picks one for each
turn (`/router` sets up how). `/turn inspect` shows which model a turn used,
what it cost and how long it took. See [Providers](docs/PROVIDERS.md).
what it cost and how long it took. `codewhale auth chatgpt` lets you use a
ChatGPT plan instead of an API key (experimental). See
[Providers](docs/PROVIDERS.md).
### Receipts and undo
`/receipts`, or `codewhale receipts --last` after you quit, lists the files a
session changed, the commands it ran, its web and MCP calls, the agents it
started, each approval and who gave it, and what failed. `/undo` takes back
your last request and the files it changed in one step. `/restore` rolls the
whole workspace back to an earlier snapshot, and because that overwrites later
edits it needs `/trust on` or Full Access. See [Receipts](docs/RECEIPTS.md).
`/receipts`, or `codewhale receipts --last` after you quit, lists the files
changed, commands run, web and MCP calls, agents started, approvals and who
gave them, and failures. `/undo` takes back your last request and the files it
changed, in one step and without trust mode. `/restore`
rolls the whole workspace back to an earlier snapshot; because that overwrites
later edits, it needs `/trust on` or Full Access. See [Receipts](docs/RECEIPTS.md).
### Several agents on one task
Exec runs that offer tools are saved in every output format, so
`codewhale receipts --last` works after `exec --auto`, including `--json`.
Answer-only text and `--json` runs are saved only when resuming or when
conversation compaction starts; use `--output-format stream-json` to always
save them for receipts or `exec --continue`.
### Longer work
`/goal` sets an objective that carries across turns. Codewhale can split work
across [sub-agents](docs/SUBAGENTS.md), run a [Fleet](docs/FLEET.md) of agents
with their own roles and models, or follow a
[workflow](docs/WORKFLOW_AUTHORING.md) checked into the repository.
across [sub-agents](docs/SUBAGENTS.md), run a [fleet](docs/FLEET.md) of agents,
or follow a [workflow](docs/WORKFLOW_AUTHORING.md) checked into the
repository.
### Extensions
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. 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
| Command | What it does |
| --- | --- |
| `codewhale` | The interactive terminal app |
| `codewhale exec "…"` | One task without the interface, for scripts and CI ([headless runs](docs/INSTALL.md#headless-scripts-ci)) |
| `codewhale web` | A local web client on `127.0.0.1` ([guide](docs/WEB.md)) |
| `codewhale review --pr N` | An advisory review of a GitHub pull request; the [GitHub bot setup](docs/GITHUB_ACTION.md#set-up-your-own-bot) runs it on your own model key |
| `codewhale app-server --http` | The [Runtime API](docs/RUNTIME_API.md), a local HTTP API for threads, events, approvals and each turn's file changes |
| `codewhale serve --acp` | The Agent Client Protocol over stdio, for editors such as Zed |
The terminal app, `exec`, `web` and the Runtime API share one engine
([architecture](docs/ARCHITECTURE.md)). A community-maintained
[VS Code extension](https://marketplace.visualstudio.com/items?itemName=HengQuWorld.brotherwhale-vscode)
connects to it from the sidebar ([source](https://github.com/HengQuWorld/CodeWhale-VSCode)).
<p align="center">
<a href="https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67" rel="sponsored">
<img src="web/public/codewhale-x-orcarouter.webp" alt="Codewhale × OrcaRouter: +30% DeepSeek V4.1 Flash usage" width="560">
</a>
</p>
<a href="https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67" rel="sponsored">Codewhale × OrcaRouter</a>
gives 30% extra DeepSeek V4.1 Flash credit on each OrcaRouter top-up until
November 21, 2026. This link is part of a referral campaign; Codewhale may
earn a referral reward on top-ups made through it.
## How it stays safe
Two switches say how much Codewhale may do on its own. `Tab`, when the input
is empty, changes the mode: **Plan** reads and searches but changes nothing,
**Work** edits files and runs commands, and **Operate** has the same access as
Work and keeps going until the goal is verified or it needs you. `Shift+Tab`
changes the permission. **Ask**, the default, applies file edits in your
repository without asking and asks before shell commands that are not
read-only.
**Auto-Review** lets rules and a reviewer model approve routine calls and
refuses high-risk ones; switch to Ask to approve those yourself. **Full
Access** never asks and turns off Codewhale's sandbox, although repository
rules and managed policy still apply. The
[v0.10.2 guide](https://github.com/codewhale-hq/Codewhale/blob/v0.10.2/docs/MODES.md)
has the details.
Coming in 0.10.3: `Shift+Tab` becomes one dial through Ask, Accept Edits,
Plan, Auto-Review and Full Access, `Tab` no longer changes the mode, and
Operate is retired. Ask will ask before every file edit too, and the new
default, Accept Edits, will apply edits inside the git repository and still ask
before commands. The [current guide](docs/MODES.md) describes it.
In Ask and Auto-Review, commands run in an OS sandbox where the system has
one: Seatbelt on macOS, and bubblewrap on Linux when it is installed. Windows
has no OS sandbox yet, and Full Access turns the sandbox off. Inside the
sandbox, shell commands and terminal sessions cannot read credential stores
such as `~/.ssh`, `~/.aws` and `.env` files.
`/preview-request` shows a redacted preview of the next request without
sending it.
You need no account, and keys you add with `/provider` stay on your machine.
With your own API key you pay the provider directly, and local models need no
key. The footer and `/cost` show what a session has cost when the price is
known, and an unknown price is shown as unknown, never as free.
Telemetry is on by default. It sends aggregate counts (version and platform,
session length and outcome, which built-in providers you used, feature and
error counts) with a random install ID that rotates every 90 days, and never
conversations, code, prompts, model names, file or repository names, or
credentials. This command turns it off:
```bash
codewhale config set telemetry false
```
At startup Codewhale also checks GitHub for a new release; set
`CODEWHALE_NO_UPDATE_CHECK=1` to skip that. More in
[authorization order](docs/AUTHORIZATION_ORDER.md),
[sandboxing](docs/SANDBOX.md) and [telemetry](docs/TELEMETRY.md).
## Where it is going
Codewhale is becoming one environment across your terminal, a desktop app,
the browser and cloud computers, all run by one engine. Each part says where
it stands today.
| Part | State | Today |
| --- | --- | --- |
| Terminal app | Released | Install it on macOS, Linux or Windows and run `codewhale` in any repository. |
| Local browser client | Released | Run `codewhale web` to read the conversation, send a task and answer approvals in your browser; it comes with the terminal and needs no account. |
| Many agents on one task | Released | Sub-agents, Fleets and workflows let several agents share one task, each with its own role. |
| Desktop app | In development, no download | We are building a desktop app on the same engine, and it has no public download yet. |
| Cloud computers | Invite-only | Cloud computers are invite-only, and we are building them so a session can keep working on a machine in the cloud. |
| One session across them | Being built | We are building one session that goes with you from the terminal to the desktop, the browser and the cloud, and stays yours the whole way. |
The [roadmap](docs/ROADMAP.md) says what each release covers and where you
can help.
## Coming from another agent
### Coming from another agent
Codewhale reads `AGENTS.md` on its own. Instruction files written for other
tools, such as `CLAUDE.md`, `.cursorrules` or
tools, such as `CLAUDE.md`, `.cursorrules`, or
`.github/copilot-instructions.md`, load only when you opt in with
`project_instruction_imports = ["claude"]` (or `"cursor"`, `"cline"`,
`"windsurf"`, `"copilot"`, `"all"`) in your config; see
@@ -232,45 +183,90 @@ tools, such as `CLAUDE.md`, `.cursorrules` or
it did not load.
`/import-claude` prepares an import of your Claude Code settings and MCP
servers for you to review. `/plugin install` accepts Claude Code plugins that
contain skills, commands, agent profiles and MCP servers, and refuses plugins
that need hooks or LSP servers
servers for you to review. `/plugin install` accepts Claude Code plugins
containing skills, commands, agent profiles, and MCP servers, and refuses
plugins that need hooks or LSP servers
([details](docs/CLAUDE_PLUGIN_COMPAT.md)).
### Extensions
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. The independently installed Computer
Use plugin (experimental, macOS only) can observe
and operate other apps. It stays off until you review its access and enable it
([guide](https://github.com/codewhale-hq/codewhale-cu-plugin)).
## Ways to run it
| Command | What it does |
| --- | --- |
| `codewhale` | The interactive terminal app |
| `codewhale exec "…"` | One task without the interface. On its own it answers without using tools; `--auto` enables tools with Full Access by default, `--sandbox workspace-write` restricts them, and `--output-format stream-json` streams events as JSON |
| `codewhale web` | A local web UI on `127.0.0.1` ([guide](docs/WEB.md)) |
| `codewhale review --pr N` | An advisory review of a GitHub pull request. The [GitHub bot setup](docs/GITHUB_ACTION.md#set-up-your-own-bot) generates a pinned workflow for automatic reviews and `@codewhale review`, using your own model key and optional App identity |
| `codewhale app-server --http` | The [Runtime API](docs/RUNTIME_API.md), a local HTTP API for threads, events, approvals, and each turn's file changes |
| `codewhale serve --acp` | The Agent Client Protocol over stdio, for editors such as Zed |
The terminal app, `exec`, `web`, and the Runtime API share one engine
([architecture](docs/ARCHITECTURE.md)). A community-maintained
[VS Code extension](https://marketplace.visualstudio.com/items?itemName=HengQuWorld.brotherwhale-vscode)
connects to it from the sidebar ([source](https://github.com/HengQuWorld/CodeWhale-VSCode)).
A native desktop app built on the Runtime API is in development; it has no
public download yet.
## Cost and accounts
Codewhale is free and MIT licensed. With your own API key you pay the provider
directly; local models need no key. The footer and `/cost` show what a session
has cost when the price is known, and an unknown price is shown as unknown,
never as free.
You don't need an account. Keys you add with `/provider` stay on your machine.
## Safety and privacy
In Ask and Auto-Review, commands run in an OS sandbox where the system has
one: Seatbelt on macOS, and bubblewrap on Linux when it is installed.
Windows has no OS sandbox yet, and Full Access turns the sandbox off. Inside
the sandbox, shell commands cannot read credential stores such as `~/.ssh`,
`~/.aws` and `.env` files; from 0.10.2 the same rule covers terminal sessions
and more file tools. `/preview-request` shows a redacted preview of the next
request without sending it.
Telemetry is on by default. It sends aggregate counts (version and platform,
session length and outcome, which built-in providers you used, feature and
error counts) with a random install ID that rotates every 90 days, and never
conversations, code, prompts, model names, file or repository names, or
credentials. `codewhale config set telemetry false` turns
it off. At startup Codewhale also
checks GitHub for a new release; set `CODEWHALE_NO_UPDATE_CHECK=1` to skip
that. More in [authorization order](docs/AUTHORIZATION_ORDER.md),
[sandboxing](docs/SANDBOX.md), and [telemetry](docs/TELEMETRY.md).
## Documentation
These guides follow the main branch and can describe changes that are not
in the latest release yet.
| Start here | Reference |
| --- | --- |
| [User guide](docs/GUIDE.md) | [Configuration](docs/CONFIGURATION.md) |
| [Installation](docs/INSTALL.md) | [Architecture](docs/ARCHITECTURE.md) |
| [Providers and local models](docs/PROVIDERS.md) | [Runtime API](docs/RUNTIME_API.md) |
| [Modes and permissions](docs/MODES.md) | [Plugin authoring](docs/PLUGIN_AUTHORING.md) |
| [Keybindings](docs/KEYBINDINGS.md) | [Receipts](docs/RECEIPTS.md) |
| [GitHub pull request review](docs/GITHUB_ACTION.md) | [All documentation](docs/README.md) |
| [Installation](docs/INSTALL.md) | [Configuration](docs/CONFIGURATION.md) |
| [Providers and local models](docs/PROVIDERS.md) | [Architecture](docs/ARCHITECTURE.md) |
| [Modes and permissions](docs/MODES.md) | [Runtime API](docs/RUNTIME_API.md) |
| [Keybindings](docs/KEYBINDINGS.md) | [Plugin authoring](docs/PLUGIN_AUTHORING.md) |
| [GitHub PR review](docs/GITHUB_ACTION.md) | [All documentation](docs/README.md) |
## Contributing
## Community
Codewhale is built in the open by the people who use it, in many languages.
<a href="docs/CONTRIBUTORS.md#everyone"><img src="docs/assets/contributors.png" width="760" alt="The Codewhale contributor wall: one avatar for each person whose code, co-authored commits, carried-forward pull requests or credited reports are in Codewhale, with the total count at the top."></a>
The [contributor wall](docs/CONTRIBUTORS.md) counts everyone whose code,
co-authored commits, carried-forward pull requests or credited reports are in
Codewhale, including people GitHub's own contributor list misses.
`scripts/contributor-wall.py` regenerates the picture and the list from git
history and GitHub.
All feedback is welcome: a bug, an idea, or something that looked or felt
wrong. [Open an issue](https://github.com/codewhale-hq/Codewhale/issues/new/choose)
for any of these; an issue is also the best place to start a new feature.
Pull requests are welcome for fixes, translations, providers and planned work.
Questions go to [Discussions](https://github.com/codewhale-hq/Codewhale/discussions).
The [contributing guide](CONTRIBUTING.md) shows where each kind of help goes,
how your work lands with your name on it, and
[where things live](CONTRIBUTING.md#project-structure).
Codewhale is what it is because of its community, and all feedback is
welcome: a bug, an idea, or something that looked or felt wrong.
[Open an issue](https://github.com/codewhale-hq/CodeWhale/issues/new/choose) for any
of these; an issue is also the best place to start a new feature. Pull requests
are welcome for fixes, translations, providers and planned work. The
[contributing guide](CONTRIBUTING.md) shows where each kind of help goes, and
the [roadmap](docs/ROADMAP.md) shows where the project is headed. Contributors
keep credit for the work that lands, and the
[repository layout](CONTRIBUTING.md#project-structure) shows where things live.
Chat on [Discord](https://discord.gg/37gfS3ksug), or add the maintainer,
Hunter, on WeChat (`hunterbown`) and ask to join the Whale Brothers group.
@@ -280,8 +276,10 @@ Everyone is welcome.
Codewhale started as `deepseek-tui` and still reads that project's
configuration and sessions. It is independently maintained and not affiliated
with DeepSeek or any other model provider.
with DeepSeek or any other model provider. Thanks to
[everyone who has contributed](docs/CONTRIBUTORS.md).
[![Codewhale contributors](docs/assets/contributors.png)](docs/CONTRIBUTORS.md#everyone)
[MIT](LICENSE). Code adapted from other open-source projects is listed in the
third-party notices for [vendored sources](THIRD_PARTY_NOTICES.md) and
[adapted designs](docs/THIRD_PARTY_NOTICES.md).
[third-party notices](docs/THIRD_PARTY_NOTICES.md).
-38
View File
@@ -164,44 +164,6 @@ The host bundle's own copy of these notices is `dist/LICENSES.txt`, generated
from the bundler's metafile; Codewhale writes it beside the bundle it
materialises (`~/.codewhale/extension-host/<sha256>/LICENSES.txt`).
## DeepSeek Harness Creator files (`dsh-agent-preset` skills, `dsh-plugin-manager`, `dsh-tool-cordis`, `dsh-sandbox`) — MIT
Codewhale ships, unmodified, the four authoring skills of
`@deepseek-ai/dsh-agent-preset` 0.2.2-rc.1
(`crates/skills/assets/dsh-creator/`), and keeps byte-for-byte copies of
`@deepseek-ai/dsh-plugin-manager` 0.2.2-rc.1 `lib/types/tools.js` and
`@deepseek-ai/dsh-tool-cordis` 0.2.2-rc.1 `lib/index.js`
(`crates/engine/extension-host/src/dsh/upstream/creator/`).
`@deepseek-ai/dsh-sandbox` 0.2.2-rc.1 is named for the approval excerpt that
slice will add. Each package is published under the licence text below, and
each pin's `UPSTREAM.json` records the SHA-256 of every file.
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
```
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Model Context Protocol SDK and bundled dependencies
The pinned builtin MCP bundle (`crates/engine/extension-host/dist/builtin/mcp.mjs`)
+2 -3
View File
@@ -924,7 +924,7 @@ base_url = "https://api.deepseek.com/beta"
# (https://bailian.console.aliyun.com/)
#
# Token Plan Personal and Team share the same AP-Southeast (Singapore) endpoint.
# Available text/coding models: qwen3.8-max, qwen4-max-preview, qwen3.7-plus,
# Available text/coding models: qwen3.8-max, qwen3.8-max-preview, qwen3.7-plus,
# qwen3.7-max, qwen3.6-flash, deepseek-v4-pro, deepseek-v4-flash-0731, glm-5.2
#
# Provider aliases: modelstudio-token-plan, modelstudio_token_plan,
@@ -944,8 +944,7 @@ base_url = "https://api.deepseek.com/beta"
# (https://bailian.console.aliyun.com/)
#
# Coding Plan uses a separate international endpoint (coding-intl.dashscope).
# Same model ids as the Token Plan except qwen4-max-preview, which is
# Token Plan only.
# Same model catalog as the Token Plan.
#
# Provider aliases: modelstudio-coding-plan, modelstudio_coding_plan,
# alibaba-coding-plan, dashscope-coding-plan
+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
@@ -391,11 +391,6 @@
"type": "boolean",
"default": false
},
"thread_fleet_member_durable_operations_v1": {
"type": "boolean",
"description": "Member starts are durable per `(store, thread, operation_key)`;\n`GET /v1/threads/{id}/members/operations/{operation_key}` reads them.",
"default": false
},
"fleet_run_start": {
"type": "boolean",
"default": false
@@ -487,7 +482,6 @@
"worker_runtime",
"fleet_run_create",
"thread_fleet_members",
"thread_fleet_member_durable_operations_v1",
"fleet_run_start",
"fleet_event_replay",
"fleet_event_stream",
@@ -86,7 +86,6 @@ export type RuntimeCapabilities = {
worker_runtime: boolean
fleet_run_create: boolean
thread_fleet_members: boolean
thread_fleet_member_durable_operations_v1: boolean
fleet_run_start: boolean
fleet_event_replay: boolean
fleet_event_stream: boolean
@@ -173,10 +173,6 @@ pub struct RuntimeCapabilities {
pub fleet_run_create: bool,
#[serde(default)]
pub thread_fleet_members: bool,
/// Member starts are durable per `(store, thread, operation_key)`;
/// `GET /v1/threads/{id}/members/operations/{operation_key}` reads them.
#[serde(default)]
pub thread_fleet_member_durable_operations_v1: bool,
#[serde(default)]
pub fleet_run_start: bool,
#[serde(default)]
@@ -554,7 +550,6 @@ mod tests {
worker_runtime: false,
fleet_run_create: true,
thread_fleet_members: true,
thread_fleet_member_durable_operations_v1: true,
fleet_run_start: true,
fleet_event_replay: true,
fleet_event_stream: true,
+1 -2
View File
@@ -26,12 +26,12 @@ default = []
test-support = ["codewhale-engine/test-support"]
[dependencies]
agent-client-protocol.workspace = true
codewhale-account-cli = { path = "../account-cli", version = "0.10.3" }
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
anyhow.workspace = true
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" }
@@ -94,7 +94,6 @@ urlencoding = "2.1"
webbrowser = "1.0"
[dev-dependencies]
agent-client-protocol-schema.workspace = true
rusqlite.workspace = true
tempfile.workspace = true
tower = "0.5"
File diff suppressed because it is too large. Load diff
@@ -1,441 +0,0 @@
use super::*;
use agent_client_protocol::v1::{ConnectionTo, Responder};
use agent_client_protocol::{Agent, Client, Lines, UntypedMessage};
use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, MAX_FRAME_BYTES};
use futures_util::{Sink, Stream};
use std::io;
use std::sync::{MutexGuard, PoisonError};
use tokio::sync::{Notify, mpsc};
const PROMPT_BUSY: &str = "a session/prompt turn is already in progress";
const OUTGOING_QUEUE_FRAMES: usize = 256;
const OVERSIZE_RESPONSE: &str =
"ACP response exceeds the 8 MiB transport limit; inspect canonical state without replay";
type Cx = ConnectionTo<Client>;
pub(super) struct Shared {
server: tokio::sync::Mutex<AcpServer>,
prompt: std::sync::Mutex<Option<mpsc::UnboundedSender<Inbound>>>,
idle: Notify,
ending: Notify,
failure: std::sync::Mutex<Option<anyhow::Error>>,
}
struct PromptGuard(Arc<Shared>);
impl Drop for PromptGuard {
fn drop(&mut self) {
self.0.release();
}
}
impl PromptGuard {
fn finish(self, responder: Responder<Value>, result: std::result::Result<Value, SdkError>) {
let mut slot = self.0.slot();
let _ = responder.respond_with_result(result);
*slot = None;
drop(slot);
self.0.idle.notify_waiters();
}
}
impl Shared {
pub(super) fn new(server: AcpServer) -> Self {
Self {
server: tokio::sync::Mutex::new(server),
prompt: std::sync::Mutex::new(None),
idle: Notify::new(),
ending: Notify::new(),
failure: std::sync::Mutex::new(None),
}
}
pub(super) fn fail(&self, error: anyhow::Error) {
self.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_or_insert(error);
self.tell_prompt(Inbound::Eof);
self.ending.notify_one();
}
pub(super) fn take_failure(&self) -> Option<anyhow::Error> {
self.failure
.lock()
.unwrap_or_else(PoisonError::into_inner)
.take()
}
#[cfg(test)]
pub(super) fn into_server(self) -> AcpServer {
self.server.into_inner()
}
fn slot(&self) -> MutexGuard<'_, Option<mpsc::UnboundedSender<Inbound>>> {
self.prompt.lock().unwrap_or_else(PoisonError::into_inner)
}
fn prompt_active(&self) -> bool {
self.slot().is_some()
}
fn tell_prompt(&self, message: Inbound) {
if let Some(inbox) = self.slot().as_ref() {
let _ = inbox.send(message);
}
}
fn release(&self) {
*self.slot() = None;
self.idle.notify_waiters();
}
async fn wait_idle(&self) {
loop {
let notified = self.idle.notified();
if !self.prompt_active() {
return;
}
notified.await;
}
}
fn cancel(&self, params: &Value) {
if let Some(session) = params.get("sessionId").and_then(Value::as_str) {
self.tell_prompt(Inbound::Cancel {
session: session.to_string(),
});
}
}
async fn request(
self: &Arc<Self>,
method: String,
params: Value,
responder: Responder<Value>,
cx: Cx,
) {
match method.as_str() {
"session/cancel" => {
self.cancel(&params);
let _ = responder.respond(Value::Null);
}
"session/prompt" => self.start_prompt(params, responder, cx).await,
_ if self.prompt_active() => self.during_prompt(&cx, method, params, responder),
"shutdown" => {
let _ = responder.respond(Value::Null);
self.ending.notify_one();
}
"session/load" => self.load(params, responder, cx).await,
_ => {
let result = self
.server
.lock()
.await
.handle_request(&method, params)
.await;
let _ = responder.respond_with_result(result);
}
}
}
fn during_prompt(
self: &Arc<Self>,
cx: &Cx,
method: String,
params: Value,
responder: Responder<Value>,
) {
let served = matches!(
method.as_str(),
"session/list"
| "session/set_config_option"
| "session/set_mode"
| "session/set_model"
| "session/listProviders"
| "session/currentModel"
| "session/selectModel"
);
if !served {
let error = if matches!(
method.as_str(),
"initialize" | "session/new" | "session/load" | "shutdown"
) {
SdkError::new(-32603, PROMPT_BUSY)
} else {
SdkError::method_not_found().data(Value::String(method))
};
let _ = responder.respond_with_error(error);
return;
}
let shared = Arc::clone(self);
let _ = cx.spawn(async move {
let result = shared
.server
.lock()
.await
.handle_request(&method, params)
.await;
let _ = responder.respond_with_result(result);
Ok(())
});
}
async fn load(&self, params: Value, responder: Responder<Value>, cx: Cx) {
let mut server = self.server.lock().await;
let result = async {
let loaded = server.load_session(params).await.map_err(SdkError::from)?;
if let Some(session) = loaded.get("sessionId").and_then(Value::as_str) {
let updates = server
.history_updates(session)
.await
.map_err(|error| SdkError::new(-32603, error.to_string()))?;
for update in updates {
cx.send_notification(acp::SessionNotification::new(
session.to_string(),
update,
))?;
}
}
Ok::<_, SdkError>(loaded)
}
.await;
drop(server);
let _ = responder.respond_with_result(result);
}
async fn start_prompt(self: &Arc<Self>, params: Value, responder: Responder<Value>, cx: Cx) {
if self.prompt_active() {
let _ = responder.respond_with_error(SdkError::new(-32603, PROMPT_BUSY));
return;
}
let admitted = {
let server = self.server.lock().await;
server.validate_prompt(&params).map(|(session, prompt)| {
let thread = server.sessions[&session].thread_id.clone();
(session, thread, prompt, server.prompt_run())
})
};
let (session, thread, prompt, run) = match admitted {
Ok(admitted) => admitted,
Err(error) => {
let _ = responder.respond_with_error(error.into());
return;
}
};
let (answers, mut inbox) = mpsc::unbounded_channel();
*self.slot() = Some(answers.clone());
let guard = PromptGuard(Arc::clone(self));
let task_cx = cx.clone();
let _ = cx.spawn(async move {
let result = {
let mut emit = |outgoing: Outgoing| -> Result<()> {
match outgoing {
Outgoing::Update(notification) => task_cx
.send_notification(notification)
.map_err(|error| anyhow!("ACP update was not sent: {}", error.message)),
Outgoing::Permission { key, request } => {
let answers = answers.clone();
task_cx
.send_request(request)
.on_receiving_result(async move |reply| {
let gone = matches!(
&reply,
Err(error)
if agent_client_protocol::is_incoming_transport_closed(error)
|| agent_client_protocol::is_connection_terminated(error)
);
if !gone {
let _ = answers.send(Inbound::Permission {
key,
allow: permission_allowed(&reply),
});
}
Ok(())
})
.map_err(|error| {
anyhow!("ACP permission request was not sent: {}", error.message)
})
}
}
};
run.drive_prompt(&session, &thread, prompt, &mut emit, &mut inbox)
.await
};
let result = match result {
Ok(stop) => to_wire(&acp::PromptResponse::new(stop)).map_err(SdkError::from),
Err(error) => Err(SdkError::new(
-32603,
codewhale_engine::client::redact_model_bound_text(&error.to_string(), &[]),
)),
};
guard.finish(responder, result);
Ok(())
});
}
}
fn permission_allowed(
reply: &std::result::Result<acp::RequestPermissionResponse, SdkError>,
) -> bool {
matches!(
reply,
Ok(response) if matches!(
&response.outcome,
acp::RequestPermissionOutcome::Selected(selected)
if &*selected.option_id.0 == "allow-once"
)
)
}
fn closes_input(line: &str) -> bool {
line.len() <= 256
&& line.contains("daemon/input_closed")
&& serde_json::from_str::<Value>(line)
.is_ok_and(|message| crate::is_control_input_closed(&message))
}
fn incoming_lines(
input: Box<dyn AsyncBufRead + Send + Unpin>,
shared: Arc<Shared>,
) -> impl Stream<Item = io::Result<String>> + Send + 'static {
futures_util::stream::unfold(
(BoundedLines::new(input), shared),
|(mut lines, shared)| async move {
loop {
match lines.next_line().await {
Ok(Some(line)) if line.trim().is_empty() => continue,
Ok(Some(line)) if closes_input(&line) => return None,
Ok(Some(line)) => return Some((Ok(line), (lines, shared))),
Ok(None) => return None,
Err(error) if crate::stdio::is_frame_refusal(&error) => {
return Some((Ok(error.to_string()), (lines, shared)));
}
Err(error) => {
shared.fail(anyhow!("ACP input failed: {error}"));
return None;
}
}
}
},
)
}
pub(super) fn bound_outgoing(line: String) -> io::Result<String> {
if line.len() <= MAX_FRAME_BYTES {
return Ok(line);
}
let refused = || io::Error::new(io::ErrorKind::InvalidData, FrameRefusal::TooLarge);
let Ok(Value::Object(frame)) = serde_json::from_str::<Value>(&line) else {
return Err(refused());
};
if frame.contains_key("method") || !frame.contains_key("id") {
return Err(refused());
}
let failure = |id: Value| {
json!({"jsonrpc":"2.0","id":id,"error":{"code":-32603,"message":OVERSIZE_RESPONSE}})
.to_string()
};
let replaced = failure(frame["id"].clone());
Ok(if replaced.len() <= MAX_FRAME_BYTES {
replaced
} else {
failure(Value::Null)
})
}
pub(super) fn outgoing_lines(
frames: mpsc::Sender<String>,
shared: Arc<Shared>,
) -> impl Sink<String, Error = io::Error> + Send + 'static {
futures_util::sink::unfold(
(frames, shared),
|(frames, shared), line: String| async move {
let line = match bound_outgoing(line) {
Ok(line) => line,
Err(error) => {
shared.fail(anyhow!("ACP output refused: {error}"));
return Ok((frames, shared));
}
};
frames
.send(line)
.await
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "ACP output closed"))?;
Ok::<_, io::Error>((frames, shared))
},
)
}
pub(super) async fn serve_connection<'a>(
server: AcpServer,
input: Box<dyn AsyncBufRead + Send + Unpin>,
mut output: Box<dyn AsyncWrite + Send + Unpin + 'a>,
) -> Result<Arc<Shared>> {
let shared = Arc::new(Shared::new(server));
let (frames, mut queued) = mpsc::channel::<String>(OUTGOING_QUEUE_FRAMES);
let transport = Lines::new(
outgoing_lines(frames, Arc::clone(&shared)),
incoming_lines(input, Arc::clone(&shared)),
);
let requests = Arc::clone(&shared);
let notifications = Arc::clone(&shared);
let finishing = Arc::clone(&shared);
let connection = Agent
.v1()
.on_receive_request(
async move |request: UntypedMessage, responder, cx| {
let (method, params) = request.into_parts();
requests.request(method, params, responder, cx).await;
Ok(())
},
agent_client_protocol::on_receive_request!(),
)
.on_receive_notification(
async move |notification: UntypedMessage, _cx| {
let (method, params) = notification.into_parts();
if method == "session/cancel" {
notifications.cancel(&params);
}
Ok(())
},
agent_client_protocol::on_receive_notification!(),
)
.connect_with(transport, async move |cx| {
tokio::select! {
() = cx.incoming_closed() => {}
() = finishing.ending.notified() => {}
}
finishing.tell_prompt(Inbound::Eof);
finishing.wait_idle().await;
Ok(())
});
let connection = async {
match connection.await {
Err(error) if agent_client_protocol::is_incoming_transport_closed(&error) => Ok(()),
other => other.map_err(|error| anyhow!("ACP connection failed: {}", error.message)),
}
};
let failing = Arc::clone(&shared);
let pump = async {
let mut broken = false;
while let Some(frame) = queued.recv().await {
if broken {
continue;
}
if let Err(error) =
crate::stdio::write_control_line(&mut output, frame.as_bytes()).await
{
broken = true;
failing.fail(error);
}
}
};
let (ended, ()) = tokio::join!(connection, pump);
drop(failing);
if let Some(error) = shared.take_failure() {
return Err(error);
}
ended?;
Ok(shared)
}
+196 -184
View File
@@ -5,17 +5,12 @@ use codewhale_engine::runtime_threads::{
manager::ExternalApprovalDecision, manager::StartTurnRequest,
};
use std::time::Duration;
use tokio::sync::mpsc::UnboundedReceiver;
struct Permission {
thread_id: String,
turn_id: String,
approval_id: String,
execution_id: String,
}
pub(super) struct PromptRun {
pub(super) runtime: Arc<RuntimeThreadManager>,
pub(super) sessions_dir: PathBuf,
}
struct Projection<'a> {
session: &'a str,
thread: &'a str,
@@ -47,32 +42,21 @@ impl Drop for TurnClaim {
}
}
}
fn admitted_call<'a>(
calls: &'a HashMap<String, PendingToolCall>,
execution: &str,
) -> Option<(&'a str, &'a PendingToolCall)> {
let mut id = execution;
loop {
if let Some((key, call)) = calls.get_key_value(id) {
return Some((key.as_str(), call));
}
id = id.rsplit_once('.')?.0;
}
}
fn update(session: &str, update: acp::SessionUpdate) -> Outgoing {
Outgoing::Update(acp::SessionNotification::new(session.to_string(), update))
}
impl PromptRun {
pub(super) async fn drive_prompt(
&self,
session_id: &str,
thread_id: &str,
prompt: String,
emit: Emit<'_>,
inbox: &mut UnboundedReceiver<Inbound>,
) -> Result<acp::StopReason> {
let session_id = session_id.to_string();
let thread_id = thread_id.to_string();
impl AcpServer {
pub(super) async fn drive_prompt<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
&mut self,
params: Value,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
) -> Result<&'static str> {
let (session_id, prompt) = self
.validate_prompt(&params)
.map_err(|error| anyhow!(error.message))?;
let binding = self
.sessions
.get(&session_id)
.ok_or_else(|| anyhow!("unknown sessionId"))?;
let thread_id = binding.thread_id.clone();
// Subscribe before snapshot/admission; replay deduplicates by seq.
let mut receiver = self.runtime.subscribe_events();
let cursor = self.runtime.get_thread_detail(&thread_id).await?.latest_seq;
@@ -102,7 +86,7 @@ impl PromptRun {
permissions: HashMap::new(),
};
let terminal = match self
.project_turn(&mut projection, &mut receiver, inbox, emit)
.project_turn(&mut projection, &mut receiver, reader, writer)
.await
{
Ok(terminal) => terminal,
@@ -160,6 +144,9 @@ impl PromptRun {
}
};
claim.armed = false;
if let Some(binding) = self.sessions.get_mut(&session_id) {
binding.cursor = projection.cursor;
}
self.save_checkpoint(&thread_id, &session_id).await?;
if terminal
.model_request_diagnostics
@@ -167,13 +154,11 @@ impl PromptRun {
.and_then(|facts| facts.stop_reason)
== Some(RuntimeTurnStopReason::StepBudgetExhausted)
{
return Ok(acp::StopReason::MaxTurnRequests);
return Ok("max_turn_requests");
}
match terminal.status {
RuntimeTurnStatus::Completed => Ok(acp::StopReason::EndTurn),
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => {
Ok(acp::StopReason::Cancelled)
}
RuntimeTurnStatus::Completed => Ok("end_turn"),
RuntimeTurnStatus::Interrupted | RuntimeTurnStatus::Canceled => Ok("cancelled"),
_ => Err(anyhow!(
"{}",
terminal.error.unwrap_or_else(
@@ -224,12 +209,12 @@ impl PromptRun {
})?;
Ok(())
}
async fn project_turn(
async fn project_turn<R: AsyncBufRead + Unpin, W: AsyncWrite + Unpin>(
&self,
projection: &mut Projection<'_>,
receiver: &mut tokio::sync::broadcast::Receiver<RuntimeEventRecord>,
inbox: &mut UnboundedReceiver<Inbound>,
emit: Emit<'_>,
reader: &mut crate::stdio::BoundedLines<R>,
writer: &mut W,
) -> Result<TurnRecord> {
let session = projection.session;
let thread = projection.thread;
@@ -247,26 +232,45 @@ impl PromptRun {
}
tokio::select! {
() = async { match cancel_deadline { Some(deadline) => tokio::time::sleep_until(deadline).await, None => std::future::pending().await } } => return Err(anyhow!("Core cancellation remains nonterminal; ACP cannot report a fabricated cancelled receipt")),
message = inbox.recv(), if !eof => {
match message {
Some(Inbound::Cancel { session: cancelled_session }) => {
if cancelled_session == session {
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
}
line = reader.next_line(), if !eof => {
let line = match line {
Ok(line) => line,
Err(error) if crate::stdio::is_frame_refusal(&error) => {
write_jsonrpc_error(writer, None, -32700, error.to_string()).await?;
continue;
}
Some(Inbound::Permission { key, allow }) => {
if !cancelled && let Some(permission) = projection.permissions.remove(&key) { self.answer_permission(&permission, allow).await?; }
}
Some(Inbound::Eof) | None => {
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
Err(error) => return Err(error.into()),
};
let Some(line) = line else {
eof = true; cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline = Some(tokio::time::Instant::now() + Duration::from_secs(30)); continue;
};
if line.trim().is_empty() { continue; }
let message: Value = match serde_json::from_str(&line) {
Ok(message) => message,
Err(error) => { write_jsonrpc_error(writer, None, -32700, format!("invalid json: {error}")).await?; continue; }
};
if crate::is_control_input_closed(&message) {
eof=true; cancelled=true; projection.permissions.clear();self.interrupt_claimed_turn(thread,turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now()+Duration::from_secs(30));continue;
}
let response_id = message.get("id").cloned().map(|id| self.response_id_policy.response_id(id));
if message.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { write_jsonrpc_error(writer, response_id, -32600, "jsonrpc version must be 2.0").await?; continue; }
if is_jsonrpc_response(&message) {
if !cancelled && let Some(id) = message.get("id").and_then(Value::as_str) && let Some(permission) = projection.permissions.remove(id) { self.answer_permission(&permission, &message).await?; }
continue;
}
if message.get("method").and_then(Value::as_str) == Some("session/cancel") {
if message.pointer("/params/sessionId").and_then(Value::as_str) == Some(session) {
cancelled = true; projection.permissions.clear(); self.interrupt_claimed_turn(thread, turn).await?;
cancel_deadline.get_or_insert(tokio::time::Instant::now() + Duration::from_secs(30));
}
}
if let Some(id) = response_id { write_jsonrpc_result(writer, id, json!(null)).await?; }
} else if let Some(id) = response_id { write_jsonrpc_error(writer, Some(id), -32603, "a session/prompt turn is already in progress").await?; }
}
record = async { replay_batch.pop_front().expect("nonempty bounded replay batch") }, if !replay_batch.is_empty() => {
projection.emit = !cancelled && !eof;
if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); }
if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); }
}
batch = async { replay.as_mut().expect("active replay").batches.recv().await }, if replay.is_some() && replay_batch.is_empty() => {
match batch {
@@ -276,7 +280,7 @@ impl PromptRun {
}
event = receiver.recv(), if replay.is_none() && replay_batch.is_empty() => {
match event {
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, &mut *emit).await? { return Ok(done); } },
Ok(record) => { projection.emit = !cancelled && !eof; if let Some(done) = self.project_event(projection, &record, writer).await? { return Ok(done); } },
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
let captured = self.runtime.replay_events(thread, Some(projection.cursor), None).await?;
if captured.base_seq > projection.cursor { return Err(anyhow!("ACP replay no longer covers the cursor; projection is incomplete")); }
@@ -288,7 +292,7 @@ impl PromptRun {
}
}
}
async fn answer_permission(&self, permission: &Permission, allow: bool) -> Result<()> {
async fn answer_permission(&self, permission: &Permission, response: &Value) -> Result<()> {
let detail = self
.runtime
.get_thread_detail(&permission.thread_id)
@@ -300,6 +304,15 @@ impl PromptRun {
}) {
return Ok(());
}
let allow = response.get("error").is_none()
&& response
.pointer("/result/outcome/outcome")
.and_then(Value::as_str)
== Some("selected")
&& response
.pointer("/result/outcome/optionId")
.and_then(Value::as_str)
== Some("allow-once");
self.runtime
.deliver_approval_from(
&permission.approval_id,
@@ -314,11 +327,11 @@ impl PromptRun {
.await?;
Ok(())
}
async fn project_event(
async fn project_event<W: AsyncWrite + Unpin>(
&self,
projection: &mut Projection<'_>,
record: &RuntimeEventRecord,
out: Emit<'_>,
writer: &mut W,
) -> Result<Option<TurnRecord>> {
let session = projection.session;
let thread = projection.thread;
@@ -340,7 +353,7 @@ impl PromptRun {
if emit && p.get("kind").and_then(Value::as_str) == Some("agent_message") =>
{
if let Some(text) = p.get("delta").and_then(Value::as_str) {
out(update(session, agent_text(text.to_string())))?;
write_session_update(writer, session, text.to_string()).await?;
}
}
"item.started" => {
@@ -360,10 +373,7 @@ impl PromptRun {
input: tool.get("input").cloned().unwrap_or(Value::Null),
};
if emit {
out(update(
session,
acp::SessionUpdate::ToolCall(tool_call_start(&call)),
))?;
write_tool_call_start(writer, session, &call).await?;
}
calls.insert(id, call);
}
@@ -376,15 +386,7 @@ impl PromptRun {
let call = calls
.get(id)
.ok_or_else(|| anyhow!("Dispatch projection lacks its admitted start"))?;
out(update(
session,
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
call,
acp::ToolCallStatus::InProgress,
None,
&[],
)),
))?;
write_tool_call_update(writer, session, call, "in_progress", None).await?;
}
"item.completed" | "item.failed" | "item.interrupted" if emit => {
if let Some(item) = p.get("item")
@@ -401,19 +403,19 @@ impl PromptRun {
.unwrap_or_default();
let status = if item.get("status").and_then(Value::as_str) == Some("completed")
{
acp::ToolCallStatus::Completed
"completed"
} else {
acp::ToolCallStatus::Failed
"failed"
};
out(update(
write_tool_call_update_with_blocks(
writer,
session,
acp::SessionUpdate::ToolCallUpdate(tool_call_update(
&call,
status,
item.get("detail").and_then(Value::as_str),
&blocks,
)),
))?;
&call,
status,
item.get("detail").and_then(Value::as_str),
&blocks,
)
.await?;
}
}
"approval.required" if emit => {
@@ -431,67 +433,14 @@ impl PromptRun {
&& pending.turn_id == turn
&& pending.tool_call_id.as_deref() == Some(execution)
}) {
let (shown, call) = admitted_call(calls, execution)
let call = calls
.get(execution)
.ok_or_else(|| anyhow!("Approval projection lacks admitted tool start"))?;
let id = uuid::Uuid::new_v4().to_string();
let nested = shown != execution;
let nested_call;
let shown_call = if nested {
nested_call = PendingToolCall {
execution_id: execution.to_string(),
name: p
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or(&call.name)
.to_string(),
input: p.get("input").cloned().unwrap_or(Value::Null),
};
&nested_call
} else {
call
};
let title = tool_call_title(shown_call);
let description = p
.get("description")
.and_then(Value::as_str)
.filter(|text| !text.trim().is_empty());
let summary = p
.get("summary")
.and_then(Value::as_str)
.filter(|text| nested && !text.trim().is_empty());
let text = match (summary, description) {
(Some(summary), Some(description)) => format!("{summary}\n{description}"),
(Some(text), None) | (None, Some(text)) => text.to_string(),
(None, None) => title.clone(),
};
let mut fields = acp::ToolCallUpdateFields::new()
.title(title)
.kind(tool_call_kind(shown_call))
.raw_input((!shown_call.input.is_null()).then(|| shown_call.input.clone()))
.content(vec![acp::ToolCallContent::from(text)]);
if !nested {
fields = fields.status(acp::ToolCallStatus::Pending);
}
let request = acp::RequestPermissionRequest::new(
session.to_string(),
acp::ToolCallUpdate::new(shown.to_string(), fields),
vec![
acp::PermissionOption::new(
"allow-once",
"Allow once",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
"reject-once",
"Reject",
acp::PermissionOptionKind::RejectOnce,
),
],
let id = format!(
"codewhale-permission-{}",
NEXT_ACP_PERMISSION_REQUEST_ID.fetch_add(1, Ordering::Relaxed)
);
out(Outgoing::Permission {
key: id.clone(),
request,
})?;
write_json_line(writer, json!({"jsonrpc":"2.0","id":id,"method":"session/request_permission","params":{"sessionId":session,"toolCall":{"toolCallId":execution,"title":tool_call_title(call),"kind":tool_call_kind(call),"status":"pending","rawInput":call.input,"content":[{"type":"content","content":{"type":"text","text":p.get("description")}}]},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}})).await?;
permissions.insert(
id,
Permission {
@@ -540,22 +489,6 @@ impl PromptRun {
mod tests {
use super::super::tests::{Rig, fixture_config};
use super::*;
fn frame(outgoing: Outgoing) -> Value {
match outgoing {
Outgoing::Update(notification) => {
json!({"jsonrpc":"2.0","method":"session/update","params":serde_json::to_value(notification).unwrap()})
}
Outgoing::Permission { key, request } => {
json!({"jsonrpc":"2.0","id":key,"method":"session/request_permission","params":serde_json::to_value(request).unwrap()})
}
}
}
fn recording(out: &mut Vec<Value>) -> impl FnMut(Outgoing) -> Result<()> + Send + '_ {
move |outgoing| {
out.push(frame(outgoing));
Ok(())
}
}
fn event(seq: u64, thread: &str, turn: &str, name: &str, payload: Value) -> RuntimeEventRecord {
serde_json::from_value(json!({"seq":seq,"timestamp":chrono::Utc::now(),"thread_id":thread,"turn_id":turn,"event":name,"payload":payload})).unwrap()
}
@@ -673,15 +606,14 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output: Vec<Value> = Vec::new();
let mut output = Vec::new();
// Project the actual Runtime records, not synthetic ACP frames.
for record in rig.server.runtime.events_since(&thread, None)? {
rig.server
.prompt_run()
.project_event(&mut projection, &record, &mut recording(&mut output))
.project_event(&mut projection, &record, &mut output)
.await?;
}
let wire = output;
let wire = super::super::tests::parse_lines(output);
let requests: Vec<_> = wire
.iter()
.filter(|frame| frame["method"] == "session/request_permission")
@@ -703,8 +635,12 @@ mod tests {
)
.ok_or_else(|| anyhow!("ACP permission was not retained"))?;
rig.server
.prompt_run()
.answer_permission(&permission, true)
.answer_permission(
&permission,
&json!({
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}
}),
)
.await?;
assert_eq!(
tokio::time::timeout(Duration::from_secs(10), engine.recv_approval_event())
@@ -822,7 +758,7 @@ mod tests {
})
.await??;
let mut terminal = None;
let mut settled_output: Vec<Value> = Vec::new();
let mut settled_output = Vec::new();
for record in rig
.server
.runtime
@@ -830,12 +766,7 @@ mod tests {
{
if let Some(completed) = rig
.server
.prompt_run()
.project_event(
&mut projection,
&record,
&mut recording(&mut settled_output),
)
.project_event(&mut projection, &record, &mut settled_output)
.await?
{
terminal = Some(completed);
@@ -850,7 +781,7 @@ mod tests {
assert!(projection.permissions.is_empty());
assert!(projection.calls.is_empty());
assert!(
!settled_output
!super::super::tests::parse_lines(settled_output)
.iter()
.any(|frame| frame["method"] == "session/request_permission")
);
@@ -873,7 +804,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output: Vec<Value> = Vec::new();
let mut output = Vec::new();
let start = event(
1,
&thread,
@@ -882,13 +813,11 @@ mod tests {
json!({"tool":{"id":"core-call","name":"read","input":{"path":"x"}}}),
);
rig.server
.prompt_run()
.project_event(&mut p, &start, &mut recording(&mut output))
.project_event(&mut p, &start, &mut output)
.await?;
let before = output.len();
rig.server
.prompt_run()
.project_event(&mut p, &start, &mut recording(&mut output))
.project_event(&mut p, &start, &mut output)
.await?;
assert_eq!(output.len(), before);
let dispatch = event(
@@ -900,8 +829,7 @@ mod tests {
);
assert!(
rig.server
.prompt_run()
.project_event(&mut p, &dispatch, &mut recording(&mut output))
.project_event(&mut p, &dispatch, &mut output)
.await
.is_err()
);
@@ -922,7 +850,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output: Vec<Value> = Vec::new();
let mut output = Vec::new();
for record in [
event(
1,
@@ -954,8 +882,7 @@ mod tests {
),
] {
rig.server
.prompt_run()
.project_event(&mut p, &record, &mut recording(&mut output))
.project_event(&mut p, &record, &mut output)
.await?;
}
assert!(output.is_empty());
@@ -977,7 +904,7 @@ mod tests {
calls: HashMap::new(),
permissions: HashMap::new(),
};
let mut output: Vec<Value> = Vec::new();
let mut output = Vec::new();
let failure = event(
1,
&thread,
@@ -987,8 +914,7 @@ mod tests {
);
let error = rig
.server
.prompt_run()
.project_event(&mut p, &failure, &mut recording(&mut output))
.project_event(&mut p, &failure, &mut output)
.await
.unwrap_err();
assert!(error.to_string().contains("no terminal receipt"));
@@ -996,4 +922,90 @@ mod tests {
rig.close().await;
Ok(())
}
struct FailedAnswerWriter(Vec<u8>);
impl AsyncWrite for FailedAnswerWriter {
fn poll_write(
mut self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
bytes: &[u8],
) -> std::task::Poll<std::io::Result<usize>> {
if String::from_utf8_lossy(bytes).contains("agent_message_chunk") {
return std::task::Poll::Ready(Err(std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"fixture client disappeared after tool effect",
)));
}
self.0.extend_from_slice(bytes);
std::task::Poll::Ready(Ok(bytes.len()))
}
fn poll_flush(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
fn poll_shutdown(
self: std::pin::Pin<&mut Self>,
_: &mut std::task::Context<'_>,
) -> std::task::Poll<std::io::Result<()>> {
std::task::Poll::Ready(Ok(()))
}
}
#[tokio::test(flavor = "current_thread")]
async fn failed_transport_after_completed_write_retains_actual_core_terminal_and_checkpoint()
-> Result<()> {
use codewhale_engine::llm_client::mock::canned;
let mut config = fixture_config();
config.yolo = Some(true);
let mut rig = Rig::new(
config,
vec![
canned::tool_call_turn(
"write",
"write",
r#"{"path":"writer-failure.txt","content":"completed"}"#,
),
canned::simple_text_turn("final answer"),
],
)?;
let id = rig.new_session().await;
let (client, input) = tokio::io::duplex(1024);
let mut reader = crate::stdio::BoundedLines::new(BufReader::new(input));
let mut writer = FailedAnswerWriter(Vec::new());
let error = tokio::time::timeout(
Duration::from_secs(20),
rig.server.drive_prompt(
json!({"sessionId":id,"prompt":"write then answer"}),
&mut reader,
&mut writer,
),
)
.await?
.unwrap_err();
drop(client);
assert!(error.to_string().contains("partial receipts retained"));
assert_eq!(
std::fs::read_to_string(rig.workspace.join("writer-failure.txt"))?,
"completed"
);
let store = codewhale_engine::session_manager::SessionManager::new(
rig.server.sessions_dir.clone(),
)?;
let saved = store.load_session(&id)?;
assert!(
saved
.messages
.iter()
.flat_map(|m| &m.content)
.any(|b| matches!(b, codewhale_models::ContentBlock::ToolResult { .. }))
);
let thread = &rig.server.sessions[&id].thread_id;
let detail = rig.server.runtime.get_thread_detail(thread).await?;
assert!(!matches!(
detail.turns.last().unwrap().status,
RuntimeTurnStatus::Queued | RuntimeTurnStatus::InProgress
));
rig.close().await;
Ok(())
}
}
+2 -67
View File
@@ -41,34 +41,6 @@ mod platform {
tokio::io::WriteHalf<Connection>,
>;
fn resolved_store_form(path: PathBuf) -> Option<PathBuf> {
let checked = codewhale_engine::runtime_threads::checked_runtime_store_root(path).ok()?;
let mut tail = Vec::new();
let mut probe = checked.as_path();
loop {
match probe.canonicalize() {
Ok(real) => {
return Some(tail.iter().rev().fold(real, |acc, part| acc.join(part)));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
tail.push(probe.file_name()?.to_owned());
probe = probe.parent()?;
}
Err(_) => return None,
}
}
}
fn resolved_store_form_of(path: &std::path::Path) -> PathBuf {
resolved_store_form(path.to_path_buf()).unwrap_or_else(|| path.to_path_buf())
}
fn same_store_path(left: &std::path::Path, right: &std::path::Path) -> bool {
left == right
|| (cfg!(any(target_os = "macos", windows))
&& left.to_string_lossy().to_lowercase() == right.to_string_lossy().to_lowercase())
}
fn same_socket_selection(left: &std::path::Path, right: &std::path::Path) -> bool {
if left == right {
return true;
@@ -255,34 +227,6 @@ mod platform {
);
Ok(())
}
fn discard_if_store_gone_blocking(&self, selected_store: PathBuf) -> Result<bool> {
let gone = resolved_store_form(selected_store).is_some_and(|selected| {
same_store_path(&selected, &resolved_store_form_of(&self.receipt().data_dir))
&& matches!(
selected.symlink_metadata(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound
)
});
if !gone || !matches!(self.revalidate_receipt(), Ok(true)) {
return Ok(false);
}
anyhow::ensure!(
self.publication.parent.retire_private_receipt(
&self.publication.receipt_name,
&self.publication.file
)?,
PublicationChangedBeforeAttach
);
Ok(true)
}
async fn discard_if_store_gone(
self: &Arc<Self>,
selected_store: &std::path::Path,
) -> Result<bool> {
let held = self.clone();
let selected_store = selected_store.to_path_buf();
owner_work(move || held.discard_if_store_gone_blocking(selected_store)).await
}
/// Caller retains the real Runtime lease; this checks evidence only.
pub(crate) fn revalidate_receipt(&self) -> Result<bool> {
if !self.publication.receipt_is_current()? {
@@ -401,9 +345,8 @@ mod platform {
pub async fn probe_owner_for_host_startup(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
selected_store: &std::path::Path,
) -> Result<HostOwnerProbe> {
match probe_frontend(
probe_frontend(
config_path,
selected,
crate::daemon_socket::AttachFrontend::Control,
@@ -412,15 +355,7 @@ mod platform {
None,
None,
)
.await?
{
HostOwnerProbe::Unavailable(publication)
if publication.discard_if_store_gone(selected_store).await? =>
{
Ok(HostOwnerProbe::Absent)
}
probe => Ok(probe),
}
.await
}
pub async fn connect(
-4
View File
@@ -1349,7 +1349,6 @@ mod platform {
let config = root.join("config.toml");
std::fs::write(&config, "")?;
let parent = PrivateDirectory::admit(path.parent().unwrap())?;
std::fs::create_dir_all(root.join("runtime"))?;
let stale = std::os::unix::net::UnixListener::bind(&path)?;
let identity = parent.socket_identity("daemon.sock")?.unwrap();
parent.protect_socket("daemon.sock", identity)?;
@@ -1384,7 +1383,6 @@ mod platform {
crate::daemon_client::probe_owner_for_host_startup(
Some(config.clone()),
Some(path.clone()),
&owner.data_dir,
)
.await?
else {
@@ -1394,7 +1392,6 @@ mod platform {
let observed_hook = observed.clone();
let selected = path.clone();
let selected_config = config.clone();
let selected_store = owner.data_dir.clone();
let stale_capture = captured.clone();
BEFORE_OWNER_REBIND.with(|slot| {
*slot.borrow_mut() = Some(Box::new(move |endpoint| {
@@ -1415,7 +1412,6 @@ mod platform {
crate::daemon_client::probe_owner_for_host_startup(
Some(selected_config),
Some(endpoint),
&selected_store,
)
.await
.unwrap(),
-1
View File
@@ -668,7 +668,6 @@ pub async fn run_exec_agent(
execution_config.subagent_heartbeat_timeout_secs_for_provider(&effective_identity),
),
prefer_bwrap: execution_config.prefers_bwrap(),
tool_launcher: execution_config.tool_launcher.clone(),
bwrap_extensions: codewhale_sandbox::BwrapMountExtensions {
read_only_roots: execution_config.bwrap_ro_roots.clone(),
device_roots: execution_config.bwrap_dev_roots.clone(),
+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)),
+1 -37
View File
@@ -76,18 +76,6 @@ impl McpServerSettings {
}
}
fn refuse_shell_tools(&mut self) -> Vec<String> {
let (refused, kept): (Vec<String>, Vec<String>) = std::mem::take(&mut self.expose_tools)
.into_iter()
.partition(|name| {
build_exposed_tools(std::slice::from_ref(name))
.iter()
.any(|tool| tool.internal == "exec_shell")
});
self.expose_tools = kept;
refused
}
fn from_toml(contents: &str) -> Result<Self> {
let config: McpServerConfigFile = toml::from_str(contents)?;
Ok(Self {
@@ -110,21 +98,12 @@ pub async fn run_mcp_server(
workspace: PathBuf,
skills_dir: PathBuf,
skills_config: std::sync::Arc<codewhale_runtime::config::SkillsConfig>,
tool_launcher_configured: bool,
) -> Result<()> {
// Settings load is a synchronous config read; keep it off the async
// worker per the blocking-call convention.
let mut settings = tokio::task::spawn_blocking(McpServerSettings::load)
let settings = tokio::task::spawn_blocking(McpServerSettings::load)
.await
.context("MCP server settings task failed")??;
if tool_launcher_configured {
for name in settings.refuse_shell_tools() {
eprintln!(
"codewhale mcp server: not exposing '{name}': the managed configuration sets \
tool_launcher, and this server does not run commands through it."
);
}
}
let server = McpServer::new(workspace, settings, skills_dir, skills_config)?;
// stdout carries the protocol; the notice goes to stderr so an operator
// whose config lists write tools sees why they are missing.
@@ -927,21 +906,6 @@ mod tests {
);
}
#[test]
fn a_tool_launcher_refuses_shell_tools_even_when_the_operator_allows_them() {
let mut settings = McpServerSettings::from_toml(
"[server]\nexpose_tools = [\"file_read\", \"shell\", \"exec_shell\", \"Bash\"]\nrequire_approval = false\n",
)
.expect("parse config");
assert_eq!(settings.refuse_shell_tools(), vec!["shell", "exec_shell"]);
let server = test_server(PathBuf::from("."), settings);
let names: Vec<&str> = server.tools.iter().map(|tool| tool.name.as_ref()).collect();
assert_eq!(names, vec!["file_read"]);
for internal in ["exec_shell", "Bash", "bash"] {
assert!(server.registry.get(internal).is_none(), "{internal}");
}
}
#[test]
fn existing_config_without_require_approval_withholds_and_names_write_tools() {
let settings = McpServerSettings::from_toml(
+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") {
+30 -115
View File
@@ -114,11 +114,9 @@ mod account;
mod auth;
mod automations;
mod avatars;
mod builtin_commands;
mod computer_display;
mod context;
mod diagnostics;
mod external_agent_options;
mod external_agents;
mod git;
mod jobs;
@@ -129,7 +127,6 @@ mod members;
mod memory_lens;
mod mobile;
mod plans;
mod plugin_index;
mod plugins;
pub(crate) mod review;
mod secrets;
@@ -387,7 +384,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) {
@@ -1324,7 +1321,6 @@ struct ThreadSummary {
preview: String,
model: String,
mode: String,
agent_preset: String,
workspace: PathBuf,
branch: Option<String>,
head: Option<String>,
@@ -1580,41 +1576,10 @@ struct RuntimeInfoResponse {
job_operation_store_id: Option<String>,
account: RuntimeAccountInfo,
experimental: RuntimeExperimentalCapabilities,
sandbox: RuntimeSandboxInfo,
// Backward-compatible alias kept for existing clients.
version: &'static str,
}
#[derive(Debug, Serialize)]
struct RuntimeSandboxInfo {
kind: String,
tool_launcher: bool,
}
async fn runtime_sandbox_info(state: &RuntimeApiState) -> RuntimeSandboxInfo {
let (prefer_bwrap, tool_launcher, denied_read_paths) = {
let cfg = state.config.read();
(
cfg.prefers_bwrap(),
cfg.tool_launcher.clone(),
codewhale_sandbox::read_guard::ReadDenylist::from_config(&cfg).subtree_paths(),
)
};
let launcher_configured = tool_launcher.is_some();
let kind = tokio::task::spawn_blocking(move || {
let mut manager = codewhale_sandbox::SandboxManager::with_bwrap_preference(prefer_bwrap);
manager.set_tool_launcher(tool_launcher);
manager.set_denied_read_subpaths(denied_read_paths);
manager.configured_sandbox().unwrap_or_default().to_string()
})
.await
.unwrap_or_else(|_| codewhale_sandbox::SandboxType::None.to_string());
RuntimeSandboxInfo {
kind,
tool_launcher: launcher_configured,
}
}
fn default_runtime_capabilities() -> RuntimeCapabilities {
RuntimeCapabilities {
client_token_intents: true,
@@ -1654,7 +1619,6 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
worker_runtime: true,
fleet_run_create: true,
thread_fleet_members: true,
thread_fleet_member_durable_operations_v1: true,
fleet_run_start: true,
fleet_event_replay: true,
fleet_event_stream: true,
@@ -2068,7 +2032,7 @@ async fn admit_runtime_host(
tokio::time::timeout(Duration::from_secs(5), async {
loop {
let probe = match crate::daemon_client::probe_owner_for_host_startup(
config_path.clone(), selected_socket.clone(), &selected_store,
config_path.clone(), selected_socket.clone(),
).await {
Err(error) if error.is::<PublicationChangedBeforeAttach>() => {
tokio::time::sleep(Duration::from_millis(25)).await;
@@ -3164,12 +3128,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/threads/{id}/context", get(context::get_thread_context))
.route(
"/v1/threads/{id}/members",
post(members::start_thread_member)
.layer(DefaultBodyLimit::max(members::MAX_MEMBER_REQUEST_BYTES)),
)
.route(
"/v1/threads/{id}/members/operations/{operation_key}",
get(members::get_member_operation),
post(members::start_thread_member),
)
.route("/v1/threads/{id}/plan", get(plans::get_thread_plan))
.route("/v1/threads/{id}/todo", get(plans::get_thread_todo))
@@ -3316,13 +3275,8 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/tasks/{id}", get(get_task))
.route("/v1/tasks/{id}/cancel", post(cancel_task))
.route("/v1/skills", get(list_skills))
.route("/v1/modes", get(list_modes))
.route("/v1/commands", get(list_commands))
.route("/v1/threads/{id}/commands", post(run_thread_command))
.route(
"/v1/threads/{id}/commands/builtin",
post(builtin_commands::run_builtin_command),
)
.route(
"/v1/threads/{id}/commands/{operation_key}",
get(get_thread_command).delete(cancel_thread_command),
@@ -3373,7 +3327,6 @@ pub fn build_router(state: RuntimeApiState) -> Router {
.route("/v1/skills/{name}/audit", get(audit_skill_api))
.route("/v1/apps/mcp/tools", get(list_mcp_tools))
.route("/v1/apps/plugins", get(plugins::list_plugins))
.route("/v1/apps/plugin-index", get(plugin_index::get_plugin_index))
.route("/v1/apps/avatars", get(avatars::list_avatars))
.route(
"/v1/apps/avatars/{handle}/atlases/{page}",
@@ -3415,10 +3368,6 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/apps/plugins/{selector}/ui/{*path}",
get(plugins::get_plugin_ui_asset),
)
.route(
"/v1/apps/plugins/{selector}/routes/{*path}",
any(plugins::plugin_route_bridge),
)
.route(
"/v1/apps/plugins/{selector}/update",
post(plugins::update_plugin_api),
@@ -3513,14 +3462,6 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/agents/external",
get(external_agents::list_external_agents),
)
.route(
"/v1/agents/external/quota",
get(external_agents::get_external_agent_quota),
)
.route(
"/v1/agents/external/{id}/options",
get(external_agent_options::get_external_agent_options),
)
.route("/v1/providers", get(list_providers))
.route("/v1/providers/{id}/models", get(list_provider_models))
.route(
@@ -3939,7 +3880,7 @@ async fn create_thread(
)
.await
.map_err(|e| {
if let Some(refusal) = external_agent_refusal(&e).or_else(|| mode_refusal(&e)) {
if let Some(refusal) = external_agent_refusal(&e) {
refusal
} else if e
.is::<codewhale_engine::runtime_threads::manager::RuntimeThreadCreateConflict>()
@@ -4188,10 +4129,6 @@ async fn list_threads_summary(
preview,
model: thread.model,
mode: thread.mode,
agent_preset: codewhale_engine::conversation_modes::effective_id(
thread.agent_preset.as_deref(),
)
.to_string(),
branch: workspace_git.and_then(|git| git.branch.clone()),
head: workspace_git.and_then(|git| git.head.clone()),
dirty: workspace_git.is_some_and(|git| git.dirty),
@@ -5877,10 +5814,6 @@ struct CommandCatalogEntry {
#[serde(skip_serializing_if = "Option::is_none")]
origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin_id: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
workspace_sourced: bool,
#[serde(skip_serializing_if = "Option::is_none")]
registration_id: Option<String>,
}
@@ -5939,8 +5872,6 @@ fn command_catalog(
shadowed_by,
shadowed_aliases,
origin: None,
plugin_id: None,
workspace_sourced: false,
registration_id: None,
});
}
@@ -5964,8 +5895,6 @@ fn command_catalog(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: None,
plugin_id: command.plugin_id.clone(),
workspace_sourced: command.workspace_sourced,
registration_id: None,
});
}
@@ -6048,8 +5977,6 @@ async fn list_commands(
shadowed_by: None,
shadowed_aliases: Vec::new(),
origin: Some(reference.origin.clone()),
plugin_id: Some(reference.plugin_id.clone()),
workspace_sourced: false,
registration_id: query
.thread_id
.as_deref()
@@ -6266,17 +6193,6 @@ async fn list_hooks(
}))
}
async fn list_modes(
State(state): State<RuntimeApiState>,
) -> Json<codewhale_engine::conversation_modes::ConversationModeTable> {
Json(
codewhale_engine::conversation_modes::current_table(
state.runtime_threads.extension_host_options(),
)
.await,
)
}
async fn list_skills(
State(state): State<RuntimeApiState>,
) -> Result<Json<SkillsResponse>, ApiError> {
@@ -7143,7 +7059,6 @@ async fn runtime_info(
capabilities.fleet_run_create = !session_only;
capabilities.fleet_run_start = !session_only;
capabilities.fleet_local_target = !session_only;
let sandbox = runtime_sandbox_info(&state).await;
Json(RuntimeInfoResponse {
service: "codewhale-runtime-api",
runtime_api_version: RUNTIME_API_VERSION,
@@ -7158,7 +7073,6 @@ async fn runtime_info(
.then(|| state.runtime_threads.job_operation_store_id().to_string()),
account,
experimental: RuntimeExperimentalCapabilities::default(),
sandbox,
version,
})
}
@@ -9297,13 +9211,14 @@ async fn preview_patch_undo_thread_turn(
.iter()
.map(|turn| turn.turn_id.clone())
.collect();
let trusted = thread.trust_mode || thread.auto_approve;
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
let (result, paths) = tokio::task::spawn_blocking(move || {
let _reservation = _reservation;
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
patch_undo_workspace_files_impl(&thread.workspace, &dropped_turns, true, None)
patch_undo_workspace_files_impl(&thread.workspace, &dropped_turns, trusted, true, None)
})
.await
.map_err(|error| ApiError::internal(format!("Patch undo preview failed: {error}")))??;
@@ -9340,6 +9255,10 @@ async fn patch_undo_thread_turn(
// Validate the selected turn/history before touching any file, so an invalid
// undo request cannot leave a half-applied workspace.
let prepared = prepare_patch_undo(&state, &id, &req).await?;
// File rollback is a workspace mutation, so it needs the trust the
// TUI's `/undo` requires. Read from the thread's own record: the
// client does not get to assert it.
let trusted = thread.trust_mode || thread.auto_approve;
let workspace = thread.workspace.clone();
// The restore points come from the dropped turns' own records, not
// from the thread's saved-session binding or a scan of the shared
@@ -9351,7 +9270,7 @@ async fn patch_undo_thread_turn(
let patch_result = tokio::task::spawn_blocking(move || {
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
patch_undo_workspace_files_impl(&workspace, &dropped_turns, false, req.expected_paths.as_deref()).map(|(result, _)| result)
patch_undo_workspace_files_impl(&workspace, &dropped_turns, trusted, false, req.expected_paths.as_deref()).map(|(result, _)| result)
})
.await
.map_err(|e| ApiError::internal(format!("Patch undo task failed: {e}")))??;
@@ -9394,6 +9313,7 @@ const PATCH_UNDO_NO_RESTORE_POINT: &str = "restore_point_unavailable";
const PATCH_UNDO_RESTORE_POINT_PRUNED: &str = "restore_point_pruned";
const PATCH_UNDO_PATH_NOT_SNAPSHOTTED: &str = "path_not_snapshotted";
const PATCH_UNDO_WORKSPACE_CHANGED: &str = "workspace_changed_since_turn";
const PATCH_UNDO_UNTRUSTED: &str = "restore_requires_trust";
const PATCH_UNDO_WORKSPACE_UNAVAILABLE: &str = "workspace_unavailable";
/// One pre-turn → post-turn window of a dropped turn, resolved to the trees
@@ -9625,23 +9545,21 @@ fn no_restore_point(turn_id: &str, why: &str) -> ApiError {
/// point, or a path changed since the turn is a `409` with a stable
/// `error.code`, never a `201` that forks while the files stay changed.
///
/// # Posture
///
/// No trust mode or Full Access is required. Undo reverses only what the
/// dropped turns' own tool calls wrote, and only while every such path still
/// holds exactly the bytes the turn left; the same ownership and later-edit
/// proofs refuse anything else, so the default posture cannot lose work the
/// turn did not make.
/// `trusted` mirrors the gate the TUI's `patch_undo()` applies
/// (`yolo || trust_mode`), evaluated once a real change is known.
pub fn patch_undo_workspace_files(
workspace: &FsPath,
dropped_turns: &[codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots],
trusted: bool,
) -> Result<PatchUndoResult, ApiError> {
patch_undo_workspace_files_impl(workspace, dropped_turns, false, None).map(|(result, _)| result)
patch_undo_workspace_files_impl(workspace, dropped_turns, trusted, false, None)
.map(|(result, _)| result)
}
fn patch_undo_workspace_files_impl(
workspace: &FsPath,
dropped_turns: &[codewhale_engine::runtime_threads::manager::DroppedTurnSnapshots],
trusted: bool,
preview: bool,
expected_paths: Option<&[String]>,
) -> Result<(PatchUndoResult, Vec<String>), ApiError> {
@@ -9898,6 +9816,17 @@ fn patch_undo_workspace_files_impl(
));
}
// Restoring is a workspace mutation. Gate it exactly where the TUI gates
// it — after a real, owned change is known — so the two surfaces cannot
// drift into "one refuses, the other half-undoes".
if !trusted {
return Err(ApiError::conflict(
"Refusing to undo workspace files outside trusted mode. \
Turn on /trust or switch this thread to Full Access, then undo again.",
)
.with_code(PATCH_UNDO_UNTRUSTED));
}
let paths: Vec<String> = to_restore
.iter()
.map(|(path, _, _)| path.display().to_string())
@@ -14858,20 +14787,6 @@ fn map_automation_err(err: anyhow::Error) -> ApiError {
}
}
fn mode_refusal(err: &anyhow::Error) -> Option<ApiError> {
use codewhale_engine::conversation_modes::ModeRefusal;
let error = err.downcast_ref::<ModeRefusal>()?;
Some(ApiError {
status: match error.code {
ModeRefusal::MODE_UNAVAILABLE => StatusCode::FAILED_DEPENDENCY,
ModeRefusal::MODE_LOCKED => StatusCode::CONFLICT,
_ => StatusCode::BAD_REQUEST,
},
message: error.message.clone(),
code: Some(error.code),
})
}
fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
use codewhale_engine::fleet::agents::ExecutorUnavailable;
let error = err.downcast_ref::<ExecutorUnavailable>()?;
@@ -14887,7 +14802,7 @@ fn external_agent_refusal(err: &anyhow::Error) -> Option<ApiError> {
}
fn map_thread_err(err: anyhow::Error) -> ApiError {
if let Some(refusal) = external_agent_refusal(&err).or_else(|| mode_refusal(&err)) {
if let Some(refusal) = external_agent_refusal(&err) {
return refusal;
}
if let Some(error) = err.downcast_ref::<codewhale_engine::runtime_threads::manager::RuntimeTurnOperationLookupError>() {
@@ -1,255 +0,0 @@
//! `POST /v1/threads/{id}/commands/builtin` — execute a terminal built-in
//! command against a thread, remotely.
//!
//! The thread's state is assembled into the engine's
//! [`BuiltinCommandHostSnapshot`] (contract projections included) and handed
//! to the installed command catalog, whose terminal-host implementation maps
//! it into the facet envelope and runs the portable handlers. Which commands
//! this serves is the host's decision (`builtin_execution::SERVED`); a name
//! outside that set answers 404 the same way an unknown name does, so the
//! remote surface can never widen without the host widening it first.
//!
//! Phase 1 notes: executions are not receipt-tracked (the phase-1 set is
//! read-only — every served handler is pure over the snapshot), and the
//! `text_only` flag reports commands that also asked the terminal host for an
//! action, which a remote client cannot perform.
use axum::Json;
use axum::extract::{Path, State};
use codewhale_command_contract::facets::{
DebugCostProjection, DebugRouteCost, DebugSystemPrompt, DebugTokenProjection,
};
use codewhale_command_contract::types::{CommandCurrency, CommandMode};
use codewhale_engine::command_catalog::{self, BuiltinCommandHostSnapshot};
use codewhale_models::SystemPrompt;
use serde::Deserialize;
use super::{ApiError, RuntimeApiState};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct BuiltinCommandApiRequest {
/// The command name (canonical or alias), with or without the slash.
pub name: String,
/// The argument tail after the command word, if any.
#[serde(default)]
pub args: Option<String>,
}
#[derive(serde::Serialize)]
pub(super) struct BuiltinCommandApiResponse {
pub name: String,
pub text: String,
pub is_error: bool,
/// `false` when the command also asked the terminal host for an action
/// the remote client cannot perform.
pub text_only: bool,
}
pub(super) async fn run_builtin_command(
State(state): State<RuntimeApiState>,
Path(thread_id): Path<String>,
Json(request): Json<BuiltinCommandApiRequest>,
) -> Result<Json<BuiltinCommandApiResponse>, ApiError> {
let catalog = command_catalog::installed()
.ok_or_else(|| ApiError::internal("command catalog is not installed"))?;
// Refuse a name outside the host's remote surface before reading any
// thread state: an unknown command and a known-but-unserved one get the
// same answer, and neither can be distinguished by what the read says
// about the daemon's idle threads.
if !catalog.serves_builtin(&request.name) {
return Err(not_served(&request.name));
}
let thread = state
.runtime_threads
.get_thread(&thread_id)
.await
.map_err(super::map_thread_err)?;
let snapshot = assemble_snapshot(&state, &thread_id, &thread).await?;
match catalog.execute_builtin(&request.name, request.args.as_deref(), &snapshot) {
Some(execution) => Ok(Json(BuiltinCommandApiResponse {
name: execution.name,
text: execution.text,
is_error: execution.is_error,
text_only: execution.text_only,
})),
// The host refused the name: the same answer an unknown command gets.
None => Err(not_served(&request.name)),
}
}
fn not_served(name: &str) -> ApiError {
const MAX_ECHOED_NAME_CHARS: usize = 64;
let shown: String = name.chars().take(MAX_ECHOED_NAME_CHARS).collect();
let ellipsis = if shown.len() < name.len() { "..." } else { "" };
ApiError::not_found(format!(
"no remotely executable builtin command '{shown}{ellipsis}'"
))
}
/// Assemble the host snapshot for one thread from the record, the live
/// engine's context budget, the usage aggregate, and the canonical session
/// document. Every projection field is filled from measured data or left at
/// its honest absence encoding; nothing is estimated into place.
async fn assemble_snapshot(
state: &RuntimeApiState,
thread_id: &str,
thread: &codewhale_engine::runtime_threads::ThreadRecord,
) -> Result<BuiltinCommandHostSnapshot, ApiError> {
// The live engine carries the context budget; an idle daemon thread has
// no honest context estimate, so the served diagnostics refuse rather
// than print zeros.
let engine = state
.runtime_threads
.loaded_engine(thread_id)
.await
.ok_or_else(|| {
ApiError::conflict("this thread's engine is not loaded; send a message first")
})?;
let budget = engine
.get_context_budget()
.await
.map_err(super::map_thread_err)?
.ok_or_else(|| {
ApiError::conflict("this thread has no live context estimate yet; send a message first")
})?;
let usage = state
.runtime_threads
.aggregate_usage_for_thread(thread_id)
.await
.map_err(super::map_thread_err)?;
let parent = &usage.parent;
let children = &usage.routed_children;
let combined = usage.combined();
// The daemon tracks no UI chat history (that is the client's layer), so
// the chat count is a true zero; the model transcript and the effective
// system prompt come from the live engine session.
let session = engine
.get_session_snapshot()
.await
.map_err(|err| ApiError::internal(format!("engine session snapshot unavailable: {err}")))?;
let api_message_count = session.messages.len();
let chat_message_count = 0;
// Currency follows the same rule the TUI's cost display applies, so the
// same config answers the same way on both surfaces: a Simplified-Chinese
// UI reading the default "usd" shows CNY, and every other spelling goes
// through the canonical parser (which accepts rmb and yuan) rather than a
// bare string compare. The CNY column is the provider-published subtotal
// only; a USD-picked currency keeps the authoritative USD numbers.
#[cfg(test)]
let env_ticket = codewhale_runtime::test_support::env_scope_ticket();
let settings = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = codewhale_runtime::test_support::join_env_scope(env_ticket);
codewhale_runtime::settings::Settings::load_read_only()
})
.await
.map_err(|err| ApiError::internal(format!("settings task failed: {err}")))?
.map_err(|err| ApiError::internal(format!("settings unavailable: {err}")))?;
let locale = codewhale_localization::resolve_locale(&settings.locale);
let currency = match (settings.cost_currency.as_str(), locale.tag()) {
("usd", "zh-Hans") => CommandCurrency::Cny,
_ => match codewhale_config::pricing::CostCurrency::from_setting(&settings.cost_currency) {
Some(codewhale_config::pricing::CostCurrency::Cny) => CommandCurrency::Cny,
_ => CommandCurrency::Usd,
},
};
let currency_amount =
|totals: &codewhale_engine::runtime_threads::manager::UsageTotals| match currency {
CommandCurrency::Cny => totals.cost_cny,
CommandCurrency::Usd => totals.cost_usd,
};
let parent_amount = currency_amount(parent);
let children_amount = currency_amount(children);
let total_amount = currency_amount(&combined);
let (priced_turns, unpriced_turns, unpriced_reasons) = match currency {
CommandCurrency::Cny => (
combined.cny_priced_turns,
combined.cny_unpriced_turns,
&combined.cny_unpriced_reasons,
),
CommandCurrency::Usd => (
combined.priced_turns,
combined.unpriced_turns,
&combined.unpriced_reasons,
),
};
let mode = CommandMode::parse(&session.mode).unwrap_or(CommandMode::Agent);
let mode_label = codewhale_config::AppMode::from_setting(&session.mode)
.label()
.to_string();
let system_prompt = match session.system_prompt {
None => DebugSystemPrompt::None,
Some(SystemPrompt::Text(text)) => DebugSystemPrompt::Text(text),
Some(SystemPrompt::Blocks(blocks)) => {
DebugSystemPrompt::Blocks(blocks.into_iter().map(|block| block.text).collect())
}
};
let cost = DebugCostProjection {
currency,
// The combined subtotal is the authoritative total, and it is by
// construction the parent and children amounts added up; the
// terminal's #244 reconciliation floor only exists because its
// display total and its recomputed components can drift apart. The
// remote aggregate cannot drift from itself, so there is no floor to
// report and none is invented.
total: total_amount,
parent_turns: parent_amount,
subagents: children_amount,
display_floor: 0.0,
priced_turns: priced_turns.clamp(0, u32::MAX as u64) as u32,
unpriced_turns: unpriced_turns.clamp(0, u32::MAX as u64) as u32,
legacy_coverage_unknown: !combined.cost_complete,
// Pricing-provenance bookkeeping (user-declared estimates, live
// defects, route receipts) is terminal-session state the daemon
// does not track; the honest encoding is "none recorded".
user_declared_estimates: false,
// Per-route attribution needs per-turn provider identities, which
// the remote snapshot does not assemble yet; the coverage counts
// above stay authoritative. With no route rows, no turn is itemized:
// claiming otherwise would make the renderer state a breakdown it
// cannot show.
itemized_turns: 0,
route_amounts: Vec::<DebugRouteCost>::new(),
turn_history_capacity: 0,
unpriced_reason_labels: unpriced_reasons.iter().cloned().collect(),
unpriced_classes: combined.unpriced_classes.iter().cloned().collect(),
pricing_provenances: Vec::new(),
live_pricing_defects: Vec::new(),
unusable_pricing_defects: Vec::new(),
route_receipts: Vec::new(),
};
Ok(BuiltinCommandHostSnapshot {
mode,
mode_label,
locale,
model: thread.model.clone(),
system_prompt,
tokens: DebugTokenProjection {
active_context_used: (budget.input_tokens as usize).min(budget.window_tokens as usize),
context_window: budget.window_tokens.clamp(0, u32::MAX as u64) as u32,
// The context budget carries no per-turn telemetry, so the
// last-turn columns take their honest absence encoding rather
// than a stale or fabricated value.
last_input: None,
last_output: None,
cache_hit: None,
cache_miss: None,
total_tokens: combined.input_tokens.saturating_add(combined.output_tokens),
cache_write_tokens: combined.cache_write_tokens,
api_message_count,
chat_message_count,
model: thread.model.clone(),
cost,
},
})
}
@@ -1,72 +0,0 @@
use std::path::PathBuf;
use axum::Json;
use axum::extract::{Path, Query, State};
use serde::Deserialize;
use codewhale_engine::fleet::acp::AgentOptions;
use codewhale_engine::fleet::agents::ExecutorUnavailable;
use super::{ApiError, RuntimeApiState, external_agent_refusal};
const MAX_AGENT_ID_BYTES: usize = 64;
const MAX_CWD_BYTES: usize = 4096;
#[derive(Debug, Deserialize)]
pub(super) struct OptionsQuery {
cwd: Option<PathBuf>,
}
fn valid_agent_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= MAX_AGENT_ID_BYTES
&& id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
}
pub(super) async fn get_external_agent_options(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
Query(query): Query<OptionsQuery>,
) -> Result<Json<AgentOptions>, ApiError> {
if !valid_agent_id(&id) {
return Err(ApiError::bad_request(
"the agent id must name an agent from GET /v1/agents/external",
));
}
let cwd = query
.cwd
.ok_or_else(|| ApiError::bad_request("cwd is required"))?;
let text = cwd.to_string_lossy();
if text.len() > MAX_CWD_BYTES || text.chars().any(char::is_control) || !cwd.is_absolute() {
return Err(ApiError::bad_request(
"cwd must be an absolute directory path",
));
}
let probe = cwd.clone();
let is_dir = tokio::task::spawn_blocking(move || probe.is_dir())
.await
.map_err(|_| ApiError::internal("cwd check failed"))?;
if !is_dir {
return Err(ApiError::not_found(
"cwd is not a directory on this computer",
));
}
match state
.runtime_threads
.external_agent_options(&id, &cwd)
.await
{
Ok(options) => Ok(Json(options)),
Err(error) => {
let mut refusal = external_agent_refusal(&error);
if let Some(refusal) = refusal.as_mut()
&& refusal.code == Some(ExecutorUnavailable::UNKNOWN_AGENT)
{
refusal.status = axum::http::StatusCode::NOT_FOUND;
}
Err(refusal.unwrap_or_else(|| ApiError::internal(error.to_string())))
}
}
}
@@ -14,11 +14,10 @@ const PROBE_REAP_TIMEOUT: Duration = Duration::from_secs(2);
const SILENT_HANDSHAKE: &str = "It did not answer the Agent Client Protocol handshake.";
use codewhale_engine::fleet::agents::{
AGENTS, AgentQuota, ExecutorUnavailable, ExternalAgent, QuotaGate, QuotaSource, QuotaUnknown,
below_minimum, cli_version, foreign_command, local_signin_quota, not_installed, resolve,
AGENTS, ExecutorUnavailable, ExternalAgent, below_minimum, cli_version, foreign_command,
not_installed, resolve,
};
use codewhale_runtime::process_tree::ProcessTree;
use codewhale_runtime::settings::{AgentSettings, Settings};
#[derive(Debug)]
enum Handshake {
@@ -275,75 +274,6 @@ pub(super) async fn list_external_agents() -> Json<Value> {
Json(json!({ "agents": found }))
}
fn quota_json(quota: &AgentQuota) -> Value {
match quota {
AgentQuota::Known { source, windows } => json!({
"status": "known",
"source": source.as_str(),
"windows": windows
.iter()
.map(|window| json!({
"name": window.name,
"used_percent": window.used_percent,
"resets_at": window.resets_at,
}))
.collect::<Vec<_>>(),
}),
AgentQuota::Unknown(reason) => json!({
"status": "unknown",
"reason": reason.as_str(),
}),
}
}
async fn agent_quota_report(agents: &AgentSettings) -> Value {
let mut entries = Vec::with_capacity(AGENTS.len());
for agent in AGENTS {
let choice = agents.quota(agent.id);
let gate = QuotaGate {
enabled: choice.enabled,
consented: choice.consented_at.is_some(),
};
let quota = local_signin_quota(agent, gate).await;
entries.push(json!({
"id": agent.id,
"name": agent.name,
"quota": quota_json(&quota),
}));
}
for id in agents
.quota
.keys()
.filter(|id| !AGENTS.iter().any(|agent| agent.id == id.as_str()))
{
entries.push(json!({
"id": id,
"name": null,
"quota": quota_json(&AgentQuota::Unknown(QuotaUnknown::UnknownAgent)),
}));
}
json!({
"default_source": QuotaSource::AgentReported.as_str(),
"agents": entries,
})
}
pub(super) async fn get_external_agent_quota() -> Json<Value> {
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
let agents = tokio::task::spawn_blocking(move || {
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
Settings::load_read_only()
})
.await
.ok()
.and_then(Result::ok)
.map(|settings| settings.agents)
.unwrap_or_default();
Json(agent_quota_report(&agents).await)
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
+2 -20
View File
@@ -515,11 +515,9 @@ fn parse_porcelain(porcelain: &str) -> Vec<GitFileEntry> {
}
let index = record.as_bytes()[0] as char;
let worktree = record.as_bytes()[1] as char;
let Some(path) = record.get(3..).map(str::to_string) else {
continue;
};
let path = record[3..].to_string();
let mut old_path = None;
if matches!(index, 'R' | 'C') || matches!(worktree, 'R' | 'C') {
if matches!(index, 'R' | 'C') {
old_path = records.next().map(str::to_string);
}
entries.push(GitFileEntry {
@@ -2911,22 +2909,6 @@ mod tests {
);
}
#[test]
fn worktree_rename_consumes_its_original_path_record() {
let rows = parse_porcelain(" R new.md\0ab说明.md\0 M a.txt\0");
let rows: Vec<_> = rows
.iter()
.map(|row| (row.path.as_str(), row.status, row.old_path.as_deref()))
.collect();
assert_eq!(
rows,
[
("new.md", "renamed", Some("ab说明.md")),
("a.txt", "modified", None),
]
);
}
pub(super) fn repo() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path();
+43 -373
View File
@@ -3,20 +3,12 @@ use std::time::Duration;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use serde::Deserialize;
use serde_json::{Value, json};
use codewhale_engine::runtime_threads::job_operations::{
JobOperationTransaction, MEMBER_ABANDONED, MEMBER_ACCEPTED, MEMBER_RESERVED, MEMBER_UNKNOWN,
MemberOperation, MemberOperationRecord, member_request_fingerprint, new_member_agent_id,
valid_member_key,
};
use codewhale_engine::tools::subagent::AgentWorkerRecord;
use super::jobs::operation_error;
use super::{ApiError, RuntimeApiState, map_thread_err};
const MEMBER_START_WAIT: Duration = Duration::from_secs(20);
pub(super) const MAX_MEMBER_REQUEST_BYTES: usize = 2 * 1024 * 1024;
const MAX_MEMBER_PROMPT_CHARS: usize = 256_000;
#[derive(Debug, Deserialize)]
@@ -27,148 +19,28 @@ pub(super) struct StartMemberRequest {
display_name: String,
prompt: String,
fleet: codewhale_engine::fleet::store::FleetFile,
#[serde(default)]
expected_store_id: Option<String>,
#[serde(default)]
request_fingerprint: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub(super) struct MemberReceipt {
thread_id: String,
member_id: String,
operation_key: String,
request_fingerprint: String,
runtime_store_id: String,
agent_id: String,
state: String,
fn opaque(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value.as_bytes()[0].is_ascii_alphanumeric()
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
}
impl MemberReceipt {
fn new(record: &MemberOperationRecord, state: &str) -> Self {
Self {
thread_id: record.operation.thread_id.clone(),
member_id: record.operation.member_id.clone(),
operation_key: record.operation.operation_key.clone(),
request_fingerprint: record.request_fingerprint.clone(),
runtime_store_id: record.operation.store_id.clone(),
agent_id: record.operation.agent_id.clone(),
state: state.to_string(),
}
}
fn status(&self) -> StatusCode {
if self.state == MEMBER_ACCEPTED {
StatusCode::OK
} else {
StatusCode::ACCEPTED
}
}
}
fn worker_proves(operation: &MemberOperation, worker: Option<&AgentWorkerRecord>) -> bool {
worker.is_some_and(|worker| {
worker.owner_session_id == operation.thread_id
&& worker.spec.worker_id == operation.agent_id
&& worker
.spec
.child_route
.as_ref()
.and_then(|route| route.requested_profile.as_deref())
== Some(operation.member_id.as_str())
})
}
fn project(
transaction: &JobOperationTransaction<'_>,
record: &mut MemberOperationRecord,
worker: Option<&AgentWorkerRecord>,
generation: &str,
) -> anyhow::Result<String> {
let proven = worker_proves(&record.operation, worker);
match record.operation.state.as_str() {
MEMBER_ACCEPTED if proven => Ok(MEMBER_ACCEPTED.to_string()),
MEMBER_ACCEPTED => Ok(MEMBER_UNKNOWN.to_string()),
MEMBER_RESERVED if proven => {
settle(transaction, record, MEMBER_ACCEPTED)?;
Ok(MEMBER_ACCEPTED.to_string())
}
MEMBER_RESERVED if record.owner_generation != generation => {
settle(transaction, record, MEMBER_ABANDONED)?;
Ok(MEMBER_ABANDONED.to_string())
}
MEMBER_RESERVED => Ok(MEMBER_UNKNOWN.to_string()),
state => Ok(state.to_string()),
}
}
fn settle(
transaction: &JobOperationTransaction<'_>,
record: &mut MemberOperationRecord,
state: &str,
) -> anyhow::Result<()> {
record.operation.state = state.to_string();
record.operation.updated_at_ms = chrono::Utc::now()
.timestamp_millis()
.max(record.operation.created_at_ms);
transaction.save_member(record)
}
async fn member_worker(
state: &RuntimeApiState,
thread_id: &str,
agent_id: &str,
) -> Option<AgentWorkerRecord> {
state
.sub_agent_manager
.read()
.await
.get_worker_record_for_session(thread_id, agent_id)
}
async fn settle_outcome(
state: RuntimeApiState,
operation: &MemberOperation,
outcome: Option<Result<String, String>>,
) -> Result<(), ApiError> {
let worker = match &outcome {
Some(Ok(agent_id)) => member_worker(&state, &operation.thread_id, agent_id).await,
_ => None,
};
let id = operation.id.clone();
let store = state.runtime_threads.job_operation_store();
tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let Some(mut record) = transaction.get_member(&id)? else {
return Ok(());
};
if record.operation.state != MEMBER_RESERVED {
return Ok(());
}
match outcome {
Some(Ok(agent_id))
if agent_id == record.operation.agent_id
&& worker_proves(&record.operation, worker.as_ref()) =>
{
settle(transaction, &mut record, MEMBER_ACCEPTED)
}
Some(Err(_)) => transaction.remove_member(&id),
_ => Ok(()),
}
})
})
.await
.map_err(|_| ApiError::internal("member operation settlement failed"))?
.map_err(operation_error)
}
fn validate(request: &StartMemberRequest) -> Result<(String, String), ApiError> {
if !valid_member_key(&request.operation_key) || !valid_member_key(&request.member_id) {
pub(super) async fn start_thread_member(
State(state): State<RuntimeApiState>,
Path(thread_id): Path<String>,
Json(request): Json<StartMemberRequest>,
) -> Result<(StatusCode, Json<Value>), ApiError> {
let display_name = request.display_name.trim();
if !opaque(&request.operation_key) || !opaque(&request.member_id) {
return Err(ApiError::bad_request(
"operation_key and member_id must be 1-128 URL-safe characters",
));
}
let display_name = request.display_name.trim();
if display_name.is_empty()
|| display_name.chars().count() > 80
|| display_name.chars().any(char::is_control)
@@ -183,249 +55,47 @@ fn validate(request: &StartMemberRequest) -> Result<(String, String), ApiError>
"prompt must contain 1-256000 characters",
));
}
Ok((display_name.to_string(), prompt.to_string()))
}
pub(super) async fn start_thread_member(
State(state): State<RuntimeApiState>,
Path(thread_id): Path<String>,
Json(request): Json<StartMemberRequest>,
) -> Result<(StatusCode, Json<MemberReceipt>), ApiError> {
let (display_name, prompt) = validate(&request)?;
let store_id = state.runtime_threads.job_operation_store_id().to_string();
if request
.expected_store_id
.as_deref()
.is_some_and(|expected| expected != store_id)
{
return Err(ApiError::conflict("member operation store changed")
.with_code("member_operation_store_changed"));
}
let fingerprint = member_request_fingerprint(
&thread_id,
&request.member_id,
&display_name,
&prompt,
&request.fleet,
)
.map_err(|_| ApiError::internal("member request fingerprint failed"))?;
if request
.request_fingerprint
.as_deref()
.is_some_and(|claimed| claimed != fingerprint)
{
return Err(
ApiError::bad_request("request_fingerprint does not match this request")
.with_code("member_request_fingerprint_mismatch"),
);
}
state
let engine = state
.runtime_threads
.get_thread(&thread_id)
.get_engine(&thread_id)
.await
.map_err(map_thread_err)?;
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(operation_error)?;
let store = state.runtime_threads.job_operation_store();
let key = request.operation_key.clone();
let lookup_thread = thread_id.clone();
let existing = {
let store = store.clone();
tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let id = transaction.member_operation_id(&lookup_thread, &key)?;
transaction.get_member(&id)
})
})
.await
.map_err(|_| ApiError::internal("member operation lookup failed"))?
.map_err(operation_error)?
};
if let Some(existing) = existing {
if existing.request_fingerprint != fingerprint {
return Err(
ApiError::conflict("member operation key belongs to a different request")
.with_code("member_operation_mismatch"),
);
}
let receipt = replay(&state, existing, generation).await?;
if receipt.state == MEMBER_ABANDONED {
return Err(ApiError::conflict(
"member operation was abandoned before it started; use a new operation_key",
)
.with_code("member_operation_abandoned"));
}
return Ok((receipt.status(), Json(receipt)));
}
let reserve_thread = thread_id.clone();
let reserve_key = request.operation_key.clone();
let reserve_member = request.member_id.clone();
let reserve_fingerprint = fingerprint.clone();
let reserve_generation = generation.clone();
let reserved = {
let store = store.clone();
tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let id = transaction.member_operation_id(&reserve_thread, &reserve_key)?;
if let Some(existing) = transaction.get_member(&id)? {
return Ok(Err(existing));
}
let now = chrono::Utc::now().timestamp_millis();
let record = MemberOperationRecord {
schema_version: 1,
operation: MemberOperation {
id,
store_id,
thread_id: reserve_thread,
member_id: reserve_member,
operation_key: reserve_key,
agent_id: new_member_agent_id(),
state: MEMBER_RESERVED.to_string(),
created_at_ms: now,
updated_at_ms: now,
},
request_fingerprint: reserve_fingerprint,
owner_generation: reserve_generation,
};
transaction.save_member(&record)?;
Ok(Ok(record))
})
})
.await
.map_err(|_| ApiError::internal("member operation reservation failed"))?
.map_err(operation_error)?
};
let record = match reserved {
Ok(record) => record,
Err(existing) => {
if existing.request_fingerprint != fingerprint {
return Err(ApiError::conflict(
"member operation key belongs to a different request",
)
.with_code("member_operation_mismatch"));
}
let receipt = replay(&state, existing, generation).await?;
return Ok((receipt.status(), Json(receipt)));
}
};
let engine = match state.runtime_threads.get_engine(&thread_id).await {
Ok(engine) => engine,
Err(error) => {
settle_outcome(state.clone(), &record.operation, Some(Err(String::new()))).await?;
return Err(map_thread_err(error));
}
};
let (tx, mut rx) = tokio::sync::oneshot::channel();
if let Err(error) = engine
let (tx, rx) = tokio::sync::oneshot::channel();
engine
.send(codewhale_engine::core::ops::Op::StartFleetMember {
start: Box::new(codewhale_engine::tools::subagent::FleetMemberStart {
operation_key: request.operation_key.clone(),
fleet: request.fleet,
member_id: request.member_id.clone(),
display_name,
prompt,
reserved_agent_id: Some(record.operation.agent_id.clone()),
display_name: display_name.to_string(),
prompt: prompt.to_string(),
}),
tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))),
})
.await
{
settle_outcome(state.clone(), &record.operation, Some(Err(String::new()))).await?;
return Err(ApiError::internal(format!(
"Failed to reach the thread's engine: {error}"
)));
}
match tokio::time::timeout(MEMBER_START_WAIT, &mut rx).await {
Ok(Ok(Ok(agent_id))) => {
let settled = settle_outcome(state.clone(), &record.operation, Some(Ok(agent_id)))
.await
.is_ok();
let receipt = if settled {
replay(&state, record.clone(), generation)
.await
.unwrap_or_else(|_| MemberReceipt::new(&record, MEMBER_UNKNOWN))
} else {
MemberReceipt::new(&record, MEMBER_UNKNOWN)
};
Ok((receipt.status(), Json(receipt)))
}
Ok(Ok(Err(message))) => {
settle_outcome(state.clone(), &record.operation, Some(Err(message.clone()))).await?;
Err(ApiError::conflict(message))
}
.map_err(|err| ApiError::internal(format!("Failed to reach the thread's engine: {err}")))?;
match tokio::time::timeout(MEMBER_START_WAIT, rx).await {
Ok(Ok(Ok(agent_id))) => Ok((
StatusCode::OK,
Json(json!({
"thread_id": thread_id,
"member_id": request.member_id,
"agent_id": agent_id,
"state": "started",
})),
)),
Ok(Ok(Err(message))) => Err(ApiError::conflict(message)),
Ok(Err(_)) => Err(ApiError::internal(
"The engine closed before starting the member",
)),
Err(_) => {
let operation = record.operation.clone();
let background = state.clone();
tokio::spawn(async move {
let outcome = rx.await.ok();
if let Err(error) = settle_outcome(background, &operation, outcome).await {
tracing::warn!(error = %error.message, "member operation settlement failed");
}
});
let receipt = MemberReceipt::new(&record, MEMBER_UNKNOWN);
Ok((receipt.status(), Json(receipt)))
}
Err(_) => Ok((
StatusCode::ACCEPTED,
Json(json!({
"thread_id": thread_id,
"member_id": request.member_id,
"operation_key": request.operation_key,
"state": "queued",
})),
)),
}
}
async fn replay(
state: &RuntimeApiState,
record: MemberOperationRecord,
generation: String,
) -> Result<MemberReceipt, ApiError> {
let worker = member_worker(
state,
&record.operation.thread_id,
&record.operation.agent_id,
)
.await;
let store = state.runtime_threads.job_operation_store();
let id = record.operation.id.clone();
tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let mut record = transaction.get_member(&id)?.unwrap_or(record);
let state = project(transaction, &mut record, worker.as_ref(), &generation)?;
Ok(MemberReceipt::new(&record, &state))
})
})
.await
.map_err(|_| ApiError::internal("member operation lookup failed"))?
.map_err(operation_error)
}
pub(super) async fn get_member_operation(
State(state): State<RuntimeApiState>,
Path((thread_id, key)): Path<(String, String)>,
) -> Result<Json<MemberReceipt>, ApiError> {
if !valid_member_key(&key) {
return Err(ApiError::bad_request("invalid member operation key"));
}
state
.runtime_threads
.get_thread(&thread_id)
.await
.map_err(map_thread_err)?;
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(operation_error)?;
let store = state.runtime_threads.job_operation_store();
let record = tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let id = transaction.member_operation_id(&thread_id, &key)?;
transaction.get_member(&id)
})
})
.await
.map_err(|_| ApiError::internal("member operation lookup failed"))?
.map_err(operation_error)?
.ok_or_else(|| ApiError::not_found("member operation not found"))?;
Ok(Json(replay(&state, record, generation).await?))
}
@@ -1,489 +0,0 @@
use std::collections::{BTreeSet, HashMap};
use std::path::{Path as FsPath, PathBuf};
use axum::Json;
use axum::extract::{Query, State};
use serde::Serialize;
use serde_json::Value;
use super::plugins::{self, PluginSummaryEntry};
use super::{ApiError, CommandsQuery, HooksQuery, RuntimeApiState};
const MAX_ADDED_NAMES: usize = 512;
#[derive(Debug, Serialize)]
pub(super) struct PluginIndexResponse {
workspace: String,
items: Vec<IndexItem>,
unavailable: Vec<IndexNote>,
problems: Vec<IndexNote>,
}
#[derive(Debug, Serialize)]
struct IndexNote {
kind: &'static str,
message: String,
}
#[derive(Debug, Serialize)]
struct IndexItem {
id: String,
kind: &'static str,
name: String,
description: Option<String>,
source: &'static str,
state: &'static str,
labels: Vec<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
plugin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
adds: Option<PluginAdds>,
#[serde(skip_serializing_if = "Option::is_none")]
tier: Option<&'static str>,
owner: &'static str,
row: Value,
}
#[derive(Debug, Default, Serialize)]
struct PluginAdds {
skills: Vec<String>,
connectors: Vec<String>,
hooks: Vec<String>,
commands: Vec<String>,
profiles: Vec<String>,
}
impl PluginAdds {
fn labels(&self, entry: &PluginSummaryEntry) -> Vec<&'static str> {
[
("skill", !self.skills.is_empty()),
("connector", !self.connectors.is_empty()),
("hook", !self.hooks.is_empty()),
("command", !self.commands.is_empty()),
(
"tool",
entry.inventory.native > 0 || entry.inventory.lsp > 0,
),
("profile", !self.profiles.is_empty()),
]
.into_iter()
.filter_map(|(label, present)| present.then_some(label))
.collect()
}
}
fn row_value<T: Serialize>(row: &T) -> Value {
serde_json::to_value(row).unwrap_or(Value::Null)
}
fn note_failure(unavailable: &mut Vec<IndexNote>, kind: &'static str, error: &ApiError) {
unavailable.push(IndexNote {
kind,
message: error.message.clone(),
});
}
fn plugin_source(origin: &str) -> &'static str {
match origin {
"codewhale-builtin" => "built_in",
"workspace-codewhale" => "project",
_ => "user",
}
}
fn plugin_state(entry: &PluginSummaryEntry) -> &'static str {
if matches!(
entry.trust_status,
"content-changed" | "capabilities-changed"
) || entry.state == "enabled-untrusted"
{
return "needs_review";
}
match entry.state {
"active" => "enabled",
"disabled" => "disabled",
_ => "unavailable",
}
}
fn add_stem(names: &mut BTreeSet<String>, path: &FsPath, extension: Option<&str>, lowercase: bool) {
if let Some(wanted) = extension
&& path.extension().and_then(|value| value.to_str()) != Some(wanted)
{
return;
}
if let Some(stem) = path.file_stem().and_then(|value| value.to_str()) {
names.insert(if lowercase {
stem.to_lowercase()
} else {
stem.to_string()
});
}
}
async fn component_names(
paths: &[PathBuf],
extension: Option<&str>,
lowercase: bool,
omitted: &mut bool,
) -> Vec<String> {
let mut names = BTreeSet::new();
for path in paths {
let Ok(meta) = tokio::fs::symlink_metadata(path).await else {
continue;
};
if meta.is_file() {
add_stem(&mut names, path, extension, lowercase);
} else if meta.is_dir()
&& let Ok(mut entries) = tokio::fs::read_dir(path).await
{
let mut seen = 0;
while let Ok(Some(entry)) = entries.next_entry().await {
seen += 1;
if seen > MAX_ADDED_NAMES {
*omitted = true;
break;
}
if entry.file_type().await.is_ok_and(|kind| kind.is_file()) {
add_stem(&mut names, &entry.path(), extension, lowercase);
}
}
}
if names.len() >= MAX_ADDED_NAMES {
*omitted = true;
break;
}
}
if names.iter().any(|name| name.chars().any(char::is_control)) {
*omitted = true;
names.retain(|name| !name.chars().any(char::is_control));
}
names.into_iter().take(MAX_ADDED_NAMES).collect()
}
async fn plugin_adds(
plugin: &codewhale_plugins::types::LoadedPlugin,
omitted: &mut bool,
) -> PluginAdds {
let mut skills: Vec<String> = plugin
.skill_snapshots
.iter()
.map(|skill| skill.name.clone())
.collect();
skills.sort();
let mut connectors: Vec<String> = plugin
.manifest
.mcp_servers
.as_ref()
.map(|servers| servers.keys().cloned().collect())
.unwrap_or_default();
connectors.sort();
PluginAdds {
skills,
connectors,
hooks: component_names(&plugin.components.hooks, None, false, omitted).await,
commands: component_names(&plugin.components.commands, Some("md"), true, omitted).await,
profiles: component_names(&plugin.components.agents, Some("toml"), false, omitted).await,
}
}
async fn plugin_adds_by_id(
state: &RuntimeApiState,
omitted: &mut BTreeSet<String>,
) -> HashMap<String, PluginAdds> {
let registry = state
.plugin_discovery
.registry_for_workspace(&state.workspace);
let mut adds = HashMap::new();
for plugin in registry.list() {
let mut cut = false;
adds.insert(
plugin.id.as_str().to_string(),
plugin_adds(plugin, &mut cut).await,
);
if cut {
omitted.insert(plugin.name().to_string());
}
}
adds
}
fn skill_source(
path: Option<&PathBuf>,
is_bundled: bool,
skills_dir: &FsPath,
workspace: &FsPath,
) -> &'static str {
if is_bundled {
return "built_in";
}
match path {
Some(path) if path.starts_with(skills_dir) => "user",
Some(path) if path.starts_with(workspace) => "project",
Some(_) => "other_app",
None => "plugin",
}
}
pub(super) async fn get_plugin_index(
State(state): State<RuntimeApiState>,
) -> Result<Json<PluginIndexResponse>, ApiError> {
let mut items = Vec::new();
let mut unavailable = Vec::new();
let mut problems = Vec::new();
let commands = super::list_commands(
State(state.clone()),
Query(CommandsQuery { thread_id: None }),
)
.await;
let mut plugin_commands: HashMap<String, BTreeSet<String>> = HashMap::new();
if let Ok(Json(response)) = &commands {
for command in &response.commands {
if let (Some(plugin_id), "user") = (command.plugin_id.as_ref(), command.kind) {
plugin_commands
.entry(plugin_id.clone())
.or_default()
.insert(command.name.clone());
}
}
}
match plugins::list_plugins(State(state.clone())).await {
Ok(Json(response)) => {
let mut omitted = BTreeSet::new();
let mut adds_by_id = plugin_adds_by_id(&state, &mut omitted).await;
for name in &omitted {
problems.push(IndexNote {
kind: "plugin",
message: format!(
"plugin '{name}' adds more files than are listed, or a file name with control characters; some names are left out of `adds`"
),
});
}
for diagnostic in &response.registry_diagnostics {
problems.push(IndexNote {
kind: "plugin",
message: diagnostic.message.clone(),
});
}
for entry in &response.plugins {
let mut adds = adds_by_id.remove(&entry.id);
if entry.state == "active"
&& commands.is_ok()
&& let Some(adds) = adds.as_mut()
{
adds.commands = plugin_commands
.remove(&entry.id)
.map(|names| names.into_iter().collect())
.unwrap_or_default();
}
items.push(IndexItem {
id: format!("plugin:{}", entry.id),
kind: "plugin",
name: entry
.display_name
.clone()
.unwrap_or_else(|| entry.name.clone()),
description: entry.description.clone(),
source: plugin_source(entry.origin),
state: plugin_state(entry),
labels: adds
.as_ref()
.map(|adds| adds.labels(entry))
.unwrap_or_default(),
plugin: None,
adds,
tier: None,
owner: "/v1/apps/plugins",
row: row_value(entry),
});
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match plugins::list_plugin_catalog(State(state.clone())).await {
Ok(Json(catalog)) => {
for (tier, rows) in [
("builtin", &catalog.builtin),
("experimental", &catalog.experimental),
("more", &catalog.more),
] {
for row in rows {
items.push(IndexItem {
id: format!("plugin:catalog:{}", row.id),
kind: "plugin",
name: row.title.clone(),
description: row.description.clone(),
source: "built_in",
state: if !row.available {
"unavailable"
} else if row.enabled {
"enabled"
} else {
"disabled"
},
labels: Vec::new(),
plugin: None,
adds: None,
tier: Some(tier),
owner: "/v1/apps/plugins/catalog",
row: row_value(row),
});
}
}
}
Err(error) => note_failure(&mut unavailable, "plugin", &error),
}
match super::list_skills(State(state.clone())).await {
Ok(Json(response)) => {
for warning in &response.warnings {
problems.push(IndexNote {
kind: "skill",
message: warning.clone(),
});
}
for skill in &response.skills {
if skill.plugin_id.is_some() {
continue;
}
items.push(IndexItem {
id: format!("skill:{}", skill.name),
kind: "skill",
name: skill.name.clone(),
description: Some(skill.description.clone()),
source: skill_source(
skill.path.as_ref(),
skill.is_bundled,
&response.directory,
&state.workspace,
),
state: if skill.enabled { "enabled" } else { "disabled" },
labels: vec!["skill"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/skills",
row: row_value(skill),
});
}
}
Err(error) => note_failure(&mut unavailable, "skill", &error),
}
match super::list_mcp_servers(State(state.clone())).await {
Ok(Json(response)) => {
for server in &response.servers {
let source = match server.origin {
"plugin" => continue,
"project" => "project",
"runtime" => "runtime",
_ => "user",
};
items.push(IndexItem {
id: format!("connector:{}", server.name),
kind: "connector",
name: server.name.clone(),
description: None,
source,
state: if !server.enabled {
"disabled"
} else if server.auth_required {
"needs_sign_in"
} else {
"enabled"
},
labels: vec!["connector"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/apps/mcp/servers",
row: row_value(server),
});
}
}
Err(error) => note_failure(&mut unavailable, "connector", &error),
}
match super::list_hooks(State(state.clone()), Query(HooksQuery { thread_id: None })).await {
Ok(Json(response)) => {
for problem in &response.problems {
problems.push(IndexNote {
kind: "hook",
message: problem.clone(),
});
}
let mut ordinals: HashMap<&'static str, usize> = HashMap::new();
for hook in &response.hooks {
let ordinal = ordinals.entry(hook.event).or_default();
*ordinal += 1;
let source = match hook.source {
"plugin" => continue,
"project" => "project",
_ => "user",
};
let name = hook
.name
.clone()
.unwrap_or_else(|| format!("{} {}", hook.event, ordinal));
items.push(IndexItem {
id: format!("hook:{source}:{}:{ordinal}", hook.event),
kind: "hook",
name,
description: None,
source,
state: if response.enabled {
"enabled"
} else {
"disabled"
},
labels: vec!["hook"],
plugin: None,
adds: None,
tier: None,
owner: "/v1/hooks",
row: row_value(hook),
});
}
}
Err(error) => note_failure(&mut unavailable, "hook", &error),
}
match commands {
Ok(Json(response)) => {
for command in &response.commands {
let (source, plugin) = match (command.kind, command.plugin_id.as_ref()) {
("builtin", _) => continue,
("user", Some(_)) => continue,
("extension", plugin_id) => ("plugin", plugin_id.cloned()),
_ if command.workspace_sourced => ("project", None),
_ => ("user", None),
};
items.push(IndexItem {
id: format!("command:{}", command.name),
kind: "command",
name: command.name.clone(),
description: command.summary.clone(),
source,
state: "enabled",
labels: vec!["command"],
plugin,
adds: None,
tier: None,
owner: "/v1/commands",
row: row_value(command),
});
}
}
Err(error) => note_failure(&mut unavailable, "command", &error),
}
Ok(Json(PluginIndexResponse {
workspace: state.workspace.display().to_string(),
items,
unavailable,
problems,
}))
}
+42 -602
View File
@@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use sha2::{Digest, Sha256};
use codewhale_plugins::host_services::{PersonVia, PluginApprover, ReviewApproval};
use codewhale_plugins::marketplace::document::{
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
};
@@ -43,102 +42,6 @@ use super::{ApiError, RuntimeApiState};
const PLUGIN_UI_MAX_BYTES: u64 = 8 * 1024 * 1024;
const PLUGIN_UI_SLOT: &str = "customize.plugins.detail";
const PLUGIN_UI_SEATS: [&str; 6] = [
PLUGIN_UI_SLOT,
"conversation.composer.dock",
"conversation.chat.node",
"workpane.panel",
"sidebar.section",
"command.row",
];
const DSH_BOOTSTRAP_ENTRY: &str = "__codewhale_dsh_bootstrap.js";
const DSH_FRAME_MODULES: [&str; 4] = [
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
];
const DSH_ROUTE_METHODS: [&str; 5] = ["GET", "POST", "PUT", "PATCH", "DELETE"];
enum DshSeat {
Seat(&'static str),
Unsupported(&'static str),
}
const DSH_SEAT_TABLE: [(&str, DshSeat); 30] = [
("plugins.bundle.config", DshSeat::Seat(PLUGIN_UI_SLOT)),
("plugins.row.config", DshSeat::Seat(PLUGIN_UI_SLOT)),
("plugins.tab", DshSeat::Seat(PLUGIN_UI_SLOT)),
("plugins.detail.section", DshSeat::Seat(PLUGIN_UI_SLOT)),
("settings.section", DshSeat::Seat(PLUGIN_UI_SLOT)),
("settings.plugin.item", DshSeat::Seat(PLUGIN_UI_SLOT)),
("settings.general.item", DshSeat::Seat(PLUGIN_UI_SLOT)),
("settings.plugins.tab", DshSeat::Seat(PLUGIN_UI_SLOT)),
(
"conversation.chat.node",
DshSeat::Seat("conversation.chat.node"),
),
(
"conversation.chat.turnTail",
DshSeat::Seat("conversation.chat.node"),
),
(
"conversation.chat.assistant-actions",
DshSeat::Seat("conversation.chat.node"),
),
(
"conversation.chat.text-interaction",
DshSeat::Seat("conversation.chat.node"),
),
(
"conversation.user.text.node",
DshSeat::Seat("conversation.chat.node"),
),
(
"tool.call.toolview",
DshSeat::Seat("conversation.chat.node"),
),
("conversation.view", DshSeat::Seat("conversation.chat.node")),
(
"conversation.input.dock",
DshSeat::Seat("conversation.composer.dock"),
),
(
"conversation.input.overlay",
DshSeat::Seat("conversation.composer.dock"),
),
(
"conversation.input.right",
DshSeat::Seat("conversation.composer.dock"),
),
(
"conversation.composer.dock",
DshSeat::Seat("conversation.composer.dock"),
),
("sidebar.right.pane.tab", DshSeat::Seat("workpane.panel")),
("main.conversation", DshSeat::Seat("workpane.panel")),
("main.page-menu", DshSeat::Seat("workpane.panel")),
("sidebar.panellist", DshSeat::Seat("sidebar.section")),
("sidebar.workspaces", DshSeat::Seat("sidebar.section")),
("sidebar.footer.action", DshSeat::Seat("sidebar.section")),
(
"sidebar.session.row.leading",
DshSeat::Seat("sidebar.section"),
),
(
"sidebar.workspaces.session.row.action",
DshSeat::Seat("sidebar.section"),
),
(
"conversation.session.header.actions",
DshSeat::Unsupported("no ADE seat yet"),
),
(
"conversation.session.header.utilities",
DshSeat::Unsupported("no ADE seat yet"),
),
("contextMenu.icon", DshSeat::Unsupported("no ADE seat yet")),
];
// ---------------------------------------------------------------------------
// Response shapes
@@ -273,20 +176,9 @@ pub(super) struct PluginActionResponse {
pub(super) note: Option<&'static str>,
}
#[derive(Debug, Serialize)]
pub(super) struct PluginUiBridge {
pub(super) kind: &'static str,
pub(super) dsh_version: String,
pub(super) compatibility_digest: String,
}
#[derive(Debug, Serialize)]
pub(super) struct PluginUiSlotResponse {
pub(super) slot: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) dsh_slot: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) bridge: Option<PluginUiBridge>,
pub(super) base: String,
pub(super) entry: String,
pub(super) revision: String,
@@ -317,7 +209,6 @@ pub(super) struct InstallPluginRequest {
#[derive(Debug, Deserialize)]
pub(super) struct PluginUiQuery {
pub(super) slot: Option<String>,
pub(super) dsh_slot: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -628,18 +519,6 @@ struct PluginUiGrant {
ui_prefix: PathBuf,
file_hashes: Arc<BTreeMap<PathBuf, String>>,
expires: std::time::Instant,
dsh: Option<Arc<DshServed>>,
}
struct DshClosure {
package: String,
entry: String,
files: BTreeMap<String, String>,
host_provided: Vec<String>,
}
struct DshServed {
closure: DshClosure,
}
#[derive(Default)]
@@ -669,12 +548,6 @@ impl PluginUiGrants {
}
}
fn revoke_selector(&self, selector: &str) {
self.entries
.lock()
.retain(|_, value| value.selector != selector);
}
fn get(&self, revision: &str) -> Option<PluginUiGrant> {
let mut entries = self.entries.lock();
let now = std::time::Instant::now();
@@ -688,8 +561,7 @@ fn ui_file_refusal(plugin: &LoadedPlugin, display: &str, error: UiFileError) ->
UiFileError::Missing => ApiError::not_found(format!(
"plugin '{}' has no staged file '{display}'",
plugin.name()
))
.with_code("plugin_ui_file_not_reviewed"),
)),
UiFileError::TooLarge => ApiError::payload_too_large(format!(
"plugin '{}' file '{display}' exceeds the 8 MiB plugin UI limit",
plugin.name()
@@ -784,15 +656,6 @@ fn admit_ui_grant(state: &RuntimeApiState, grant: &PluginUiGrant) -> Result<(),
Ok(())
}
fn valid_dsh_slot_key(key: &str) -> bool {
let mut chars = key.chars();
key.len() <= 128
&& chars
.next()
.is_some_and(|first| first.is_ascii_alphanumeric())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
}
fn ui_stale_revision() -> ApiError {
ApiError {
status: StatusCode::PRECONDITION_FAILED,
@@ -801,300 +664,10 @@ fn ui_stale_revision() -> ApiError {
}
}
struct PreparedPluginUi {
entry: String,
dsh_slot: Option<String>,
bridge: Option<PluginUiBridge>,
grant: PluginUiGrant,
}
fn seat_not_admitted(plugin: &LoadedPlugin, seat: &str, dsh_slot: Option<&str>) -> ApiError {
let key = dsh_slot
.map(|key| format!(" key '{key}'"))
.unwrap_or_default();
ApiError::not_found(format!(
"plugin '{}' has no admitted client for seat '{seat}'{key}",
plugin.name()
))
.with_code("plugin_ui_seat_not_admitted")
}
fn ui_unsupported(message: impl Into<String>) -> ApiError {
ApiError::bad_request(message).with_code("plugin_ui_unsupported")
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn dsh_client_closure(conversion: &Value) -> Option<DshClosure> {
let closure = conversion
.get("client_closures")?
.as_array()?
.iter()
.find(|closure| closure.get("root").and_then(Value::as_str) == Some(""))?;
let files = closure
.get("files")?
.as_object()?
.iter()
.map(|(path, hash)| Some((path.clone(), hash.as_str()?.to_string())))
.collect::<Option<BTreeMap<_, _>>>()?;
let entry = closure.get("entry")?.as_str()?.to_string();
if !files.contains_key(&entry) {
return None;
}
Some(DshClosure {
package: closure
.get("package")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
entry,
files,
host_provided: closure
.get("host_provided")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default(),
})
}
fn verify_dsh_closure(
grant: &PluginUiGrant,
closure: &DshClosure,
) -> Result<Vec<(String, Vec<u8>)>, UiFileError> {
let mut verified = Vec::with_capacity(closure.files.len());
for (path, expected) in &closure.files {
let relative = ui_relative_path(path)
.map_err(|reason| UiFileError::Invalid(format!("closure file '{path}': {reason}")))?;
let bytes = verified_ui_file(grant, &relative)?;
if sha256_hex(&bytes) != *expected {
return Err(UiFileError::Invalid(format!(
"closure file '{path}' no longer matches its reviewed hash"
)));
}
verified.push((path.clone(), bytes));
}
Ok(verified)
}
fn has_esm_syntax(text: &str) -> bool {
text.contains("import.meta")
|| text.lines().any(|line| {
let line = line.trim_start();
[
"import ", "import{", "import\"", "import'", "export ", "export{", "export*",
]
.iter()
.any(|prefix| line.starts_with(prefix))
})
}
fn dsh_closure_unsupported(closure: &DshClosure, verified: &[(String, Vec<u8>)]) -> Option<String> {
let missing: Vec<&str> = closure
.host_provided
.iter()
.map(String::as_str)
.filter(|module| !DSH_FRAME_MODULES.contains(module))
.take(5)
.collect();
if !missing.is_empty() {
return Some(format!(
"this DSH client requires modules the plugin frame does not provide yet: {}",
missing.join(", ")
));
}
for (path, bytes) in verified {
let Ok(text) = std::str::from_utf8(bytes) else {
return Some(format!("closure file '{path}' is not UTF-8 text"));
};
if path.ends_with(".json") {
if serde_json::from_str::<Value>(text).is_err() {
return Some(format!("closure file '{path}' is not valid JSON"));
}
} else if has_esm_syntax(text) {
return Some(format!(
"closure file '{path}' uses ES module syntax; only CommonJS closures are served"
));
}
}
None
}
fn quoted_in(text: &str, key: &str) -> bool {
['"', '\'', '`']
.iter()
.any(|quote| text.contains(&format!("{quote}{key}{quote}")))
}
fn declared_dsh_keys(verified: &[(String, Vec<u8>)]) -> Vec<&'static (&'static str, DshSeat)> {
let texts: Vec<String> = verified
.iter()
.map(|(_, bytes)| String::from_utf8_lossy(bytes).into_owned())
.collect();
DSH_SEAT_TABLE
.iter()
.filter(|(key, _)| texts.iter().any(|text| quoted_in(text, key)))
.collect()
}
fn admit_dsh_seat(
plugin: &LoadedPlugin,
seat: &str,
requested: Option<&str>,
declared: &[&'static (&'static str, DshSeat)],
) -> Result<String, ApiError> {
if let Some(key) = requested {
let Some((_, mapped)) = declared.iter().find(|(name, _)| *name == key) else {
return Err(seat_not_admitted(plugin, seat, requested));
};
return match mapped {
DshSeat::Unsupported(reason) => Err(ApiError {
status: StatusCode::UNPROCESSABLE_ENTITY,
message: format!(
"plugin '{}' declares '{key}', which has no Codewhale seat: {reason}",
plugin.name()
),
code: Some("plugin_ui_seat_unsupported"),
}),
DshSeat::Seat(mapped_seat) if *mapped_seat == seat => Ok(key.to_string()),
DshSeat::Seat(_) => Err(seat_not_admitted(plugin, seat, requested)),
};
}
let mut on_seat = declared.iter().filter_map(|(name, mapped)| match mapped {
DshSeat::Seat(mapped_seat) if *mapped_seat == seat => Some(*name),
_ => None,
});
match (on_seat.next(), on_seat.next()) {
(None, _) => Err(seat_not_admitted(plugin, seat, None)),
(Some(key), None) => Ok(key.to_string()),
(Some(_), Some(_)) => Err(ApiError::bad_request(format!(
"plugin '{}' admits more than one client key on seat '{seat}'; pass dsh_slot",
plugin.name()
))
.with_code("plugin_ui_dsh_slot_required")),
}
}
fn dsh_bridge() -> Result<PluginUiBridge, ApiError> {
let receipt: Value = serde_json::from_slice(codewhale_plugins::activation::DSH_COMPATIBILITY)
.map_err(|_| {
ApiError::internal("the pinned DSH compatibility receipt is unreadable")
})?;
let version = receipt
.get("target_dsh_version")
.and_then(Value::as_str)
.ok_or_else(|| ApiError::internal("the pinned DSH compatibility receipt has no version"))?;
Ok(PluginUiBridge {
kind: "dsh-client",
dsh_version: version.to_string(),
compatibility_digest: sha256_hex(codewhale_plugins::activation::DSH_COMPATIBILITY),
})
}
const DSH_BOOTSTRAP_HEAD: &str = r#""use strict";
(function () {
var files = {
"#;
const DSH_BOOTSTRAP_TAIL: &str = r#" var cache = {};
var probes = ["", ".js", ".mjs", ".cjs", ".json", "/index.js", "/index.mjs", "/index.cjs"];
var hostRequire = null;
function normalize(path) {
var out = [];
path.split("/").forEach(function (part) {
if (part === "" || part === ".") return;
if (part === "..") out.pop();
else out.push(part);
});
return out.join("/");
}
function resolve(from, id) {
var base = normalize(from.slice(0, from.lastIndexOf("/")) + "/" + id);
for (var i = 0; i < probes.length; i++) {
if (Object.prototype.hasOwnProperty.call(files, base + probes[i])) return base + probes[i];
}
return null;
}
function load(path) {
if (Object.prototype.hasOwnProperty.call(cache, path)) return cache[path].exports;
var module = { exports: {} };
cache[path] = module;
files[path].call(module.exports, module, module.exports, makeRequire(path));
return module.exports;
}
function makeRequire(from) {
return function (id) {
if (id.charAt(0) === ".") {
var resolved = resolve(from, id);
if (resolved === null) throw new Error("the plugin client requires a file outside its reviewed closure: " + id);
return load(resolved);
}
return hostRequire(id);
};
}
window.__ModuleLoader__.load({
id: PLUGIN_ID,
factory: function (require) {
hostRequire = require;
return load(ENTRY);
}
});
})();
"#;
fn dsh_bootstrap(
selector: &str,
closure: &DshClosure,
verified: &[(String, Vec<u8>)],
) -> Result<Vec<u8>, UiFileError> {
let quote = |text: &str| {
serde_json::to_string(text).map_err(|error| UiFileError::Internal(error.to_string()))
};
let mut out = String::from(DSH_BOOTSTRAP_HEAD);
for (path, bytes) in verified {
let text = std::str::from_utf8(bytes)
.map_err(|_| UiFileError::Invalid(format!("closure file '{path}' is not UTF-8")))?;
out.push_str(" ");
out.push_str(&quote(path)?);
out.push_str(": function (module, exports, require) {\n");
if path.ends_with(".json") {
out.push_str("module.exports = ");
out.push_str(text);
out.push(';');
} else {
out.push_str(text);
}
out.push_str("\n},\n");
}
out.push_str(" };\n");
out.push_str(&format!(
" var PLUGIN_ID = {};\n var ENTRY = {};\n",
quote(if closure.package.is_empty() {
selector
} else {
&closure.package
})?,
quote(&closure.entry)?
));
out.push_str(DSH_BOOTSTRAP_TAIL);
Ok(out.into_bytes())
}
fn prepare_plugin_ui(
state: &RuntimeApiState,
selector: &str,
seat: &str,
dsh_slot: Option<&str>,
) -> Result<PreparedPluginUi, ApiError> {
) -> Result<(String, PluginUiGrant), ApiError> {
let registry = registry_for_state(state);
let plugin = registry
.get(selector)
@@ -1145,7 +718,6 @@ fn prepare_plugin_ui(
ui_prefix: PathBuf::new(),
file_hashes: Arc::new(validated.file_hashes),
expires: std::time::Instant::now() + std::time::Duration::from_secs(3600),
dsh: None,
};
let mut found = None;
for candidate in ["package.json", "source/package.json"] {
@@ -1173,7 +745,6 @@ fn prepare_plugin_ui(
return Err(ApiError::not_found(format!("plugin '{}' declares no dsh.client UI in {}; add dsh.client and exports[\"./client\"] to expose client assets", plugin.name(), ui_relative_display(&manifest_path))).with_code("plugin_ui_not_declared"));
}
let conversion_path = FsPath::new("CONVERSION.json");
let mut closure = None;
if grant.file_hashes.contains_key(conversion_path) {
let bytes = verified_ui_file(&grant, conversion_path)
.map_err(|error| ui_file_refusal(&plugin, "CONVERSION.json", error))?;
@@ -1188,39 +759,16 @@ fn prepare_plugin_ui(
.and_then(Value::as_str)
== Some("dsh")
{
closure = Some(dsh_client_closure(&conversion).ok_or_else(|| {
ui_unsupported(
"this DSH client UI was not converted and is not supported by Codewhale yet",
)
})?);
return Err(ApiError::bad_request(
"this DSH client UI was not converted and is not supported by Codewhale yet",
)
.with_code("plugin_ui_unsupported"));
}
}
grant.ui_prefix = manifest_path
.parent()
.unwrap_or(FsPath::new(""))
.to_path_buf();
if let Some(closure) = closure {
let verified = verify_dsh_closure(&grant, &closure)
.map_err(|error| ui_file_refusal(&plugin, "client closure", error))?;
if let Some(reason) = dsh_closure_unsupported(&closure, &verified) {
return Err(ui_unsupported(format!(
"plugin '{}': {reason}",
plugin.name()
)));
}
let key = admit_dsh_seat(&plugin, seat, dsh_slot, &declared_dsh_keys(&verified))?;
grant.dsh = Some(Arc::new(DshServed { closure }));
admit_ui_grant(state, &grant)?;
return Ok(PreparedPluginUi {
entry: DSH_BOOTSTRAP_ENTRY.to_string(),
dsh_slot: Some(key),
bridge: Some(dsh_bridge()?),
grant,
});
}
if seat != PLUGIN_UI_SLOT || dsh_slot.is_some() {
return Err(seat_not_admitted(&plugin, seat, dsh_slot));
}
let raw = ui_client_entry(&manifest).unwrap_or_else(|| "client.js".into());
let relative = ui_relative_path(&raw).map_err(|reason| {
ApiError::bad_request(format!(
@@ -1231,12 +779,7 @@ fn prepare_plugin_ui(
verified_ui_file(&grant, &grant.ui_prefix.join(&relative))
.map_err(|error| ui_file_refusal(&plugin, &ui_relative_display(&relative), error))?;
admit_ui_grant(state, &grant)?;
Ok(PreparedPluginUi {
entry: ui_relative_display(&relative),
dsh_slot: None,
bridge: None,
grant,
})
Ok((ui_relative_display(&relative), grant))
}
fn plugin_ui_base(
@@ -1366,8 +909,6 @@ async fn run_plugin_mutation(
PluginMutationOutcome::Uninstalled => "uninstalled",
};
state.plugin_ui_grants.revoke_selector(&receipt.name);
// Mutations can change merged plugin MCP servers. Advance the existing
// shared generation so each captured workspace pool reloads on next use.
state
@@ -1433,16 +974,8 @@ async fn run_registry_mutation(
let mut registry = (*registry).clone();
let result = match mutation {
RegistryMutation::Trust { .. } => registry.trust_reviewed(
selector,
ReviewApproval {
approved_by: PluginApprover::person(PersonVia::App),
host_services: None,
},
),
RegistryMutation::Enable => {
registry.enable_by(selector, PluginApprover::person(PersonVia::App))
}
RegistryMutation::Trust { .. } => registry.trust(selector),
RegistryMutation::Enable => registry.enable(selector),
RegistryMutation::Restage => registry.restage(selector),
RegistryMutation::Disable => registry.disable(selector),
RegistryMutation::Revoke => registry.revoke_trust(selector),
@@ -1451,8 +984,6 @@ async fn run_registry_mutation(
ApiError::conflict(format!("{action} failed for '{selector}': {error}"))
})?;
state.plugin_ui_grants.revoke_selector(selector);
state
.workspace_scopes
.mcp_generation
@@ -1734,58 +1265,43 @@ pub(super) async fn get_plugin_ui(
));
}
let slot = query.slot.ok_or_else(|| {
ApiError::bad_request(format!(
"the 'slot' query parameter is required; seats: {}",
PLUGIN_UI_SEATS.join(", ")
))
.with_code("plugin_ui_seat_unknown")
})?;
if !PLUGIN_UI_SEATS.contains(&slot.as_str()) {
return Err(ApiError::bad_request(format!(
"slot '{slot}' is not a plugin UI seat; seats: {}",
PLUGIN_UI_SEATS.join(", ")
))
.with_code("plugin_ui_seat_unknown"));
}
if let Some(key) = &query.dsh_slot
&& !valid_dsh_slot_key(key)
{
return Err(ApiError::bad_request(
"dsh_slot must be 1 to 128 characters of letters, digits, '.', '_' or '-', starting with a letter or digit",
ApiError::bad_request(
"the 'slot' query parameter is required; supported slot: 'customize.plugins.detail'",
)
.with_code("plugin_ui_dsh_slot_invalid"));
})?;
if slot.as_str() != PLUGIN_UI_SLOT {
return Err(ApiError::bad_request(format!(
"slot '{slot}' is not supported; supported slot: 'customize.plugins.detail'"
)));
}
let state_for_prepare = state.clone();
let selector_for_prepare = selector.clone();
let seat = slot.clone();
let requested_key = query.dsh_slot.clone();
let prepared = tokio::task::spawn_blocking(move || {
prepare_plugin_ui(
&state_for_prepare,
&selector_for_prepare,
&seat,
requested_key.as_deref(),
)
let (entry, grant) = tokio::task::spawn_blocking(move || {
prepare_plugin_ui(&state_for_prepare, &selector_for_prepare)
})
.await
.map_err(|_| ApiError::internal("plugin UI preparation failed"))??;
let base = plugin_ui_base(&state, &selector, &headers, &uri)?;
let revision = state.plugin_ui_grants.insert(prepared.grant);
let revision = state.plugin_ui_grants.insert(grant);
Ok(Json(PluginUiSlotResponse {
slot,
dsh_slot: prepared.dsh_slot,
bridge: prepared.bridge,
base,
entry: prepared.entry,
entry,
revision,
}))
}
fn grant_for_request(
state: &RuntimeApiState,
selector: &str,
headers: &HeaderMap,
) -> Result<(PluginUiGrant, String), ApiError> {
pub(super) async fn get_plugin_ui_asset(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin UI assets are available only from loopback",
));
}
let mut values = headers.get_all(header::IF_MATCH).iter();
let first = values.next().ok_or_else(|| ApiError {
status: StatusCode::PRECONDITION_REQUIRED,
@@ -1803,101 +1319,25 @@ fn grant_for_request(
if grant.selector != selector || grant.authority.workspace != state.workspace {
return Err(ui_stale_revision());
}
Ok((grant, revision))
}
pub(super) async fn plugin_route_bridge(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
method: axum::http::Method,
headers: HeaderMap,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin routes are available only from loopback",
));
}
let (grant, revision) = grant_for_request(&state, &selector, &headers)?;
let state_for_route = state.clone();
tokio::task::spawn_blocking(move || {
codewhale_plugins::registry::verify_plugin_authority(&grant.authority)
.map_err(|_| ui_stale_revision())?;
admit_ui_grant(&state_for_route, &grant)?;
if state_for_route.plugin_ui_grants.get(&revision).is_none() {
return Err(ui_stale_revision());
}
if grant.dsh.is_none() {
return Err(ApiError::forbidden(
"this plugin UI grant has no DSH client and no plugin routes",
)
.with_code("plugin_route_not_admitted"));
}
Ok(())
})
.await
.map_err(|_| ApiError::internal("plugin route check failed"))??;
ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!("plugin route path '{path}' is invalid: {reason}"))
})?;
if !DSH_ROUTE_METHODS.contains(&method.as_str()) {
return Err(ApiError {
status: StatusCode::METHOD_NOT_ALLOWED,
message: format!("plugin routes do not accept {method}"),
code: None,
});
}
Err(ApiError::not_implemented(
"this Engine has no plugin web server to forward the route to yet",
)
.with_code("plugin_route_not_served"))
}
pub(super) async fn get_plugin_ui_asset(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin UI assets are available only from loopback",
));
}
let (grant, revision) = grant_for_request(&state, &selector, &headers)?;
let relative = ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!("plugin UI path '{path}' is invalid: {reason}"))
})?;
let content_type = ui_content_type(&relative);
if grant.dsh.is_some() && relative != FsPath::new(DSH_BOOTSTRAP_ENTRY) {
return Err(
ApiError::not_found("plugin UI file is not in the reviewed client closure")
.with_code("plugin_ui_file_not_reviewed"),
);
}
let state_for_asset = state.clone();
let bytes = tokio::task::spawn_blocking(move || {
codewhale_plugins::registry::verify_plugin_authority(&grant.authority)
.map_err(|_| ui_stale_revision())?;
let bytes = if let Some(dsh) = &grant.dsh {
let verified =
verify_dsh_closure(&grant, &dsh.closure).map_err(|_| ui_stale_revision())?;
dsh_bootstrap(&grant.selector, &dsh.closure, &verified)
.map_err(|_| ui_stale_revision())?
} else {
verified_ui_file(&grant, &grant.ui_prefix.join(&relative)).map_err(
|error| match error {
UiFileError::Missing => {
ApiError::not_found("plugin UI file is not in the reviewed bundle")
.with_code("plugin_ui_file_not_reviewed")
}
UiFileError::TooLarge => {
ApiError::payload_too_large("plugin UI file exceeds the 8 MiB limit")
}
_ => ui_stale_revision(),
},
)?
};
let bytes = verified_ui_file(&grant, &grant.ui_prefix.join(&relative)).map_err(
|error| match error {
UiFileError::Missing => {
ApiError::not_found("plugin UI file is not in the reviewed bundle")
}
UiFileError::TooLarge => {
ApiError::payload_too_large("plugin UI file exceeds the 8 MiB limit")
}
_ => ui_stale_revision(),
},
)?;
admit_ui_grant(&state_for_asset, &grant)?;
if state_for_asset.plugin_ui_grants.get(&revision).is_none() {
return Err(ui_stale_revision());
+108 -58
View File
@@ -15,22 +15,12 @@ use tokio::sync::{Mutex, mpsc, oneshot};
use tokio::time::sleep;
use uuid::Uuid;
mod catalog_parity;
mod command_catalog;
#[cfg(unix)]
mod control_socket_selection;
mod conversation_modes;
#[cfg(unix)]
mod external_agent_options;
#[cfg(unix)]
mod external_agent_quota;
mod headless_catalog;
#[cfg(any(unix, windows))]
mod member_operations;
#[cfg(unix)]
mod owner_bootstrap;
mod plugin_index;
mod plugin_ui;
mod profile_constitution;
#[cfg(any(unix, windows))]
mod runtime_store_convergence;
@@ -426,7 +416,6 @@ fn saved_session_with_blocks(blocks: Vec<codewhale_models::ContentBlock>) -> Sav
composer_color: None,
last_auto_route: None,
turn_outcomes: Vec::new(),
agent_preset: None,
}
}
@@ -567,7 +556,6 @@ fn messages_from_thread_detail_batches_tool_results() {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
};
let turn = TurnRecord {
source_usage_origin_turn_id: None,
@@ -800,7 +788,6 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
last_read: None,
executor: None,
environment: None,
agent_preset: None,
},
turns: Vec::new(),
items: Vec::new(),
@@ -8205,13 +8192,18 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
);
let snapshot_count = repo.list(usize::MAX)?.len();
let (preview, paths) =
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), true, None)
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), true, true, None)
.expect("a trusted preview should succeed");
assert!(!preview.files_restored);
assert_eq!(paths, vec!["a.txt", "made/new.txt"]);
let refused =
patch_undo_workspace_files_impl(&workspace, std::slice::from_ref(&turn), false, Some(&[]))
.expect_err("an unreviewed file plan must refuse before restoring");
let refused = patch_undo_workspace_files_impl(
&workspace,
std::slice::from_ref(&turn),
true,
false,
Some(&[]),
)
.expect_err("an unreviewed file plan must refuse before restoring");
assert_eq!(refused.status, StatusCode::CONFLICT);
assert_eq!(refused.code, Some("undo_selection_changed"));
@@ -8220,7 +8212,7 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
assert_eq!(fs::read_to_string(&other)?, "later work");
assert_eq!(repo.list(usize::MAX)?.len(), snapshot_count);
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
let restored = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
.expect("a trusted rollback should succeed");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&a)?, "before", "both writes are undone");
@@ -8240,7 +8232,7 @@ fn patch_undo_helper_restores_only_the_dropped_turns_changes() -> Result<()> {
);
// Undoing again finds the files already at their pre-turn state.
let again = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
let again = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
.expect("already restored is provably nothing to do");
assert!(!again.files_restored);
Ok(())
@@ -8274,7 +8266,7 @@ fn patch_undo_helper_refuses_to_overwrite_later_edits() -> Result<()> {
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
);
let err = patch_undo_workspace_files(&workspace, &[turn])
let err = patch_undo_workspace_files(&workspace, &[turn], true)
.expect_err("a later edit must not be overwritten");
assert_eq!(err.status, StatusCode::CONFLICT);
assert_eq!(err.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
@@ -8330,7 +8322,7 @@ fn patch_undo_helper_fails_honestly_without_an_owned_restore_point() -> Result<(
),
];
for (turn, code) in cases {
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), true)
.expect_err(&turn.turn_id);
assert_eq!(err.status, StatusCode::CONFLICT, "{}", turn.turn_id);
assert_eq!(err.code, Some(code), "{}: {}", turn.turn_id, err.message);
@@ -8338,16 +8330,20 @@ fn patch_undo_helper_fails_honestly_without_an_owned_restore_point() -> Result<(
assert_eq!(fs::read_to_string(&a)?, "after");
}
let chat_only =
patch_undo_workspace_files(&workspace, &[dropped_turn("chat", false, Vec::new())])
.expect("a turn that ran no tools changed no files");
let chat_only = patch_undo_workspace_files(
&workspace,
&[dropped_turn("chat", false, Vec::new())],
false,
)
.expect("a turn that ran no tools changed no files");
assert!(!chat_only.files_restored);
// A prune that drops the restore point's tree fails closed too.
let receipts = shell_turn_receipts(&repo, &pre, &post, "thr_a");
repo.prune_older_than(Duration::ZERO)?;
let err = patch_undo_workspace_files(&workspace, &[dropped_turn("pruned", true, receipts)])
.expect_err("a pruned restore point cannot be restored");
let err =
patch_undo_workspace_files(&workspace, &[dropped_turn("pruned", true, receipts)], true)
.expect_err("a pruned restore point cannot be restored");
assert_eq!(err.code, Some(PATCH_UNDO_RESTORE_POINT_PRUNED));
assert_eq!(fs::read_to_string(&a)?, "after");
Ok(())
@@ -8391,6 +8387,7 @@ fn patch_undo_helper_survives_a_prune_that_rewrites_commit_ids() -> Result<()> {
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
)],
true,
)
.expect("the tree still resolves the restore point");
assert!(restored.files_restored);
@@ -8484,6 +8481,7 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
let err = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, receipts(gap_writer))],
true,
)
.expect_err("another writer's change must not be reverted");
assert_eq!(err.status, StatusCode::CONFLICT);
@@ -8510,9 +8508,12 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
None,
Some(changed_between(&repo, &post_tool, &clean_post)),
);
let restored =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, clean.clone())])
.expect("the turn's own write restores");
let restored = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, clean.clone())],
true,
)
.expect("the turn's own write restores");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&own)?, "before");
@@ -8520,8 +8521,9 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
fs::write(&own, "turn")?;
let mut shell = clean.clone();
shell[1].write_paths = None;
let restored = patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, shell)])
.expect("an undeclared span owns its changes");
let restored =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, shell)], true)
.expect("an undeclared span owns its changes");
assert!(restored.files_restored);
assert_eq!(fs::read_to_string(&own)?, "before");
@@ -8529,8 +8531,9 @@ fn patch_undo_helper_attributes_changes_to_the_turns_own_tool_spans() -> Result<
fs::write(&own, "turn")?;
let mut unknown = clean;
unknown[2].changed_paths = None;
let err = patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, unknown)])
.expect_err("an unaccounted span fails closed");
let err =
patch_undo_workspace_files(&workspace, &[dropped_turn("turn_1", true, unknown)], true)
.expect_err("an unaccounted span fails closed");
assert_eq!(
err.code,
Some(PATCH_UNDO_NO_RESTORE_POINT),
@@ -8591,6 +8594,7 @@ fn patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold() -> Result<(
let err = patch_undo_workspace_files(
&workspace,
&[dropped_turn("turn_1", true, receipts(&[declared]))],
true,
)
.expect_err(declared);
assert_eq!(err.status, StatusCode::CONFLICT, "{declared}");
@@ -8605,24 +8609,24 @@ fn patch_undo_helper_refuses_declared_writes_snapshots_cannot_hold() -> Result<(
// The same declared write from a call recorded as failed wrote nothing.
let mut failed = dropped_turn("turn_1", true, receipts(&[".env.local"]));
failed.unrun_tool_calls.insert("call-1".to_string());
let result =
patch_undo_workspace_files(&workspace, &[failed]).expect("a failed call wrote nothing");
let result = patch_undo_workspace_files(&workspace, &[failed], true)
.expect("a failed call wrote nothing");
assert!(!result.files_restored);
// Declared through the turn's items rather than a receipt.
let mut from_items = dropped_turn("turn_1", true, receipts(&[]));
from_items.declared_writes.push(".env.local".to_string());
let err = patch_undo_workspace_files(&workspace, &[from_items])
let err = patch_undo_workspace_files(&workspace, &[from_items], true)
.expect_err("an item-declared ignored write refuses too");
assert_eq!(err.code, Some(PATCH_UNDO_PATH_NOT_SNAPSHOTTED));
Ok(())
}
/// Default posture (no trust mode, no Full Access): the Runtime does not
/// gate on trust at all, because the ownership and later-edit proofs already
/// decide what may be reverted. A later edit in the same workspace still
/// refuses and leaves every byte alone.
/// The gate the TUI's `/undo` applies, stated as a contract: an untrusted
/// thread may not roll files back. When there *is* something to roll back the
/// whole undo aborts — nothing is changed, and the caller must not fork the
/// conversation either.
#[test]
fn patch_undo_helper_restores_in_the_default_posture_and_still_refuses_later_edits() -> Result<()> {
fn patch_undo_helper_refuses_an_untrusted_rollback_and_touches_nothing() -> Result<()> {
if git_missing() {
return Ok(());
}
@@ -8647,20 +8651,69 @@ fn patch_undo_helper_restores_in_the_default_posture_and_still_refuses_later_edi
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
);
fs::write(&file, "edited later by the person")?;
let refused = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn))
.expect_err("a later edit must not be overwritten");
assert_eq!(refused.code, Some(PATCH_UNDO_WORKSPACE_CHANGED));
assert_eq!(fs::read_to_string(&file)?, "edited later by the person");
let err = patch_undo_workspace_files(&workspace, std::slice::from_ref(&turn), false)
.expect_err("an untrusted rollback must be refused");
assert_eq!(err.status, StatusCode::CONFLICT);
assert_eq!(err.code, Some(PATCH_UNDO_UNTRUSTED));
assert!(
err.message.contains("outside trusted mode"),
"got: {}",
err.message
);
assert_eq!(
fs::read_to_string(&file)?,
"after",
"a refusal must leave the workspace exactly as it was"
);
fs::write(&file, "after")?;
let restored = patch_undo_workspace_files(&workspace, &[turn])
.expect("the turn's own change restores without trust");
// The identical call once trusted performs the rollback.
let restored = patch_undo_workspace_files(&workspace, &[turn], true)
.expect("a trusted rollback should succeed");
assert!(restored.files_restored, "{:?}", restored.summary);
assert_eq!(fs::read_to_string(&file)?, "before");
Ok(())
}
/// ...but with provably nothing to roll back, an untrusted undo is still
/// allowed: the conversation half needs no trust, and skipping it would make
/// Undo useless in Ask / Auto-Review for no safety gain.
#[test]
fn patch_undo_helper_allows_an_untrusted_undo_with_nothing_to_roll_back() -> Result<()> {
if git_missing() {
return Ok(());
}
let _lock = lock_test_env();
let root = tempfile::tempdir()?;
let home = root.path().join("home");
fs::create_dir_all(&home)?;
let _home = EnvVarGuard::set("HOME", &home);
let workspace = root.path().join("workspace");
fs::create_dir_all(&workspace)?;
let repo = codewhale_snapshot::SnapshotRepo::open_or_init(&workspace)?;
let file = workspace.join("a.txt");
fs::write(&file, "only-state")?;
let pre = repo.take_snapshot("pre-turn:1", Some("thr_a"))?;
// The turn ran a tool but changed nothing.
let post = repo.take_snapshot("post-turn:1", Some("thr_a"))?;
let result = patch_undo_workspace_files(
&workspace,
&[dropped_turn(
"turn_1",
true,
shell_turn_receipts(&repo, &pre, &post, "thr_a"),
)],
false,
)
.expect("nothing to roll back is not a refusal");
assert!(!result.files_restored);
assert!(result.summary.is_some());
assert_eq!(fs::read_to_string(&file)?, "only-state");
Ok(())
}
fn sha256_of(path: &FsPath) -> String {
format!(
"sha256:{}",
@@ -11089,8 +11142,6 @@ async fn runtime_info_reports_bind_state() -> Result<()> {
assert!(info["account"].get("refresh_token").is_none());
assert!(info["account"].get("email").is_none());
assert!(info["experimental"].is_object());
assert!(info["sandbox"]["kind"].is_string());
assert_eq!(info["sandbox"]["tool_launcher"], false);
handle.abort();
Ok(())
@@ -26455,9 +26506,8 @@ mod thread_snapshot_ownership {
/// An undo that cannot restore files fails honestly: snapshots disabled,
/// a legacy seeded turn with tool calls and no receipts, a restore point
/// pruned from the store. Each is a 409 with a code, no fork, and
/// unchanged files. A default-posture thread (no trust, no Full Access)
/// with an intact restore point is not among them: it restores.
/// pruned from the store, and an untrusted thread. Each is a 409 with a
/// code, no fork, and unchanged files.
#[tokio::test]
async fn undo_that_cannot_restore_files_is_refused() -> Result<()> {
let _env = lock_test_env();
@@ -26540,7 +26590,7 @@ mod thread_snapshot_ownership {
assert_eq!(fx.thread_count().await?, before + 1);
drop(fx);
// Snapshots on again: a pruned restore point, then a default-posture thread.
// Snapshots on again: a pruned restore point and an untrusted thread.
let root = test_root("honest-on");
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.join("home"));
let Some(fx) = fixture(&root, TestServerOverrides::default()).await? else {
@@ -26562,9 +26612,9 @@ mod thread_snapshot_ownership {
.await?
.error_for_status()?;
let before = fx.thread_count().await?;
fx.undo_restoring(&untrusted, 0).await?;
assert_eq!(fx.read("u.txt"), None);
assert_eq!(fx.thread_count().await?, before + 1);
refused(&fx, untrusted, "restore_requires_trust").await?;
assert_eq!(fx.read("u.txt").as_deref(), Some("u"));
assert_eq!(fx.thread_count().await?, before);
let _ = &fx.root;
Ok(())
}
@@ -1,600 +0,0 @@
use super::*;
use codewhale_config::catalog::{
CatalogOffering, CatalogSource, CatalogStatus, ProviderCatalogDelta, base_url_fingerprint,
now_unix,
};
use codewhale_engine::cost_status::EffectiveRouteEnvelope;
use codewhale_engine::model_inventory::ModelInventory;
use codewhale_runtime::config::ProviderIdentity;
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
const SINGULAR: &str = "model list / model resolve";
const PLURAL: &str = "models";
const RUNTIME: &str = "runtime api";
const ROUTING: &str = "routing inventory";
const COST: &str = "cost";
const UNKNOWN: &str = "unknown";
const ROSTER: &[&str] = &["claude-opus-5", "claude-haiku-4-5", "claude-parity-probe-1"];
type Readings = BTreeMap<(String, String, &'static str), BTreeMap<&'static str, String>>;
struct Route {
label: &'static str,
api_path: &'static str,
identity: ProviderIdentity,
base_url: String,
models: &'static [&'static str],
}
fn cold_process() {
codewhale_engine::provider_catalog_live::reset_cache_for_test();
codewhale_engine::provider_lake::clear_live_snapshot();
}
fn known<T: ToString>(value: Option<T>) -> String {
value.map_or_else(|| UNKNOWN.to_string(), |value| value.to_string())
}
fn rate(value: &Value) -> String {
let number = match value {
Value::Number(number) => number.as_f64(),
Value::String(text) => text.parse::<f64>().ok(),
_ => None,
};
known(number)
}
fn roster(mut models: Vec<String>) -> String {
models.sort();
models.dedup();
models.join(",")
}
fn read(
readings: &mut Readings,
route: &Route,
model: &str,
field: &'static str,
surface: &'static str,
value: String,
) {
readings
.entry((route.label.to_string(), model.to_string(), field))
.or_default()
.insert(surface, value);
}
fn persist_id_only_roster(identity: &ProviderIdentity, base_url: &str) {
let fingerprint = base_url_fingerprint(base_url);
let fetched_at = now_unix();
let ticket = codewhale_engine::provider_catalog_live::begin_refresh_for_identity(
identity.provider,
identity.key.as_str(),
base_url,
);
assert_eq!(
codewhale_engine::provider_catalog_live::record_success_if_current(
&ticket,
ProviderCatalogDelta {
provider: identity.key.to_string(),
base_url_fingerprint: fingerprint.clone(),
fetched_at,
offerings: ROSTER
.iter()
.map(|id| CatalogOffering {
provider: identity.key.to_string(),
wire_model_id: (*id).to_string(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: fingerprint.clone(),
fetched_at,
},
..Default::default()
})
.collect(),
},
),
Some(CatalogStatus::Fresh)
);
}
fn read_singular(readings: &mut Readings, config_path: &Path, route: &Route) -> Result<()> {
cold_process();
let store = codewhale_config::ConfigStore::load(Some(config_path.to_path_buf()))?;
let listed =
codewhale_engine::route_preferences::list_models(&store, Some(route.identity.provider))?
.into_iter()
.find(|(listed, _)| listed == route.identity.key.as_str())
.map(|(_, models)| roster(models));
read(
readings,
route,
"*",
"roster",
SINGULAR,
listed.unwrap_or_else(|| "route not listed".into()),
);
for model in route.models {
let limits = codewhale_engine::route_preferences::resolve_model(
config_path,
route.identity.key.as_str(),
&route.base_url,
Some(model),
)
.map(|candidate| candidate.limits());
let (context, output) = match limits {
Ok(limits) => (known(limits.context_tokens), known(limits.output_tokens)),
Err(error) => (format!("refused: {error}"), format!("refused: {error}")),
};
read(readings, route, model, "limit.context", SINGULAR, context);
read(readings, route, model, "limit.output", SINGULAR, output);
}
Ok(())
}
fn read_plural(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
cold_process();
codewhale_engine::models_dev_live::maybe_load_persisted_cache();
let mut route_config = config.clone();
route_config
.scope_to_provider_identity(&route.identity)
.map_err(anyhow::Error::msg)?;
read(
readings,
route,
"*",
"roster",
PLURAL,
roster(codewhale_engine::provider_lake::listed_models_for_route(
&route_config,
&route.identity,
)),
);
Ok(())
}
async fn read_runtime(
readings: &mut Readings,
client: &reqwest::Client,
addr: &SocketAddr,
route: &Route,
) -> Result<()> {
let body: Value = client
.get(format!("http://{addr}/v1/providers/{}", route.api_path))
.send()
.await?
.error_for_status()?
.json()
.await?;
let models = body["models"].as_array().context("models array")?;
read(
readings,
route,
"*",
"roster",
RUNTIME,
roster(
models
.iter()
.filter_map(|model| model["id"].as_str().map(str::to_string))
.collect(),
),
);
for model in route.models {
let facts = models
.iter()
.find(|entry| entry["id"] == *model)
.map_or(&Value::Null, |entry| &entry["facts"]);
read(
readings,
route,
model,
"limit.context",
RUNTIME,
known(facts["effective_limits"]["context_tokens"].as_u64()),
);
read(
readings,
route,
model,
"limit.output",
RUNTIME,
known(facts["effective_limits"]["output_tokens"].as_u64()),
);
read(
readings,
route,
model,
"window",
RUNTIME,
known(facts["effective_context"]["tokens"].as_u64()),
);
read(
readings,
route,
model,
"window.source",
RUNTIME,
known(facts["effective_context"]["source"].as_str()),
);
read(
readings,
route,
model,
"price.input",
RUNTIME,
rate(&facts["pricing"]["input_per_million"]),
);
read(
readings,
route,
model,
"price.output",
RUNTIME,
rate(&facts["pricing"]["output_per_million"]),
);
read(
readings,
route,
model,
"price.basis",
RUNTIME,
format!(
"provenance={} status={} source={}",
facts["pricing"]["provenance"], facts["pricing_status"], facts["source"]
),
);
}
Ok(())
}
fn read_routing(readings: &mut Readings, config: &Config, route: &Route) -> Result<()> {
let inventory = ModelInventory::from_config(config).map_err(anyhow::Error::msg)?;
let candidates: Vec<_> = inventory
.candidates
.iter()
.filter(|candidate| candidate.identity == route.identity)
.collect();
read(
readings,
route,
"*",
"roster",
ROUTING,
roster(
candidates
.iter()
.map(|candidate| candidate.model.clone())
.collect(),
),
);
for model in route.models {
let candidate = candidates
.iter()
.find(|candidate| candidate.model == *model);
read(
readings,
route,
model,
"window",
ROUTING,
known(candidate.map(|candidate| candidate.context_window)),
);
read(
readings,
route,
model,
"window.source",
ROUTING,
known(candidate.map(|candidate| {
if candidate.context_window_unverified {
"unverified"
} else {
"verified"
}
})),
);
read(
readings,
route,
model,
"limit.output",
ROUTING,
known(candidate.and_then(|candidate| candidate.max_output)),
);
}
Ok(())
}
fn read_cost(readings: &mut Readings, config: &Config, route: &Route) {
let usage = Usage {
input_tokens: 100_000,
output_tokens: 10_000,
..Default::default()
};
for model in route.models {
let envelope = EffectiveRouteEnvelope::from_admitted(
Some(config),
&route.identity,
*model,
Some(&route.base_url),
chrono::Utc::now(),
);
let quote = envelope.provider_live_pricing.as_ref();
let audit = envelope.audit(&usage);
read(
readings,
route,
model,
"price.input",
COST,
quote
.and_then(|quote| quote.input_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.output",
COST,
quote
.and_then(|quote| quote.output_per_million.as_deref())
.map_or_else(|| UNKNOWN.to_string(), |text| rate(&json!(text))),
);
read(
readings,
route,
model,
"price.basis",
COST,
format!(
"provenance={:?} billing={:?} charge_usd={} unpriced={:?}",
quote.map(|quote| quote.provenance.clone()),
envelope.billing_mode,
known(audit.exact_usd.as_deref()),
audit.unpriced_reason,
),
);
}
}
#[tokio::test]
async fn one_captured_catalog_reads_the_same_on_every_model_surface() -> Result<()> {
let _env = lock_test_env();
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
let root = tempfile::tempdir()?;
let captured = codewhale_config::catalog::BUNDLED_MODELS_DEV_JSON;
let catalog_path = root.path().join("captured-models-dev.json");
fs::write(&catalog_path, captured)?;
let _catalog = EnvVarGuard::set("CODEWHALE_MODELS_DEV_PATH", &catalog_path);
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
let _cli = EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
cold_process();
let config_path = root.path().join("config.toml");
fs::write(
&config_path,
r#"provider = "anthropic"
[providers.anthropic]
api_key = "parity-fixture-key"
model = "claude-opus-5"
[providers.zai]
api_key = "parity-fixture-key"
model = "GLM-5.3"
[providers.lab]
kind = "openai-compatible"
base_url = "http://127.0.0.1:9/v1"
api_key = "parity-fixture-key"
model = "claude-haiku-4-5"
"#,
)?;
let config = Config::load(Some(config_path.clone()), None)?;
let route = |label: &'static str,
api_path: &'static str,
key: &str,
models: &'static [&'static str]|
-> Result<Route> {
let identity = config
.resolve_provider_pin_identity(key)
.map_err(anyhow::Error::msg)?;
Ok(Route {
label,
api_path,
base_url: config.base_url_for_route(&identity),
identity,
models,
})
};
let routes = [
route(
"anthropic (official, id-only roster)",
"anthropic/models",
"anthropic",
ROSTER,
)?,
route(
"lab (custom endpoint, same spelling)",
"custom/models?model_provider_id=lab",
"lab",
&["claude-haiku-4-5"],
)?,
route(
"zai (plan endpoint)",
"zai/models",
"zai",
&["GLM-5.3", "GLM-5.2"],
)?,
];
assert!(
codewhale_engine::models_dev_live::refresh(false)
.await
.map_err(|error| anyhow::anyhow!("{error}"))?
> 0
);
persist_id_only_roster(&routes[0].identity, &routes[0].base_url);
let mut readings = Readings::new();
for route in &routes {
read_singular(&mut readings, &config_path, route)?;
read_plural(&mut readings, &config, route)?;
}
cold_process();
let (addr, _, handle) = spawn_test_server_with_config_path(config_path.clone())
.await?
.expect("loopback server required");
let client = codewhale_release::tls::reqwest_client();
for route in &routes {
read_runtime(&mut readings, &client, &addr, route).await?;
read_routing(&mut readings, &config, route)?;
read_cost(&mut readings, &config, route);
}
handle.abort();
cold_process();
println!(
"PARITY captured_catalog sha256={} bytes={}",
Sha256::digest(captured.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
captured.len()
);
let mut differing = Vec::new();
let mut known_differences: Vec<String> = Vec::new();
for ((route, model, field), surfaces) in &readings {
let mut values: Vec<String> = surfaces
.values()
.map(|value| {
if *field == "roster" {
roster(
value
.to_ascii_lowercase()
.split(',')
.map(str::to_string)
.collect(),
)
} else {
value.clone()
}
})
.collect();
values.sort();
values.dedup();
let compared = !matches!(*field, "price.basis" | "window.source");
let borrowed_window = *field == "window"
&& surfaces.get(RUNTIME).map(String::as_str) == Some(UNKNOWN)
&& readings
.get(&(route.clone(), model.clone(), "window.source"))
.and_then(|sources| sources.get(ROUTING))
.map(String::as_str)
== Some("unverified");
let verdict = if !compared {
"info"
} else if borrowed_window {
known_differences.push(format!("{route} | {model} | {surfaces:?}"));
"KNOWN-DIFFERENCE"
} else if values.len() == 1 {
"agree"
} else {
differing.push(format!("{route} | {model} | {field} | {surfaces:?}"));
"DIFFER"
};
println!("PARITY {verdict} | {route} | {model} | {field} | {surfaces:?}");
}
let value = |route: &Route, model: &str, field: &'static str, surface: &'static str| {
readings
.get(&(route.label.to_string(), model.to_string(), field))
.and_then(|surfaces| surfaces.get(surface))
.cloned()
.unwrap_or_default()
};
assert_eq!(
value(&routes[0], "*", "roster", SINGULAR),
"claude-haiku-4-5,claude-opus-5,claude-parity-probe-1",
"the singular command lists the route's own roster, not every bundled row"
);
assert_eq!(
value(&routes[0], "claude-opus-5", "limit.context", RUNTIME),
"1000000"
);
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "limit.context", RUNTIME),
"200000"
);
assert_eq!(value(&routes[0], "claude-opus-5", "price.input", COST), "5");
assert_eq!(
value(&routes[0], "claude-haiku-4-5", "price.output", COST),
"5"
);
for field in ["limit.context", "limit.output", "price.input"] {
assert_eq!(
value(&routes[1], "claude-haiku-4-5", field, RUNTIME),
UNKNOWN,
"a custom endpoint borrows no fact from a same-named model: {field}"
);
}
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "window.source", ROUTING),
"unverified",
"routing never presents a same-named model's window as this endpoint's"
);
assert_eq!(
value(&routes[1], "claude-haiku-4-5", "limit.output", ROUTING),
UNKNOWN
);
for model in ["GLM-5.3", "GLM-5.2"] {
let basis = value(&routes[2], model, "price.basis", COST);
assert!(
basis.contains("NotMoneyMetered") && basis.contains("charge_usd=unknown"),
"a plan route is never billed per token: {model}: {basis}"
);
}
for (model, rate) in [("GLM-5.3", UNKNOWN), ("GLM-5.2", "1.4")] {
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[2], model, "price.input", surface),
rate,
"the list rate is shown the same everywhere and never turned into a charge: {model} {surface}"
);
}
}
let basis = value(&routes[1], "claude-haiku-4-5", "price.basis", COST);
assert!(
basis.contains("UnknownBillingBasis") && basis.contains("charge_usd=unknown"),
"a custom endpoint is never given a charge: {basis}"
);
for surface in [COST, RUNTIME] {
assert_eq!(
value(&routes[0], "claude-parity-probe-1", "price.input", surface),
UNKNOWN,
"a metered first-party model with no price row reads unknown: {surface}"
);
}
let basis = value(&routes[0], "claude-parity-probe-1", "price.basis", COST);
assert!(
basis.contains("charge_usd=unknown") && !basis.contains("unpriced=None"),
"an unpriced model produces no charge and says why: {basis}"
);
assert_eq!(
known_differences,
vec![
"lab (custom endpoint, same spelling) | claude-haiku-4-5 | {\"routing inventory\": \"200000\", \"runtime api\": \"unknown\"}"
],
"the only named difference is the custom endpoint's borrowed routing window"
);
assert!(
differing.is_empty(),
"surfaces disagree on the same captured catalog:\n{}",
differing.join("\n")
);
Ok(())
}
@@ -28,8 +28,6 @@ fn user(name: &str) -> UserCommand {
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: None,
workspace_sourced: false,
builtin_bindings: vec![name.to_string()],
}
}
@@ -830,149 +828,3 @@ async fn turn_detail(
.and_then(|turns| turns.iter().find(|turn| turn["id"] == turn_id))
.map_or_else(|| "turn absent".to_string(), Value::to_string))
}
// ── Remote built-in execution ──
/// A catalog that serves exactly one built-in remotely, so the route's name
/// gate and snapshot hand-off can be observed without the terminal host.
struct RemoteBuiltinCatalog;
impl CommandCatalog for RemoteBuiltinCatalog {
fn answers_to(&self, name: &str) -> bool {
name == "system"
}
fn builtins(&self) -> Vec<BuiltinCommand> {
vec![builtin("system", vec!["xitong".to_string()])]
}
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
Vec::new()
}
fn serves_builtin(&self, name: &str) -> bool {
matches!(name.trim_start_matches('/'), "system" | "xitong")
}
fn execute_builtin(
&self,
name: &str,
_args: Option<&str>,
host: &codewhale_engine::command_catalog::BuiltinCommandHostSnapshot,
) -> Option<codewhale_engine::command_catalog::BuiltinCommandExecution> {
if !self.serves_builtin(name) {
return None;
}
Some(codewhale_engine::command_catalog::BuiltinCommandExecution {
name: "system".to_string(),
text: format!(
"{}|{}|{}",
host.mode_label,
host.model,
match &host.system_prompt {
codewhale_command_contract::facets::DebugSystemPrompt::None => 0,
codewhale_command_contract::facets::DebugSystemPrompt::Text(text) => text.len(),
codewhale_command_contract::facets::DebugSystemPrompt::Blocks(blocks) =>
blocks.iter().map(String::len).sum(),
}
),
is_error: false,
text_only: true,
})
}
}
/// B5: `POST /v1/threads/{id}/commands/builtin` executes only the names the
/// installed host serves, and answers everything else like an unknown name.
#[tokio::test]
async fn post_thread_builtin_command_serves_only_the_host_allowlist() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let token = "builtin-route-fixture-token";
let root = temp.path().join("builtin-commands-route");
let sessions_dir = root.join("sessions");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let _catalog =
codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(RemoteBuiltinCatalog));
let Some((addr, runtime_threads, handle)) = spawn_test_server_with_root_token_mobile_workspace(
root,
sessions_dir,
Some(token.to_string()),
false,
workspace,
)
.await?
else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let thread = runtime_threads
.create_thread(CreateThreadRequest::default())
.await?;
let url = format!("http://{addr}/v1/threads/{}/commands/builtin", thread.id);
let unauthorized = client
.post(&url)
.json(&json!({ "name": "system" }))
.send()
.await?;
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let cold = client
.post(&url)
.bearer_auth(token)
.json(&json!({ "name": "system" }))
.send()
.await?;
assert_eq!(cold.status(), StatusCode::CONFLICT, "cold engine");
runtime_threads.get_engine(&thread.id).await?;
// Not on the host's remote surface: the same answer an unknown name gets.
for name in ["balance", "cache", "not-a-command"] {
let response = client
.post(&url)
.bearer_auth(token)
.json(&json!({ "name": name }))
.send()
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{name}");
}
// A served name (alias spelling included) reaches the host with the
// thread's own state, and its text comes back unchanged.
for name in ["system", "/system", "xitong"] {
let body: Value = client
.post(&url)
.bearer_auth(token)
.json(&json!({ "name": name, "args": "ignored" }))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(body["name"], "system");
assert_eq!(body["is_error"], false);
assert_eq!(body["text_only"], true);
// One snapshot field per segment, so a route that assembled the
// wrong thread's state cannot pass.
let text = body["text"].as_str().context("builtin text")?;
let fields: Vec<&str> = text.split('|').collect();
assert_eq!(fields.len(), 3, "{text}");
assert!(!fields[0].is_empty(), "mode label: {text}");
assert_eq!(fields[1], thread.model, "model: {text}");
let prompt_len: usize = fields[2].parse().context("system prompt length")?;
assert!(prompt_len > 0, "effective system prompt reported: {text}");
}
// The thread id is still the address: a missing thread stays 404.
let missing = client
.post(format!(
"http://{addr}/v1/threads/thr_missing/commands/builtin"
))
.bearer_auth(token)
.json(&json!({ "name": "system" }))
.send()
.await?;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
handle.abort();
Ok(())
}
@@ -1,82 +0,0 @@
use super::*;
use codewhale_engine::conversation_modes::CreatorAvailabilityGuard;
#[tokio::test]
async fn modes_route_lists_the_table_and_threads_carry_and_refuse_modes() -> Result<()> {
let _unavailable = CreatorAvailabilityGuard::unforced();
let Some((addr, _threads, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}");
let table: Value = client
.get(format!("{base}/v1/modes"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(table["default"], "standard");
assert_eq!(table["modes"][0]["id"], "standard");
assert_eq!(table["modes"][0]["source"], "builtin");
assert_eq!(table["modes"][0]["available"], true);
assert!(table["modes"][0].get("reason").is_none());
assert_eq!(table["modes"][1]["id"], "creator");
assert_eq!(table["modes"][1]["available"], false);
assert_eq!(
table["modes"][1]["reason"],
codewhale_engine::conversation_modes::CREATOR_NOT_READY_REASON
);
let unavailable = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "creator"}))
.send()
.await?;
assert_eq!(unavailable.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
let body: Value = unavailable.json().await?;
assert_eq!(body["error"]["code"], "mode_unavailable");
let unknown = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "minimal"}))
.send()
.await?;
assert_eq!(unknown.status(), reqwest::StatusCode::BAD_REQUEST);
let body: Value = unknown.json().await?;
assert_eq!(body["error"]["code"], "unknown_mode");
let created: Value = client
.post(format!("{base}/v1/threads"))
.json(&json!({"agent_preset": "standard"}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = created["id"].as_str().expect("thread id").to_string();
let summary: Value = client
.get(format!("{base}/v1/threads/summary"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let row = summary
.as_array()
.and_then(|rows| rows.iter().find(|row| row["id"] == id.as_str()))
.expect("summary row");
assert_eq!(row["agent_preset"], "standard");
let patched = client
.patch(format!("{base}/v1/threads/{id}"))
.json(&json!({"agent_preset": "creator"}))
.send()
.await?;
assert_eq!(patched.status(), reqwest::StatusCode::FAILED_DEPENDENCY);
handle.abort();
let _ = handle.await;
Ok(())
}
@@ -1,184 +0,0 @@
use super::*;
use reqwest::StatusCode as HttpStatus;
fn options_fixture() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../engine/tests/fixtures/acp-options-agent.mjs")
.canonicalize()
.expect("the options fixture agent")
}
fn with_query(base: &str, pairs: &[(&str, &str)]) -> reqwest::Url {
let mut url = reqwest::Url::parse(base).expect("a url");
for (key, value) in pairs {
url.query_pairs_mut().append_pair(key, value);
}
url
}
fn received(workspace: &Path) -> Vec<Value> {
fs::read_to_string(workspace.join("options-received.jsonl"))
.unwrap_or_default()
.lines()
.filter_map(|line| serde_json::from_str(line).ok())
.collect()
}
async fn wait_for_usage_total(
manager: &SharedRuntimeThreadManager,
thread_id: &str,
) -> Result<RuntimeEventRecord> {
for _ in 0..600 {
let events = manager.events_since_async(thread_id, None).await?;
if let Some(event) = events.into_iter().find(|event| {
event.event == "turn.agent_usage" && event.payload["kind"] == "turn_total"
}) {
return Ok(event);
}
sleep(Duration::from_millis(50)).await;
}
bail!("the agent's usage was never recorded")
}
#[tokio::test]
async fn the_options_route_offers_the_agents_choices_and_a_chosen_one_reaches_it() -> Result<()> {
let _env = lock_test_env();
let _fixture = EnvVarGuard::set("CODEWHALE_ACP_FIXTURE_AGENT", options_fixture());
let Some((addr, manager, handle)) = spawn_test_server().await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let workspace = tempfile::tempdir()?;
let cwd = workspace.path().canonicalize()?;
let url = format!("http://{addr}/v1/agents/external/fixture/options");
let offered: Value = client
.get(with_query(&url, &[("cwd", cwd.to_str().unwrap())]))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(offered["agent"]["id"], "fixture");
let controls = offered["controls"].as_array().unwrap();
let model = controls.iter().find(|c| c["id"] == "model").unwrap();
let values: Vec<&str> = model["choices"]
.as_array()
.unwrap()
.iter()
.map(|c| c["value"].as_str().unwrap())
.collect();
assert!(values.len() >= 2, "{values:?}");
assert!(controls.iter().any(|c| c["id"] == "mode"));
assert!(
controls.iter().any(|c| c["id"] == "config:verbose"
&& c["unavailable"]["code"] == "control_type_unsupported")
);
assert!(
offered["actions"]
.as_array()
.unwrap()
.iter()
.any(|a| a["action"] == "steering" && a["supported"] == false)
);
let leaked = fs::read_to_string(cwd.join("options-env.json"))?;
assert!(!leaked.contains("CODEWHALE_ACP_FIXTURE_AGENT"));
for (query, status, code) in [
(vec![], HttpStatus::BAD_REQUEST, None),
(vec![("cwd", "relative/dir")], HttpStatus::BAD_REQUEST, None),
(
vec![("cwd", "/definitely/not/here")],
HttpStatus::NOT_FOUND,
None,
),
] {
let response = client.get(with_query(&url, &query)).send().await?;
assert_eq!(response.status(), status, "{query:?}");
let body: Value = response.json().await?;
assert_eq!(body["error"]["code"].as_str(), code, "{body}");
}
let unknown = client
.get(with_query(
&format!("http://{addr}/v1/agents/external/nope/options"),
&[("cwd", cwd.to_str().unwrap())],
))
.send()
.await?;
assert_eq!(unknown.status(), HttpStatus::NOT_FOUND);
let body: Value = unknown.json().await?;
assert_eq!(body["error"]["code"], "unknown_agent");
let bad_id = client
.get(with_query(
&format!("http://{addr}/v1/agents/external/..%2Fsh/options"),
&[("cwd", cwd.to_str().unwrap())],
))
.send()
.await?;
assert!(bad_id.status().is_client_error());
let chosen = values[1];
let created: Value = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"workspace": cwd,
"permission_posture": "ask",
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": chosen}}
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let thread_id = created["id"].as_str().unwrap().to_string();
let refused = client
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
.json(&json!({"prompt": "x", "model": "other"}))
.send()
.await?;
assert_eq!(refused.status(), HttpStatus::FAILED_DEPENDENCY);
let body: Value = refused.json().await?;
assert_eq!(body["error"]["code"], "unsupported_control");
client
.post(format!("http://{addr}/v1/threads/{thread_id}/turns"))
.json(&json!({"prompt": "hello"}))
.send()
.await?
.error_for_status()?;
let total = wait_for_usage_total(&manager, &thread_id).await?;
assert_eq!(total.payload["source"], "agent reported");
assert_eq!(total.payload["cost"], "unknown");
assert_eq!(total.payload["tokens"]["total_tokens"], 150);
let log = received(&cwd);
let prompt = log
.iter()
.find(|e| e["method"] == "session/prompt")
.unwrap();
assert_eq!(prompt["state"]["model"], chosen);
let stale = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"workspace": cwd,
"executor": {"kind": "acp", "agent_id": "fixture", "config": {"model": "model-z"}}
}))
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
let stale_id = stale["id"].as_str().unwrap();
let response = client
.post(format!("http://{addr}/v1/threads/{stale_id}/turns"))
.json(&json!({"prompt": "x"}))
.send()
.await?;
assert_eq!(response.status(), HttpStatus::FAILED_DEPENDENCY);
let body: Value = response.json().await?;
assert_eq!(body["error"]["code"], "choice_not_offered");
handle.abort();
Ok(())
}
@@ -1,202 +0,0 @@
use super::*;
use reqwest::StatusCode as HttpStatus;
use std::collections::BTreeMap;
const FIXTURE_TOKEN: &str = "cw-fixture-token-7993-do-not-echo";
fn reasons(body: &Value) -> BTreeMap<String, String> {
body["agents"]
.as_array()
.expect("agents array")
.iter()
.map(|entry| {
assert!(
entry["quota"].get("source").is_none(),
"no source without a reader: {entry}"
);
assert_eq!(entry["quota"]["status"], "unknown", "{entry}");
(
entry["id"].as_str().expect("id").to_string(),
entry["quota"]["reason"]
.as_str()
.expect("reason")
.to_string(),
)
})
.collect()
}
fn snapshot(paths: &[PathBuf]) -> Vec<(Vec<u8>, std::time::SystemTime)> {
paths
.iter()
.map(|path| {
(
fs::read(path).expect("fixture credential"),
fs::metadata(path)
.and_then(|meta| meta.modified())
.expect("mtime"),
)
})
.collect()
}
#[tokio::test]
async fn agent_quota_reads_nothing_until_flag_and_consent_and_never_echoes_a_token() -> Result<()> {
let _env = lock_test_env();
let root = tempfile::tempdir()?;
let home = root.path().join("home");
let mut credentials = Vec::new();
for (dir, file) in [
(".claude", ".credentials.json"),
(".codex", "auth.json"),
(".gemini", "oauth_creds.json"),
(".kimi", "credentials.json"),
(".grok", "auth.json"),
] {
let dir = home.join(dir);
fs::create_dir_all(&dir)?;
let path = dir.join(file);
fs::write(
&path,
format!(r#"{{"access_token":"{FIXTURE_TOKEN}","refresh_token":"{FIXTURE_TOKEN}"}}"#),
)?;
credentials.push(path);
}
let before = snapshot(&credentials);
let config_file = root.path().join("config").join("config.toml");
fs::create_dir_all(config_file.parent().unwrap())?;
fs::write(
&config_file,
"provider = \"deepseek\"\napi_key = \"runtime-api-test-key\"\nbase_url = \"http://127.0.0.1:1/v1\"\n",
)?;
let _home = EnvVarGuard::set("HOME", &home);
let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_file);
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path(config_file.clone()).await?
else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let mut seen = String::new();
let read = || {
let client = client.clone();
let url = format!("http://{addr}/v1/agents/external/quota");
async move {
let text = client
.get(url)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok::<String, anyhow::Error>(text)
}
};
let set = |key: &'static str, value: &'static str| {
let client = client.clone();
let url = format!("http://{addr}/v1/config");
async move {
let response = client
.post(url)
.json(&json!({"key": key, "value": value, "persist": true}))
.send()
.await?;
let status = response.status();
Ok::<(HttpStatus, String), anyhow::Error>((status, response.text().await?))
}
};
let text = read().await?;
seen.push_str(&text);
let body: Value = serde_json::from_str(&text)?;
assert_eq!(body["default_source"], "agent_reported");
let initial = reasons(&body);
assert_eq!(initial.len(), codewhale_engine::fleet::agents::AGENTS.len());
assert!(
initial.values().all(|reason| reason == "flag_off"),
"{initial:?}"
);
for (key, value) in [
("agents.quota.claude", "on"),
("agents.quota.codex.consent", "granted"),
] {
let (status, text) = set(key, value).await?;
seen.push_str(&text);
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
}
let text = read().await?;
seen.push_str(&text);
let gated = reasons(&serde_json::from_str(&text)?);
assert_eq!(gated["claude"], "consent_missing");
assert_eq!(gated["codex"], "flag_off");
assert_eq!(gated["grok"], "flag_off");
for (key, value) in [
("agents.quota.claude.consent", "granted"),
("agents.quota.codex", "true"),
] {
let (status, text) = set(key, value).await?;
seen.push_str(&text);
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
}
let saved = codewhale_runtime::settings::Settings::load_persisted()?;
assert!(saved.agents.quota("claude").enabled);
assert!(saved.agents.quota("claude").consented_at.is_some());
let text = read().await?;
seen.push_str(&text);
let consented = reasons(&serde_json::from_str(&text)?);
assert_eq!(consented["claude"], "not_implemented");
assert_eq!(consented["codex"], "not_implemented");
assert_eq!(consented["opencode"], "flag_off");
for (key, value) in [
("agents.quota.claude.consent", "revoked"),
("agents.quota.codex", "off"),
] {
let (status, text) = set(key, value).await?;
seen.push_str(&text);
assert_eq!(status, HttpStatus::OK, "{key}: {text}");
}
let text = read().await?;
seen.push_str(&text);
let revoked = reasons(&serde_json::from_str(&text)?);
assert_eq!(revoked["claude"], "consent_missing");
assert_eq!(revoked["codex"], "flag_off");
for (key, value) in [
("agents.quota.claude.consent", FIXTURE_TOKEN),
("agents.quota.Claude/../x", "on"),
("agents.quota.claude", FIXTURE_TOKEN),
] {
let (status, text) = set(key, value).await?;
assert_eq!(status, HttpStatus::BAD_REQUEST, "{key}: {text}");
seen.push_str(&text);
}
let (status, text) = set("agents.quota.not-an-agent", "on").await?;
seen.push_str(&text);
assert_eq!(status, HttpStatus::OK, "{text}");
let text = read().await?;
seen.push_str(&text);
assert_eq!(
reasons(&serde_json::from_str(&text)?)["not-an-agent"],
"unknown_agent"
);
let settings_file = fs::read_to_string(config_file.with_file_name("settings.toml"))?;
assert!(
settings_file.contains("[agents.quota.claude]"),
"{settings_file}"
);
assert!(!settings_file.contains(FIXTURE_TOKEN));
assert!(
!seen.contains(FIXTURE_TOKEN),
"a response echoed the fixture token"
);
assert_eq!(snapshot(&credentials), before, "a credential file changed");
handle.abort();
Ok(())
}
@@ -1,285 +0,0 @@
use super::*;
use codewhale_engine::llm_client::mock::MockLlmClient;
use codewhale_engine::runtime_threads::job_operations::{
MEMBER_RESERVED, MemberOperation, MemberOperationRecord,
};
struct AbortOnDrop(tokio::task::AbortHandle);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
fn fleet() -> Value {
json!({
"schema": "fleet",
"schema_revision": 2,
"name": "Review team",
"members": [
{ "id": "reviewer", "role": "reviewer", "provider": "deepseek", "model": "deepseek-v4-pro" }
]
})
}
fn start_body(key: &str, prompt: &str) -> Value {
json!({
"operation_key": key,
"member_id": "reviewer",
"display_name": "Reviewer",
"prompt": prompt,
"fleet": fleet(),
})
}
fn reserve(
threads: &SharedRuntimeThreadManager,
thread_id: &str,
key: &str,
generation: &str,
) -> Result<()> {
threads
.job_operation_store()
.with_job_operations(|transaction| {
let now = chrono::Utc::now().timestamp_millis();
transaction.save_member(&MemberOperationRecord {
schema_version: 1,
operation: MemberOperation {
id: transaction.member_operation_id(thread_id, key)?,
store_id: threads.job_operation_store_id().to_string(),
thread_id: thread_id.to_string(),
member_id: "reviewer".to_string(),
operation_key: key.to_string(),
agent_id: "agent_reservedfixture".to_string(),
state: MEMBER_RESERVED.to_string(),
created_at_ms: now,
updated_at_ms: now,
},
request_fingerprint: "a".repeat(64),
owner_generation: generation.to_string(),
})
})
}
#[tokio::test]
async fn member_starts_are_durable_replayable_and_refuse_foreign_or_mismatched_replay() -> Result<()>
{
let _env = lock_test_env();
let temporary = tempfile::tempdir()?;
let root = temporary.path().canonicalize()?;
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.join("home"));
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let config = Config::default().with_legacy_root(
Some("member-fixture-key".into()),
Some("http://127.0.0.1:1/v1".into()),
);
let (addr, threads, server) = spawn_test_server_with_root_token_mobile_workspace_and_overrides(
root.clone(),
root.join("sessions"),
None,
false,
workspace.clone(),
TestServerOverrides {
config: Some(config),
..TestServerOverrides::default()
},
)
.await?
.context("the member fixture needs a loopback listener")?;
let _server = AbortOnDrop(server.abort_handle());
threads.set_test_model_client(Arc::new(MockLlmClient::new(Vec::new())));
let client = codewhale_release::tls::reqwest_client();
let url = |path: &str| format!("http://{addr}{path}");
let create_thread = || async {
let created: Value = client
.post(url("/v1/threads"))
.json(&json!({ "model": "deepseek-v4-pro", "mode": "agent", "workspace": workspace }))
.send()
.await?
.error_for_status()?
.json()
.await?;
anyhow::Ok(created["id"].as_str().context("thread id")?.to_string())
};
let thread = create_thread().await?;
let other = create_thread().await?;
let info: Value = client
.get(url("/v1/runtime/info"))
.send()
.await?
.json()
.await?;
assert_eq!(
info["capabilities"]["thread_fleet_member_durable_operations_v1"],
true
);
let store_id = threads.job_operation_store_id().to_string();
let members = url(&format!("/v1/threads/{thread}/members"));
let first = client
.post(&members)
.json(&start_body("team-start-1", "Review the diff"))
.send()
.await?;
let status = first.status();
let receipt: Value = first.json().await?;
assert_eq!(status, StatusCode::OK, "{receipt}");
assert_eq!(receipt["state"], "accepted");
assert_eq!(receipt["runtime_store_id"], store_id.as_str());
assert_eq!(receipt["operation_key"], "team-start-1");
let agent_id = receipt["agent_id"]
.as_str()
.context("agent id")?
.to_string();
let runs = member_runs(&client, addr, &thread).await?;
assert_eq!(runs.len(), 1, "{runs:?}");
assert_eq!(
runs[0]["spec"]["worker_id"],
agent_id.as_str(),
"the receipt names the worker it started"
);
let replayed: Value = client
.post(&members)
.json(&start_body("team-start-1", "Review the diff"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(
replayed, receipt,
"exact replay returns the original receipt"
);
let read: Value = client
.get(url(&format!(
"/v1/threads/{thread}/members/operations/team-start-1"
)))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(read, receipt);
let mismatched = client
.post(&members)
.json(&start_body("team-start-1", "Review something else"))
.send()
.await?;
assert_eq!(mismatched.status(), StatusCode::CONFLICT);
let body: Value = mismatched.json().await?;
assert_eq!(body["error"]["code"], "member_operation_mismatch");
let foreign = client
.get(url(&format!(
"/v1/threads/{other}/members/operations/team-start-1"
)))
.send()
.await?;
assert_eq!(
foreign.status(),
StatusCode::NOT_FOUND,
"a key never crosses threads"
);
let mut ghost = start_body("team-ghost", "Review the diff");
ghost["member_id"] = json!("ghost");
let refused = client.post(&members).json(&ghost).send().await?;
assert_eq!(refused.status(), StatusCode::CONFLICT);
let withdrawn = client
.get(url(&format!(
"/v1/threads/{thread}/members/operations/team-ghost"
)))
.send()
.await?;
assert_eq!(
withdrawn.status(),
StatusCode::NOT_FOUND,
"a start the Engine refused withdraws its reservation"
);
let mut stale = start_body("team-start-2", "Review the diff");
stale["expected_store_id"] = json!("another-store");
let stale = client.post(&members).json(&stale).send().await?;
assert_eq!(stale.status(), StatusCode::CONFLICT);
let body: Value = stale.json().await?;
assert_eq!(body["error"]["code"], "member_operation_store_changed");
let mut forged = start_body("team-start-3", "Review the diff");
forged["request_fingerprint"] = json!("b".repeat(64));
let forged = client.post(&members).json(&forged).send().await?;
assert_eq!(forged.status(), StatusCode::BAD_REQUEST);
let generation = threads.job_operation_generation()?;
reserve(&threads, &other, "in-flight", &generation)?;
let deleted = threads.delete_thread(&other).await;
assert!(
deleted
.as_ref()
.is_err_and(|error| error.to_string().contains("pending member operation")),
"a thread with a start in flight is not deleted: {deleted:?}"
);
reserve(
&threads,
&other,
"before-restart",
&Uuid::new_v4().to_string(),
)?;
let abandoned: Value = client
.get(url(&format!(
"/v1/threads/{other}/members/operations/before-restart"
)))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(
abandoned["state"], "abandoned",
"a reservation an earlier Engine never started is settled, not left unknown"
);
let cancelled = client
.post(url(&format!("/v1/agent-runs/{agent_id}/cancel")))
.send()
.await?;
assert!(cancelled.status().is_success(), "{}", cancelled.status());
let after_cancel: Value = client
.post(&members)
.json(&start_body("team-start-1", "Review the diff"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(
after_cancel["agent_id"], agent_id,
"owner cancellation never makes a replay start a second worker"
);
assert_eq!(member_runs(&client, addr, &thread).await?.len(), 1);
Ok(())
}
async fn member_runs(
client: &reqwest::Client,
addr: SocketAddr,
thread: &str,
) -> Result<Vec<Value>> {
let listed: Value = client
.get(format!("http://{addr}/v1/agent-runs"))
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(listed["runs"]
.as_array()
.context("runs")?
.iter()
.filter(|run| run["owner_session_id"] == thread)
.cloned()
.collect())
}
@@ -1,219 +0,0 @@
use super::*;
use codewhale_engine::command_catalog::{BuiltinCommand, CommandCatalog, UserCommand};
struct Catalog(Arc<std::sync::Mutex<Option<String>>>);
impl CommandCatalog for Catalog {
fn answers_to(&self, _name: &str) -> bool {
false
}
fn builtins(&self) -> Vec<BuiltinCommand> {
Vec::new()
}
fn user_commands(&self, _workspace: &Path) -> Vec<UserCommand> {
let plugin_id = self.0.lock().expect("plugin id").clone();
let command = |name: &str, plugin: Option<String>| UserCommand {
name: name.to_string(),
aliases: Vec::new(),
description: Some("A command.".to_string()),
usage: None,
takes_arguments: false,
hidden: false,
extension: false,
plugin_id: plugin,
workspace_sourced: false,
builtin_bindings: Vec::new(),
};
vec![command("mine", None), command("ship", plugin_id)]
}
}
#[tokio::test]
async fn the_index_lists_every_kind_once_and_a_plugin_carries_what_it_adds() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("index-route");
let workspace = root.join("workspace");
fs::create_dir_all(&workspace)?;
let plugin_id = Arc::new(std::sync::Mutex::new(None));
let _catalog = codewhale_engine::command_catalog::CatalogGuard::install(Arc::new(Catalog(
plugin_id.clone(),
)));
let sources = root.join("sources");
fs::create_dir_all(&sources)?;
let bundle = write_plugin_source_bundle(&sources, "indexed")?;
fs::create_dir_all(bundle.join("commands"))?;
fs::write(bundle.join("commands/ship.md"), "Ship it.\n")?;
fs::create_dir_all(bundle.join("agents"))?;
fs::write(bundle.join("agents/reviewer.toml"), "name = \"reviewer\"\n")?;
fs::write(
bundle.join("plugin.toml"),
"schema_version = 1\n[plugin]\nname = \"indexed\"\nversion = \"1.0.0\"\n\
description = \"test bundle\"\n\n[skills]\npath = \"skills\"\n\
[commands]\npath = \"commands\"\n[agents]\npath = \"agents\"\n",
)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
plugin_ui::install_active(
&client,
&format!("http://{addr}/v1/apps/plugins"),
bundle.display().to_string(),
)
.await?;
let listed: Value = client
.get(format!("http://{addr}/v1/apps/plugins"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let id = listed["plugins"]
.as_array()
.and_then(|plugins| plugins.iter().find(|plugin| plugin["name"] == "indexed"))
.and_then(|plugin| plugin["id"].as_str())
.expect("installed plugin id")
.to_string();
*plugin_id.lock().expect("plugin id") = Some(id.clone());
let commands: Value = client
.get(format!("http://{addr}/v1/commands"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let ship = commands["commands"]
.as_array()
.and_then(|rows| rows.iter().find(|row| row["name"] == "ship"))
.expect("ship row");
assert_eq!(ship["plugin_id"], id.as_str());
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let items = index["items"].as_array().expect("items");
let find = |id: &str| items.iter().find(|item| item["id"] == id);
let plugin = items
.iter()
.find(|item| item["kind"] == "plugin" && item["row"]["name"] == "indexed")
.unwrap_or_else(|| panic!("plugin row in {index}"));
assert_eq!(plugin["kind"], "plugin");
assert_eq!(plugin["state"], "enabled");
assert_eq!(plugin["adds"]["skills"], serde_json::json!(["greet"]));
assert_eq!(plugin["adds"]["commands"], serde_json::json!(["ship"]));
assert_eq!(plugin["adds"]["profiles"], serde_json::json!(["reviewer"]));
assert_eq!(
plugin["labels"],
serde_json::json!(["skill", "command", "profile"])
);
assert_eq!(plugin["row"]["name"], "indexed");
assert!(find("skill:greet").is_none());
assert!(find("command:ship").is_none());
let mine = find("command:mine").expect("a hand-written command is a row");
assert_eq!(mine["source"], "user");
assert_eq!(mine["labels"], serde_json::json!(["command"]));
assert!(
items
.iter()
.any(|item| item["kind"] == "plugin" && item["tier"] == "builtin"),
"built-in switches are rows"
);
assert_eq!(
index["unavailable"].as_array().map(Vec::len),
Some(0),
"{index}"
);
handle.abort();
Ok(())
}
#[tokio::test]
async fn the_index_keeps_same_name_hooks_distinct() -> Result<()> {
let _env = lock_test_env();
let temp = tempfile::tempdir()?;
let root = temp.path().join("hook-index-route");
let workspace = root.join("workspace");
let mut config = Config::default();
config.hooks = Some(codewhale_config::hooks::HooksConfig {
hooks: vec![
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo first",
)
.with_name("gate"),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo second",
)
.with_name("gate"),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo third",
),
codewhale_config::hooks::Hook::new(
codewhale_config::hooks::HookEvent::SessionStart,
"echo fourth",
)
.with_name("session_start 3"),
],
enabled: true,
..Default::default()
});
let (addr, _runtime_threads, handle) =
spawn_test_server_with_root_token_mobile_workspace_and_overrides(
root.clone(),
root.join("sessions"),
None,
false,
workspace.clone(),
TestServerOverrides {
config: Some(config),
plugin_discovery: Some(isolated_plugin_discovery(&root, &workspace)),
..TestServerOverrides::default()
},
)
.await?
.expect("hook index test server");
let client = codewhale_release::tls::reqwest_client();
let index: Value = client
.get(format!("http://{addr}/v1/apps/plugin-index"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let hooks: Vec<_> = index["items"]
.as_array()
.expect("items")
.iter()
.filter(|item| item["kind"] == "hook")
.collect();
assert_eq!(hooks.len(), 4, "{index}");
for (ordinal, hook) in hooks.iter().enumerate() {
assert_eq!(
hook["id"],
format!("hook:user:session_start:{}", ordinal + 1)
);
assert_eq!(hook["state"], "enabled");
}
assert_eq!(hooks[0]["name"], "gate");
assert_eq!(hooks[1]["name"], "gate");
assert_eq!(hooks[2]["name"], "session_start 3");
assert_eq!(hooks[3]["name"], "session_start 3");
assert_eq!(hooks[3]["row"]["command"], "echo fourth");
assert_eq!(hooks[0]["row"]["command"], "echo first");
assert_eq!(hooks[1]["row"]["command"], "echo second");
handle.abort();
Ok(())
}
@@ -1,537 +0,0 @@
use super::*;
const SEATS: [&str; 6] = [
"customize.plugins.detail",
"conversation.composer.dock",
"conversation.chat.node",
"workpane.panel",
"sidebar.section",
"command.row",
];
const CONSUMER_BRIDGE_KEYS: [&str; 7] = [
"kind",
"dsh_version",
"cordis_version",
"compatibility_digest",
"declared_range",
"matched_version",
"admission",
];
const CONSUMER_REFUSALS: [(u16, &str); 6] = [
(404, "plugin_ui_not_declared"),
(404, "plugin_ui_seat_not_admitted"),
(400, "plugin_ui_unsupported"),
(422, "plugin_ui_seat_unsupported"),
(400, "plugin_ui_seat_unknown"),
(404, "plugin_ui_file_not_reviewed"),
];
const DSH_CLIENT: &str = "const react = require(\"react\");\n\
const util = require(\"./util\");\n\
const keys = [\"sidebar.right.pane.tab\", \"settings.section\", \"conversation.session.header.utilities\"];\n\
exports.apply = function apply(ctx) { return [react, util.v, keys, ctx]; };\n";
fn write_dsh_package(dir: &Path, name: &str, client: &str) -> Result<PathBuf> {
let package = dir.join(name);
fs::create_dir_all(package.join("lib"))?;
fs::write(
package.join("package.json"),
serde_json::json!({
"name": format!("@demo/{name}"),
"version": "1.0.0",
"exports": { "./client": { "default": "./lib/client.js" } },
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web" }
}
})
.to_string(),
)?;
fs::write(
package.join("cordis.patch.yml"),
"- insert:\n - id: skills\n name: \"@deepseek-ai/dsh-skill-filesystem\"\n config:\n customSkillDirs:\n - ./skills\n",
)?;
fs::create_dir_all(package.join("skills/greet"))?;
fs::write(
package.join("skills/greet/SKILL.md"),
"---\nname: greet\ndescription: Greets a person\n---\nSay hi.\n",
)?;
fs::write(package.join("lib/client.js"), client)?;
fs::write(package.join("lib/util.js"), "exports.v = 1;\n")?;
Ok(package)
}
fn write_native_ui_bundle(dir: &Path, name: &str) -> Result<PathBuf> {
let bundle = write_plugin_source_bundle(dir, name)?;
fs::write(
bundle.join("package.json"),
serde_json::json!({
"name": name,
"version": "1.0.0",
"exports": { "./client": "./client.js" },
"dsh": { "client": { "platform": "web" } }
})
.to_string(),
)?;
fs::write(bundle.join("client.js"), "window.__native = true;\n")?;
Ok(bundle)
}
pub(super) async fn install_active(
client: &reqwest::Client,
base: &str,
source: String,
) -> Result<serde_json::Value> {
let install: serde_json::Value = client
.post(format!("{base}/install"))
.json(&serde_json::json!({ "source": source }))
.send()
.await?
.error_for_status()?
.json()
.await?;
let name = install["name"]
.as_str()
.expect("installed name")
.to_string();
let detail: serde_json::Value = client
.get(format!("{base}/{name}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let token = detail["review"]["token"].as_str().unwrap().to_string();
client
.post(format!("{base}/{name}/trust"))
.json(&serde_json::json!({ "token": token }))
.send()
.await?
.error_for_status()?;
let enabled: serde_json::Value = client
.post(format!("{base}/{name}/enable"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(enabled["state"], "active", "{enabled}");
Ok(serde_json::json!({ "name": name, "path": detail["path"] }))
}
async fn ui(
client: &reqwest::Client,
base: &str,
name: &str,
query: &str,
) -> Result<(u16, serde_json::Value)> {
let response = client
.get(format!("{base}/{name}/ui?{query}"))
.send()
.await?;
let status = response.status().as_u16();
Ok((status, response.json().await?))
}
async fn mutate(client: &reqwest::Client, url: String) -> Result<(StatusCode, String)> {
let response = client.post(url).send().await?;
Ok((response.status(), response.text().await?))
}
fn refusal(status: u16, body: &serde_json::Value) -> (u16, String) {
(
status,
body["error"]["code"].as_str().unwrap_or("").to_string(),
)
}
fn assert_consumer_contract(name: &str, minted: &serde_json::Value, seat: &str, key: &str) {
assert_eq!(minted["slot"], seat);
assert_eq!(minted["dsh_slot"], key);
assert!(SEATS.contains(&minted["slot"].as_str().unwrap()));
let key = minted["dsh_slot"].as_str().unwrap();
assert!(key.len() <= 128 && key.chars().next().unwrap().is_ascii_alphanumeric());
assert!(
key.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
);
let bridge = minted["bridge"].as_object().expect("bridge object");
assert_eq!(bridge["kind"], "dsh-client");
for (field, value) in bridge {
assert!(CONSUMER_BRIDGE_KEYS.contains(&field.as_str()), "{field}");
assert!(
value
.as_str()
.is_some_and(|v| !v.is_empty() && v.len() <= 128)
);
}
assert!(bridge.contains_key("dsh_version"));
let base = minted["base"].as_str().unwrap();
assert!(base.starts_with("http://127.0.0.1:"));
assert!(base.ends_with(&format!(
"/v1/apps/plugins/{}/ui/",
urlencoding::encode(name)
)));
let entry = minted["entry"].as_str().unwrap();
assert!(!entry.is_empty() && entry.len() <= 512 && !entry.contains(".."));
assert!(
minted["revision"]
.as_str()
.is_some_and(|r| r.starts_with('"'))
);
}
fn bootstrap_loads_in_node(source: &str) -> Option<String> {
let dir = tempfile::tempdir().ok()?;
let script = dir.path().join("run.js");
let harness = format!(
"var captured;\nvar window = {{ __ModuleLoader__: {{ load: function (r) {{ captured = r; }} }} }};\n{source}\nvar mod = captured.factory(function (id) {{ return {{ id: id }}; }});\nprocess.stdout.write(JSON.stringify({{ id: captured.id, apply: typeof mod.apply, out: mod.apply('ctx') }}));\n"
);
fs::write(&script, harness).ok()?;
let output = std::process::Command::new("node")
.arg(&script)
.output()
.ok()?;
assert!(
output.status.success(),
"the served bootstrap failed under node: {}",
String::from_utf8_lossy(&output.stderr)
);
Some(String::from_utf8_lossy(&output.stdout).into_owned())
}
#[tokio::test]
async fn plugin_ui_dsh_client_serves_admitted_seats_and_denies_and_revokes() -> Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("runtime");
let workspace = tmp.path().join("ws");
fs::create_dir_all(&root)?;
let package = write_dsh_package(tmp.path(), "panel", DSH_CLIENT)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}/v1/apps/plugins");
let installed = install_active(&client, &base, format!("dsh:{}", package.display())).await?;
let name = installed["name"].as_str().unwrap().to_string();
let (status, panel) = ui(&client, &base, &name, "slot=workpane.panel").await?;
assert_eq!(status, 200, "{panel}");
assert_consumer_contract(&name, &panel, "workpane.panel", "sidebar.right.pane.tab");
assert_eq!(panel["entry"], "__codewhale_dsh_bootstrap.js");
let (status, detail) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
assert_eq!(status, 200, "{detail}");
assert_consumer_contract(
&name,
&detail,
"customize.plugins.detail",
"settings.section",
);
let (status, explicit) = ui(
&client,
&base,
&name,
"slot=workpane.panel&dsh_slot=sidebar.right.pane.tab",
)
.await?;
assert_eq!(status, 200, "{explicit}");
for (query, expected) in [
("slot=sidebar.section", (404, "plugin_ui_seat_not_admitted")),
("slot=command.row", (404, "plugin_ui_seat_not_admitted")),
(
"slot=workpane.panel&dsh_slot=main.page-menu",
(404, "plugin_ui_seat_not_admitted"),
),
(
"slot=workpane.panel&dsh_slot=settings.section",
(404, "plugin_ui_seat_not_admitted"),
),
(
"slot=workpane.panel&dsh_slot=conversation.session.header.utilities",
(422, "plugin_ui_seat_unsupported"),
),
("slot=chat.node", (400, "plugin_ui_seat_unknown")),
("dsh_slot=settings.section", (400, "plugin_ui_seat_unknown")),
(
"slot=workpane.panel&dsh_slot=..%2Fx",
(400, "plugin_ui_dsh_slot_invalid"),
),
] {
let (status, body) = ui(&client, &base, &name, query).await?;
let (status, code) = refusal(status, &body);
assert_eq!((status, code.as_str()), expected, "{query}: {body}");
assert!(
CONSUMER_REFUSALS.contains(&(status, code.as_str()))
|| code == "plugin_ui_dsh_slot_invalid",
"{query}"
);
}
let revision = panel["revision"].as_str().unwrap().to_string();
let asset_url = format!(
"{}{}",
panel["base"].as_str().unwrap(),
panel["entry"].as_str().unwrap()
);
let served = client
.get(&asset_url)
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(served.status(), StatusCode::OK);
assert!(
served
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.starts_with("text/javascript"))
);
let bootstrap = served.text().await?;
assert!(bootstrap.contains("\"source/lib/util.js\""));
assert!(!bootstrap.contains("Bearer"));
match bootstrap_loads_in_node(&bootstrap) {
Some(out) => {
let out: serde_json::Value = serde_json::from_str(&out)?;
assert_eq!(out["apply"], "function");
assert_eq!(out["out"][0]["id"], "react");
assert_eq!(out["out"][1], 1);
}
None => eprintln!("node is unavailable; the bootstrap execution check was skipped"),
}
let asset_base = panel["base"].as_str().unwrap().to_string();
let missing = client.get(&asset_url).send().await?;
assert_eq!(missing.status(), StatusCode::PRECONDITION_REQUIRED);
let wrong = client
.get(&asset_url)
.header("If-Match", "\"not-a-revision\"")
.send()
.await?;
assert_eq!(wrong.status(), StatusCode::PRECONDITION_FAILED);
for outside in [
"package.json",
"source/package.json",
"source/lib/index.cjs",
"source/lib/util.js",
] {
let response = client
.get(format!("{asset_base}{outside}"))
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{outside}");
let body: serde_json::Value = response.json().await?;
assert_eq!(body["error"]["code"], "plugin_ui_file_not_reviewed");
}
let traversal = client
.get(format!("{asset_base}%2e%2e/%2e%2e/etc/passwd"))
.header("If-Match", &revision)
.send()
.await?;
assert!(traversal.status().is_client_error());
let route = format!("{base}/{name}/routes/api/state");
let routed = client
.post(&route)
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(routed.status(), StatusCode::NOT_IMPLEMENTED);
let body: serde_json::Value = routed.json().await?;
assert_eq!(body["error"]["code"], "plugin_route_not_served");
assert_eq!(
client.post(&route).send().await?.status(),
StatusCode::PRECONDITION_REQUIRED
);
assert_eq!(
client
.post(&route)
.header("If-Match", "\"not-a-revision\"")
.send()
.await?
.status(),
StatusCode::PRECONDITION_FAILED
);
assert_eq!(
client
.post(format!("{base}/other/routes/api/state"))
.header("If-Match", &revision)
.send()
.await?
.status(),
StatusCode::PRECONDITION_FAILED
);
assert!(
client
.post(format!("{base}/{name}/routes/%2e%2e/x"))
.header("If-Match", &revision)
.send()
.await?
.status()
.is_client_error()
);
let (status, body) = mutate(&client, format!("{base}/{name}/disable")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
for url in [&asset_url, &route] {
let response = client.get(url).header("If-Match", &revision).send().await?;
assert_eq!(response.status(), StatusCode::PRECONDITION_FAILED, "{url}");
}
let (status, body) = mutate(&client, format!("{base}/{name}/enable")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
let stale = client
.get(&asset_url)
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(stale.status(), StatusCode::PRECONDITION_FAILED);
let (status, again) = ui(&client, &base, &name, "slot=workpane.panel").await?;
assert_eq!(status, 200, "{again}");
let again_revision = again["revision"].as_str().unwrap().to_string();
assert_ne!(again_revision, revision);
let staged = PathBuf::from(installed["path"].as_str().unwrap()).join("source/lib/util.js");
let live = client
.get(&asset_url)
.header("If-Match", &again_revision)
.send()
.await?;
assert_eq!(live.status(), StatusCode::OK);
fs::write(&staged, "exports.v = 2;\n")?;
let tampered = client
.get(&asset_url)
.header("If-Match", &again_revision)
.send()
.await?;
assert_eq!(tampered.status(), StatusCode::PRECONDITION_FAILED);
handle.abort();
Ok(())
}
#[tokio::test]
async fn plugin_ui_dsh_client_with_unprovided_modules_or_ambient_dependencies_is_unsupported()
-> Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("runtime");
let workspace = tmp.path().join("ws");
fs::create_dir_all(&root)?;
let host_module = write_dsh_package(
tmp.path(),
"hostmod",
"require(\"@deepseek-ai/dsh-client-ui-primitives\");\nconst k = \"sidebar.right.pane.tab\";\n",
)?;
let esm = write_dsh_package(
tmp.path(),
"esm",
"const k = \"sidebar.right.pane.tab\";\nexport const apply = () => k;\n",
)?;
let ambient = write_dsh_package(
tmp.path(),
"ambient",
"require(\"left-pad\");\nconst k = \"sidebar.right.pane.tab\";\n",
)?;
let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}/v1/apps/plugins");
for package in [host_module, esm, ambient] {
let installed =
install_active(&client, &base, format!("dsh:{}", package.display())).await?;
let name = installed["name"].as_str().unwrap();
let (status, body) = ui(&client, &base, name, "slot=workpane.panel").await?;
assert_eq!(
refusal(status, &body),
(400, "plugin_ui_unsupported".to_string()),
"{name}: {body}"
);
}
handle.abort();
Ok(())
}
#[tokio::test]
async fn plugin_ui_native_client_serves_only_the_detail_seat_and_is_revoked() -> Result<()> {
let tmp = tempfile::tempdir()?;
let root = tmp.path().join("runtime");
let workspace = tmp.path().join("ws");
fs::create_dir_all(&root)?;
let bundle = write_native_ui_bundle(tmp.path(), "nativeui")?;
let Some((addr, handle)) = spawn_plugin_api_server(root.clone(), workspace.clone()).await?
else {
return Ok(());
};
let client = codewhale_release::tls::reqwest_client();
let base = format!("http://{addr}/v1/apps/plugins");
let installed = install_active(&client, &base, format!("path:{}", bundle.display())).await?;
let name = installed["name"].as_str().unwrap().to_string();
let recorded =
isolated_plugin_discovery(&root, &workspace).inspect_registry_for_workspace(&workspace);
let by_app = codewhale_plugins::host_services::PluginApprover::person(
codewhale_plugins::host_services::PersonVia::App,
);
let plugin = recorded.get(&name).unwrap();
assert_eq!(plugin.approved_by.as_ref(), Some(&by_app));
assert_eq!(plugin.enabled_by.as_ref(), Some(&by_app));
let (status, minted) = ui(&client, &base, &name, "slot=customize.plugins.detail").await?;
assert_eq!(status, 200, "{minted}");
assert!(minted.get("dsh_slot").is_none() && minted.get("bridge").is_none());
assert_eq!(minted["entry"], "client.js");
for query in [
"slot=workpane.panel",
"slot=sidebar.section",
"slot=customize.plugins.detail&dsh_slot=settings.section",
] {
let (status, body) = ui(&client, &base, &name, query).await?;
assert_eq!(
refusal(status, &body),
(404, "plugin_ui_seat_not_admitted".to_string()),
"{query}"
);
}
let revision = minted["revision"].as_str().unwrap().to_string();
let asset = format!("{}client.js", minted["base"].as_str().unwrap());
let served = client
.get(&asset)
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(served.status(), StatusCode::OK);
let unlisted = client
.get(format!("{}nothing.js", minted["base"].as_str().unwrap()))
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(unlisted.status(), StatusCode::NOT_FOUND);
let body: serde_json::Value = unlisted.json().await?;
assert_eq!(body["error"]["code"], "plugin_ui_file_not_reviewed");
let routed = client
.post(format!("{base}/{name}/routes/x"))
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(routed.status(), StatusCode::FORBIDDEN);
let body: serde_json::Value = routed.json().await?;
assert_eq!(body["error"]["code"], "plugin_route_not_admitted");
let (status, body) = mutate(&client, format!("{base}/{name}/revoke")).await?;
assert_eq!(status, StatusCode::OK, "{body}");
let after = client
.get(&asset)
.header("If-Match", &revision)
.send()
.await?;
assert_eq!(after.status(), StatusCode::PRECONDITION_FAILED);
handle.abort();
Ok(())
}
@@ -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?,
@@ -635,7 +635,7 @@ async fn snapshot_in_owner(
snapshot_in_runtime(&state.runtime_threads, &state.sessions_dir, id).await
}
pub(super) async fn snapshot_in_runtime(
async fn snapshot_in_runtime(
runtime: &Arc<RuntimeThreadManager>,
sessions_dir: &Path,
id: String,
@@ -559,7 +559,6 @@ impl RuntimeChatRelayHost {
parent_thread_id: None,
executor: None,
environment: None,
agent_preset: None,
operation_key: None,
model: Some(command.model.clone()),
model_provider: Some(command.model_provider.clone()),
@@ -722,7 +721,6 @@ impl RuntimeChatRelayHost {
parent_thread_id: None,
executor: None,
environment: None,
agent_preset: None,
dynamic_tools: Vec::new(),
environments: Vec::new(),
})
+101 -4
View File
@@ -18,14 +18,111 @@ use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::time::Duration;
use tokio::io::AsyncWriteExt as _;
use tokio::io::{AsyncBufRead, AsyncWrite};
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
use tokio_util::sync::CancellationToken;
pub use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, is_frame_refusal};
#[derive(Debug, thiserror::Error)]
pub enum FrameRefusal {
#[error("control frame exceeds the 8 MiB transport limit")]
TooLarge,
#[error("control frame is not UTF-8")]
InvalidUtf8,
}
const _: () =
assert!(codewhale_util::bounded_lines::MAX_FRAME_BYTES == MAX_RUNTIME_IMAGE_BODY_BYTES);
pub fn is_frame_refusal(error: &std::io::Error) -> bool {
error
.get_ref()
.is_some_and(|source| source.is::<FrameRefusal>())
}
/// Cancellation-safe, allocation-bounded newline framing shared by every
/// local control transport. Consumed partial bytes remain owned by the reader.
pub struct BoundedLines<R> {
reader: R,
pub(crate) pending: Vec<u8>,
discarding: bool,
}
impl<R: AsyncBufRead + Unpin> BoundedLines<R> {
pub fn new(reader: R) -> Self {
Self {
reader,
pending: Vec::new(),
discarding: false,
}
}
pub fn into_inner(self) -> R {
self.reader
}
pub async fn next_line(&mut self) -> std::io::Result<Option<String>> {
loop {
let available = self.reader.fill_buf().await?;
if available.is_empty() {
self.discarding = false;
if self.pending.is_empty() {
return Ok(None);
}
if self.pending.len() > MAX_RUNTIME_IMAGE_BODY_BYTES {
self.pending.clear();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
let bytes = std::mem::take(&mut self.pending);
return String::from_utf8(bytes).map(Some).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
});
}
let available = &available[..available.len().min(8192)];
let newline = available.iter().position(|&byte| byte == b'\n');
let count = newline.unwrap_or(available.len());
if self.discarding {
self.reader.consume(count + usize::from(newline.is_some()));
if newline.is_some() {
self.discarding = false;
} else {
tokio::task::yield_now().await;
}
continue;
}
if count > (MAX_RUNTIME_IMAGE_BODY_BYTES + 1).saturating_sub(self.pending.len()) {
self.pending.clear();
self.reader.consume(count + usize::from(newline.is_some()));
self.discarding = newline.is_none();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
// Do not let Vec's geometric growth double an almost-full frame.
self.pending.reserve_exact(count);
self.pending.extend_from_slice(&available[..count]);
self.reader.consume(count + usize::from(newline.is_some()));
if self.pending.len() > MAX_RUNTIME_IMAGE_BODY_BYTES
&& self.pending.last() != Some(&b'\r')
{
self.pending.clear();
self.discarding = newline.is_none();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
if newline.is_some() {
if self.pending.last() == Some(&b'\r') {
self.pending.pop();
}
let bytes = std::mem::take(&mut self.pending);
return String::from_utf8(bytes).map(Some).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
});
}
}
}
}
/// The stdio JSON-RPC loop, generic over its transport so it can be driven by
/// a duplex pipe in tests rather than the process's real stdin/stdout.
+106
View File
@@ -31,6 +31,37 @@ async fn control_framing_accepts_exact_limit_crlf_and_preserves_next_frame() {
assert!(reader.next_line().await.unwrap().is_none());
}
#[tokio::test]
async fn control_framing_refuses_oversize_before_retaining_extra_bytes() {
let input = vec![b'x'; MAX_RUNTIME_IMAGE_BODY_BYTES + 4096];
let mut reader = BoundedLines::new(BufReader::with_capacity(8192, input.as_slice()));
assert_eq!(
reader.next_line().await.unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
assert!(reader.pending.len() <= MAX_RUNTIME_IMAGE_BODY_BYTES + 1);
assert!(reader.pending.capacity() <= MAX_RUNTIME_IMAGE_BODY_BYTES + 1);
}
#[tokio::test]
async fn control_framing_keeps_consumed_prefix_when_read_future_is_cancelled() {
let (mut input, output) = tokio::io::duplex(64);
input.write_all(b"partial").await.unwrap();
let mut reader = BoundedLines::new(BufReader::new(output));
std::future::poll_fn(|cx| {
let mut read = std::pin::pin!(reader.next_line());
assert!(std::future::Future::poll(read.as_mut(), cx).is_pending());
std::task::Poll::Ready(())
})
.await;
assert_eq!(reader.pending, b"partial");
input.write_all(b"-continued\n").await.unwrap();
assert_eq!(
reader.next_line().await.unwrap().as_deref(),
Some("partial-continued")
);
}
#[tokio::test]
async fn control_framing_rejects_non_utf8_and_keeps_eof_line_contract() {
let mut invalid = BoundedLines::new(BufReader::new(&b"\xff\n"[..]));
@@ -367,6 +398,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
-306
View File
@@ -1,306 +0,0 @@
mod acp_support;
#[path = "../../engine/tests/support/acp_conformance.rs"]
mod conformance;
use acp_support::{Client, fixture_config, text_of, updates};
use agent_client_protocol_schema::ProtocolVersion;
use agent_client_protocol_schema::v1 as acp;
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use conformance::{Allowed, Conformance, Side};
use serde::Serialize;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
const KNOWN: &[Allowed] = &[Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.sessionId` is not in the official type",
reason: "session/load keeps the session/new body with its root sessionId through the 0.10.3 tag; official LoadSessionResponse has no sessionId",
}];
fn wire<T: Serialize>(value: T) -> Value {
serde_json::to_value(value).expect("official type serializes")
}
async fn prompt_to_end(
client: &mut Client,
params: Value,
answer: Value,
) -> Result<(Value, Vec<Value>)> {
client.next_id += 1;
let id = client.next_id;
client
.send(json!({"jsonrpc":"2.0","id":id,"method":"session/prompt","params":params}))
.await?;
let mut frames = Vec::new();
loop {
let frame = client.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
if frame["method"] == "session/request_permission" {
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],"result":answer}))
.await?;
}
frames.push(frame);
}
}
#[test]
fn every_frame_the_acp_server_writes_is_an_official_v1_shape() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(120), journey())
.await
.map_err(|_| anyhow!("conformance journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("conformance journey thread panicked"))?
}
async fn journey() -> Result<()> {
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace)?;
let runtime_dir = dir.path().join("runtime");
let config = fixture_config();
let plugins = Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace));
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 4,
},
plugins,
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let provider = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn(
"plan-call",
"todo_write",
r#"{"todos":[{"content":"write the file","status":"in_progress"}]}"#,
),
canned::tool_call_turn(
"write-call",
"write",
r#"{"path":"allowed.txt","content":"approved"}"#,
),
canned::simple_text_turn("settled after approval"),
canned::tool_call_turn(
"cancel-call",
"write",
r#"{"path":"cancelled.txt","content":"never"}"#,
),
canned::simple_text_turn("unused"),
]));
manager.set_test_model_client(provider.clone());
let sessions_dir = codewhale_engine::session_manager::default_sessions_dir()?;
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
sessions_dir,
None,
None,
)?;
let mut observed = Conformance::checking(&[Side::Agent]);
let (mut client, agent_read, agent_write) = Client::connect(true);
let first_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let first_script = async {
let result = async {
let (initialized, _) = client
.request(
"initialize",
wire(
acp::InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false),
)
.client_info(acp::Implementation::new("conformance", "1.0.0")),
),
)
.await?;
assert_eq!(initialized["result"]["protocolVersion"], 1);
client
.send(json!({"jsonrpc":"2.0","method":"$/cancel_request","params":wire(acp::CancelRequestNotification::new(999_i64))}))
.await?;
let (unknown, _) = client.request("session/not_a_method", json!({})).await?;
assert_eq!(unknown["error"]["code"], -32601);
let (invalid, _) = client
.request("session/set_mode", json!({"modeId":"agent"}))
.await?;
assert_eq!(invalid["error"]["code"], -32602);
let (relative, _) = client
.request("session/new", wire(acp::NewSessionRequest::new("relative/dir")))
.await?;
assert_eq!(relative["error"]["code"], -32602);
let (created, _) = client
.request("session/new", wire(acp::NewSessionRequest::new(&workspace)))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
.to_string();
let (mode, _) = client
.request(
"session/set_mode",
wire(acp::SetSessionModeRequest::new(session.clone(), "agent")),
)
.await?;
assert!(mode.get("result").is_some(), "{mode}");
let (option, _) = client
.request(
"session/set_config_option",
wire(acp::SetSessionConfigOptionRequest::new(
session.clone(),
"model",
"fixture-model",
)),
)
.await?;
assert!(option.get("result").is_some(), "{option}");
let (image, _) = client
.request(
"session/prompt",
wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Image(acp::ImageContent::new(
"AAAA",
"image/png",
))],
)),
)
.await?;
assert_eq!(image["error"]["code"], -32602);
let allow = wire(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
"allow-once",
)),
));
let (done, frames) = prompt_to_end(
&mut client,
wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Text(acp::TextContent::new(
"plan, then write under Ask",
))],
)),
allow,
)
.await?;
assert_eq!(done["result"]["stopReason"], "end_turn");
assert!(!updates(&frames, "tool_call").is_empty());
assert_eq!(text_of(&frames, "agent_message_chunk"), "settled after approval");
client.next_id += 1;
let cancel_id = client.next_id;
client
.send(json!({"jsonrpc":"2.0","id":cancel_id,"method":"session/prompt","params":wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Text(acp::TextContent::new("write then cancel"))],
))}))
.await?;
let (permission, _) = client.until_permission().await?;
client
.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":wire(acp::CancelNotification::new(session.clone()))}))
.await?;
client
.send(json!({"jsonrpc":"2.0","id":permission["id"],"result":wire(acp::RequestPermissionResponse::new(acp::RequestPermissionOutcome::Cancelled))}))
.await?;
let (cancelled, _) = client.until_response(cancel_id).await?;
assert_eq!(cancelled["result"]["stopReason"], "cancelled");
let (listed, _) = client
.request(
"session/list",
wire(acp::ListSessionsRequest::new().cwd(workspace.clone())),
)
.await?;
assert!(listed["result"]["sessions"].is_array());
Ok::<_, anyhow::Error>(session)
}
.await;
let _ = client.write.shutdown().await;
result
};
let (served, session) = tokio::join!(first_connection, first_script);
served?;
let session = session?;
for (sent, frame) in client.log.take().unwrap_or_default() {
observed.observe(if sent { Side::Client } else { Side::Agent }, &frame);
}
let (mut client, agent_read, agent_write) = Client::connect(true);
let second_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let second_script = async {
let result = async {
client
.request(
"initialize",
wire(acp::InitializeRequest::new(ProtocolVersion::V1)),
)
.await?;
let (missing, _) = client
.request(
"session/load",
wire(acp::LoadSessionRequest::new("no-such-session", &workspace)),
)
.await?;
assert_eq!(missing["error"]["code"], -32602);
let (loaded, replay) = client
.request(
"session/load",
wire(acp::LoadSessionRequest::new(session.clone(), &workspace)),
)
.await?;
assert!(loaded.get("result").is_some(), "{loaded}");
assert!(text_of(&replay, "user_message_chunk").contains("plan, then write under Ask"));
assert!(!updates(&replay, "tool_call_update").is_empty());
Ok::<_, anyhow::Error>(())
}
.await;
let _ = client.write.shutdown().await;
result
};
let (served, script) = tokio::join!(second_connection, second_script);
served?;
script?;
for (sent, frame) in client.log.take().unwrap_or_default() {
observed.observe(if sent { Side::Client } else { Side::Agent }, &frame);
}
manager.shutdown_and_wait().await?;
assert!(
observed.frames() > 20,
"only {} frames observed",
observed.frames()
);
observed.assert_only(KNOWN);
Ok(())
}
-842
View File
@@ -1,842 +0,0 @@
mod acp_support;
use acp_support::{Client, fixture_config, text_of, updates};
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{CannedTurn, MockLlmClient, canned};
use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
#[test]
fn editor_drives_a_session_through_permission_cancel_and_load() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(120), journey())
.await
.map_err(|_| anyhow!("journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("journey thread panicked"))?
}
async fn journey() -> Result<()> {
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace)?;
let runtime_dir = dir.path().join("runtime");
let config = fixture_config();
let plugins = Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace));
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 4,
},
plugins,
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let provider = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn(
"provider-id-not-authority",
"write",
r#"{"path":"allowed.txt","content":"approved"}"#,
),
canned::simple_text_turn("settled after approval"),
canned::tool_call_turn(
"provider-id-two",
"write",
r#"{"path":"cancelled.txt","content":"never"}"#,
),
canned::simple_text_turn("resumed in the real session"),
]));
manager.set_test_model_client(provider.clone());
let sessions_dir = codewhale_engine::session_manager::default_sessions_dir()?;
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
sessions_dir,
None,
None,
)?;
let (mut client, agent_read, agent_write) = Client::connect(false);
let first_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let first_script = async {
let result = async {
let (initialized, _) = client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{"terminal":false}}),
)
.await?;
let capabilities = &initialized["result"]["agentCapabilities"];
assert_eq!(initialized["result"]["protocolVersion"], 1);
assert_eq!(capabilities["loadSession"], true);
assert_eq!(capabilities["promptCapabilities"]["image"], false);
assert_eq!(capabilities["promptCapabilities"]["audio"], false);
assert_eq!(capabilities["mcpCapabilities"]["http"], false);
assert_eq!(capabilities["mcpCapabilities"]["sse"], false);
assert!(capabilities["sessionCapabilities"]["list"].is_object());
assert!(capabilities["sessionCapabilities"].get("resume").is_none());
let (relative, _) = client
.request("session/new", json!({"cwd":"relative/dir"}))
.await?;
assert_eq!(relative["error"]["code"], -32602);
let (mcp, _) = client
.request(
"session/new",
json!({"cwd":workspace,"mcpServers":[{"name":"x","command":"x","args":[],"env":[]}]}),
)
.await?;
assert_eq!(mcp["error"]["code"], -32602);
let (resume, _) = client
.request("session/resume", json!({"sessionId":"x","cwd":workspace}))
.await?;
assert_eq!(resume["error"]["code"], -32601);
let (created, _) = client
.request("session/new", json!({"cwd":workspace}))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
.to_string();
let (image, _) = client
.request(
"session/prompt",
json!({"sessionId":session,"prompt":[
{"type":"text","text":"look"},
{"type":"image","data":"AAAA","mimeType":"image/png"}
]}),
)
.await?;
assert_eq!(image["error"]["code"], -32602);
assert_eq!(
provider.call_count(),
0,
"a refused prompt never reaches the provider"
);
client.next_id += 1;
let prompt_id = client.next_id;
client
.send(
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":[{"type":"text","text":"write under Ask"}]}}),
)
.await?;
let (permission, before) = client.until_permission().await?;
assert_eq!(permission["params"]["sessionId"], session.as_str());
assert_eq!(permission["params"]["toolCall"]["kind"], "edit");
let kinds: Vec<_> = permission["params"]["options"]
.as_array()
.unwrap()
.iter()
.map(|option| option["kind"].as_str().unwrap().to_string())
.collect();
assert_eq!(kinds, ["allow_once", "reject_once"]);
assert!(
!updates(&before, "tool_call").is_empty(),
"the tool call is announced before approval is requested"
);
assert!(
updates(&before, "tool_call_update")
.iter()
.all(|frame| frame.pointer("/params/update/status") != Some(&json!("in_progress"))),
"nothing dispatches before the person decides"
);
assert!(!workspace.join("allowed.txt").exists());
client
.send(json!({"jsonrpc":"2.0","id":permission["id"],
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}))
.await?;
let (done, after) = client.until_response(prompt_id).await?;
assert_eq!(done["result"]["stopReason"], "end_turn");
assert_eq!(
std::fs::read_to_string(workspace.join("allowed.txt"))?,
"approved"
);
assert!(
updates(&after, "tool_call_update")
.iter()
.any(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
);
assert_eq!(
text_of(&after, "agent_message_chunk"),
"settled after approval"
);
client.next_id += 1;
let cancel_id = client.next_id;
client
.send(
json!({"jsonrpc":"2.0","id":cancel_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":"write then cancel"}}),
)
.await?;
let (second, _) = client.until_permission().await?;
client
.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":session}}))
.await?;
client
.send(json!({"jsonrpc":"2.0","id":second["id"],
"result":{"outcome":{"outcome":"cancelled"}}}))
.await?;
let (cancelled, _) = client.until_response(cancel_id).await?;
assert_eq!(cancelled["result"]["stopReason"], "cancelled");
assert!(!workspace.join("cancelled.txt").exists());
let (listed, _) = client
.request("session/list", json!({"cwd":workspace}))
.await?;
assert!(
listed["result"]["sessions"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["sessionId"] == session.as_str())
);
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(session)
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, session) = tokio::join!(first_connection, first_script);
served?;
let session = session?;
let thread = manager
.list_threads(Default::default(), None)
.await?
.into_iter()
.next()
.ok_or_else(|| anyhow!("no runtime thread was created"))?;
let detail = manager.get_thread_detail(&thread.id).await?;
assert!(
detail.pending_approvals.is_empty(),
"cancel settles the approval"
);
assert_eq!(detail.turns.len(), 2);
assert_eq!(detail.turns[0].status, RuntimeTurnStatus::Completed);
assert_eq!(detail.turns[1].status, RuntimeTurnStatus::Interrupted);
let (mut client, agent_read, agent_write) = Client::connect(false);
let second_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let second_script = async {
client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let (missing, _) = client
.request(
"session/load",
json!({"sessionId":"no-such-session","cwd":workspace,"mcpServers":[]}),
)
.await?;
assert_eq!(missing["error"]["code"], -32602);
let (loaded, replay) = client
.request(
"session/load",
json!({"sessionId":session,"cwd":workspace,"mcpServers":[]}),
)
.await?;
assert_eq!(loaded["result"]["sessionId"], session.as_str());
assert!(text_of(&replay, "user_message_chunk").contains("write under Ask"));
assert!(text_of(&replay, "agent_message_chunk").contains("settled after approval"));
assert!(!updates(&replay, "tool_call").is_empty());
let (resumed, frames) = client
.request(
"session/prompt",
json!({"sessionId":session,"prompt":"continue"}),
)
.await?;
assert_eq!(resumed["result"]["stopReason"], "end_turn");
assert_eq!(
text_of(&frames, "agent_message_chunk"),
"resumed in the real session"
);
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
};
let (served, script) = tokio::join!(second_connection, second_script);
served?;
script?;
let requests = provider.captured_requests();
let last = serde_json::to_string(requests.last().ok_or_else(|| anyhow!("no model request"))?)?;
assert!(
last.contains("write under Ask") && last.contains("settled after approval"),
"the loaded session continues from its real history"
);
manager.shutdown_and_wait().await?;
Ok(())
}
const MCP_FIXTURE: &str = r#"import readline from 'node:readline';
readline.createInterface({input:process.stdin}).on('line', line => {
const r = JSON.parse(line); if (r.id === undefined) return;
let result = {};
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
if (r.method === 'tools/list') result = {tools:[{name:'echo',description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}]};
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-echo:' + r.params.arguments.text}]};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
});
"#;
#[test]
fn plugin_and_mcp_tools_run_as_acp_tool_calls_through_the_permission_dial() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(120), extension_journey())
.await
.map_err(|_| anyhow!("extension journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("extension journey thread panicked"))?
}
async fn extension_journey() -> Result<()> {
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
let Some(node) = node_for_tests("plugin_and_mcp_tools_run_as_acp_tool_calls") else {
return Ok(());
};
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["plugin-context"]).await;
let host = fixture.manager(node.clone());
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = fixture.workspace().to_path_buf();
let server = dir.path().join("fixture-mcp.mjs");
std::fs::write(&server, MCP_FIXTURE)?;
let mcp_config = dir.path().join("mcp.json");
std::fs::write(
&mcp_config,
serde_json::to_vec(&json!({"servers":{"fixture":{"command":node,"args":[server]}}}))?,
)?;
let runtime_dir = dir.path().join("runtime");
let mut config = fixture_config();
config.set_feature("mcp", true).unwrap();
config.set_feature("extension_host", true).unwrap();
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 4,
},
fixture.registry(),
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let provider = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn("plugin-call", "ctx_probe", "{}"),
canned::tool_call_turn("mcp-call", "mcp_fixture_echo", r#"{"text":"from-acp"}"#),
canned::simple_text_turn("both extension tools ran"),
]));
manager.set_test_model_client(provider.clone());
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
codewhale_engine::session_manager::default_sessions_dir()?,
None,
None,
)?;
let (mut client, agent_read, agent_write) = Client::connect(false);
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let script = async {
let result = async {
let (initialized, _) = client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let meta = &initialized["result"]["agentCapabilities"]["_meta"]["codewhale"];
assert_eq!(meta["plugins"], true);
assert_eq!(meta["configuredMcpServers"], true);
assert_eq!(meta["clientMcpServers"], false);
let (created, _) = client
.request("session/new", json!({"cwd":workspace}))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
.to_string();
client.next_id += 1;
let prompt_id = client.next_id;
client
.send(json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":[{"type":"text","text":"use both extension tools"}]}}))
.await?;
let mut asked = Vec::new();
let mut frames = Vec::new();
let done = loop {
let frame = client.recv().await?;
if frame["method"] == "session/request_permission" {
asked.push(
frame["params"]["toolCall"]["title"]
.as_str()
.unwrap_or_default()
.to_string(),
);
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],
"result":{"outcome":{"outcome":"selected","optionId":"allow-once"}}}))
.await?;
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
break frame;
} else {
frames.push(frame);
}
};
assert_eq!(done["result"]["stopReason"], "end_turn");
assert!(
asked.iter().any(|title| title.starts_with("ctx_probe")),
"the plugin tool asks through the permission dial: {asked:?} {frames:#?}"
);
assert!(
asked
.iter()
.any(|title| title.starts_with("mcp_fixture_echo")),
"the MCP tool asks through the permission dial: {asked:?}"
);
let announced: Vec<_> = updates(&frames, "tool_call")
.iter()
.filter_map(|frame| frame.pointer("/params/update/title")?.as_str())
.map(str::to_string)
.collect();
assert!(announced.iter().any(|title| title == "ctx_probe"), "{announced:?}");
assert!(
announced.iter().any(|title| title == "mcp_fixture_echo"),
"{announced:?}"
);
let completed = updates(&frames, "tool_call_update")
.iter()
.filter(|frame| {
frame.pointer("/params/update/status") == Some(&json!("completed"))
})
.count();
assert_eq!(completed, 2, "both extension calls complete: {frames:#?}");
let rendered = serde_json::to_string(&frames)?;
assert!(rendered.contains("mcp-echo:from-acp"), "{rendered}");
let probed = updates(&frames, "tool_call_update")
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/0/content/text"))
.filter_map(Value::as_str)
.filter_map(|text| serde_json::from_str::<Value>(&text[text.find('{')?..]).ok())
.find_map(|result| Some(PathBuf::from(result.get("workspace")?.as_str()?)))
.ok_or_else(|| anyhow!("the plugin result did not reach the editor: {rendered}"))?;
assert_eq!(probed.canonicalize()?, workspace.canonicalize()?);
assert_eq!(
text_of(&frames, "agent_message_chunk"),
"both extension tools ran"
);
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, scripted) = tokio::join!(connection, script);
served?;
scripted?;
let first = serde_json::to_string(
provider
.captured_requests()
.first()
.ok_or_else(|| anyhow!("no model request"))?,
)?;
assert!(
first.contains("ctx_probe") && first.contains("mcp_fixture_echo"),
"the first model request carries both extension tools"
);
manager.shutdown_and_wait().await?;
host.shutdown().await;
Ok(())
}
const GATES_MCP_FIXTURE: &str = r#"import readline from 'node:readline';
readline.createInterface({input:process.stdin}).on('line', line => {
const r = JSON.parse(line); if (r.id === undefined) return;
let result = {};
if (r.method === 'initialize') result = {protocolVersion:'2024-11-05',capabilities:{tools:{}},serverInfo:{name:'fixture',version:'1'}};
if (r.method === 'tools/list') result = {tools:['echo','hidden'].map(name => ({name,description:'Echo text',inputSchema:{type:'object',properties:{text:{type:'string'}},required:['text']}}))};
if (r.method === 'tools/call') result = {content:[{type:'text',text:'mcp-ran:' + r.params.name + ':' + r.params.arguments.text}]};
process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:r.id,result}) + '\n');
});
"#;
struct Outcome {
response: Value,
frames: Vec<Value>,
asked: Vec<Value>,
}
impl Outcome {
fn rendered(&self) -> String {
serde_json::to_string(&self.frames).unwrap_or_default()
}
fn completed(&self) -> usize {
updates(&self.frames, "tool_call_update")
.iter()
.filter(|frame| frame.pointer("/params/update/status") == Some(&json!("completed")))
.count()
}
fn titles(&self) -> Vec<String> {
self.asked
.iter()
.map(|frame| {
frame["params"]["toolCall"]["title"]
.as_str()
.unwrap_or_default()
.to_string()
})
.collect()
}
fn announced_ids(&self) -> Vec<String> {
updates(&self.frames, "tool_call")
.iter()
.filter_map(|frame| frame.pointer("/params/update/toolCallId")?.as_str())
.map(str::to_string)
.collect()
}
}
async fn run_prompt(
client: &mut Client,
session: &str,
allow: &dyn Fn(usize) -> bool,
) -> Result<Outcome> {
client.next_id += 1;
let prompt_id = client.next_id;
client
.send(
json!({"jsonrpc":"2.0","id":prompt_id,"method":"session/prompt","params":{
"sessionId":session,"prompt":[{"type":"text","text":"go"}]}}),
)
.await?;
let mut frames = Vec::new();
let mut asked = Vec::new();
let response = loop {
let frame = client.recv().await?;
if frame["method"] == "session/request_permission" {
let option = if allow(asked.len()) {
"allow-once"
} else {
"reject-once"
};
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],
"result":{"outcome":{"outcome":"selected","optionId":option}}}))
.await?;
asked.push(frame);
} else if frame["id"] == json!(prompt_id) && frame.get("method").is_none() {
break frame;
} else {
frames.push(frame);
}
};
Ok(Outcome {
response,
frames,
asked,
})
}
async fn new_session(
client: &mut Client,
manager: &RuntimeThreadManager,
workspace: &Path,
turns: Vec<CannedTurn>,
plan: bool,
) -> Result<String> {
manager.set_test_model_client(Arc::new(MockLlmClient::new(turns)));
let (created, _) = client
.request("session/new", json!({"cwd":workspace}))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId: {created}"))?
.to_string();
if plan {
let (set, _) = client
.request(
"session/set_mode",
json!({"sessionId":session,"modeId":"plan"}),
)
.await?;
assert!(set.get("error").is_none(), "{set}");
}
Ok(session)
}
#[test]
fn extension_tools_keep_the_permission_boundary_in_an_acp_session() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(180), gates())
.await
.map_err(|_| anyhow!("extension gates exceeded 180s"))?
})
})?
.join()
.map_err(|_| anyhow!("extension gates thread panicked"))?
}
async fn gates() -> Result<()> {
use codewhale_engine::extension_host::test_support::{FixturePlugins, node_for_tests};
let Some(node) = node_for_tests("extension_tools_keep_the_permission_boundary") else {
return Ok(());
};
let _policy = codewhale_plugins::activation::TestPolicyGuard::extension_host(true);
let fixture = FixturePlugins::new(&["plugin-context", "core-call"]).await;
let host = fixture.manager(node.clone());
let _host = codewhale_engine::extension_host::TestManagerGuard::install(Arc::clone(&host));
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = fixture.workspace().to_path_buf();
std::fs::write(workspace.join("seed.txt"), "seed-content-xyz")?;
let server = dir.path().join("fixture-mcp.mjs");
std::fs::write(&server, GATES_MCP_FIXTURE)?;
let mcp_config = dir.path().join("mcp.json");
std::fs::write(
&mcp_config,
serde_json::to_vec(&json!({"servers":{
"fixture":{"command":node,"args":[server],"disabled_tools":["hidden"]},
"off":{"command":node,"args":[server],"disabled":true}
}}))?,
)?;
let runtime_dir = dir.path().join("runtime");
let mut config = fixture_config();
config.set_feature("mcp", true).unwrap();
config.set_feature("extension_host", true).unwrap();
config.mcp_config_path = Some(mcp_config.to_string_lossy().into_owned());
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 16,
},
fixture.registry(),
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
codewhale_engine::session_manager::default_sessions_dir()?,
None,
None,
)?;
let (mut client, agent_read, agent_write) = Client::connect(false);
let connection = frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let manager_for_script = Arc::clone(&manager);
let script =
async {
let manager = manager_for_script;
let result = async {
client
.request(
"initialize",
json!({"protocolVersion":1,"clientCapabilities":{}}),
)
.await?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n1",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested allowed"),
],
false,
)
.await?;
let allowed = run_prompt(&mut client, &session, &|_| true).await?;
assert!(allowed.response.get("error").is_none(), "{}", allowed.response);
assert_eq!(allowed.response["result"]["stopReason"], "end_turn");
assert_eq!(allowed.asked.len(), 2, "{:?}", allowed.titles());
assert!(allowed.titles()[0].starts_with("cc_call"));
let announced = allowed.announced_ids();
for request in &allowed.asked {
let id = request["params"]["toolCall"]["toolCallId"]
.as_str()
.unwrap_or_default();
assert!(
announced.iter().any(|known| known == id),
"a nested approval is shown on a tool call the editor knows: {id} {announced:?}"
);
}
let nested = &allowed.asked[1]["params"]["toolCall"];
assert_eq!(nested["title"], "write: nested.txt", "{nested}");
assert_eq!(nested["kind"], "edit", "{nested}");
assert_eq!(nested["rawInput"]["path"], "nested.txt", "{nested}");
assert!(nested.get("status").is_none(), "{nested}");
assert_eq!(
std::fs::read_to_string(workspace.join("nested.txt"))?,
"via-plugin"
);
std::fs::remove_file(workspace.join("nested.txt"))?;
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn(
"n2",
"cc_call",
r#"{"name":"write","input":{"path":"nested.txt","content":"via-plugin"}}"#,
),
canned::simple_text_turn("nested denied"),
],
false,
)
.await?;
let denied = run_prompt(&mut client, &session, &|index| index == 0).await?;
assert!(denied.response.get("error").is_none(), "{}", denied.response);
assert_eq!(denied.response["result"]["stopReason"], "end_turn");
assert_eq!(denied.asked.len(), 2, "{:?}", denied.titles());
assert!(
!workspace.join("nested.txt").exists(),
"a rejected nested write must not run"
);
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("r1", "ctx_probe", "{}"),
canned::tool_call_turn("r2", "mcp_fixture_echo", r#"{"text":"control"}"#),
canned::tool_call_turn("r3", "ctx_note", r#"{"text":"must-not-be-written"}"#),
canned::tool_call_turn("r4", "mcp_fixture_echo", r#"{"text":"rejected"}"#),
canned::simple_text_turn("rejections done"),
],
false,
)
.await?;
let rejected = run_prompt(&mut client, &session, &|index| index < 2).await?;
assert_eq!(rejected.asked.len(), 4, "{:?}", rejected.titles());
let rendered = rejected.rendered();
assert!(rendered.contains("mcp-ran:echo:control"), "{rendered}");
assert!(!rendered.contains("mcp-ran:echo:rejected"), "{rendered}");
assert!(!rendered.contains("note.txt"), "{rendered}");
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("p1", "ctx_probe", "{}"),
canned::tool_call_turn("p2", "mcp_fixture_echo", r#"{"text":"plan"}"#),
canned::simple_text_turn("plan done"),
],
true,
)
.await?;
let plan = run_prompt(&mut client, &session, &|_| true).await?;
assert!(plan.asked.is_empty(), "{:?}", plan.titles());
assert_eq!(plan.completed(), 0, "{}", plan.rendered());
assert!(!plan.rendered().contains("mcp-ran:"));
let session = new_session(
&mut client,
&manager,
&workspace,
vec![
canned::tool_call_turn("f1", "mcp_fixture_hidden", r#"{"text":"hid"}"#),
canned::tool_call_turn("f2", "mcp_off_echo", r#"{"text":"off"}"#),
canned::tool_call_turn("f3", "mcp_evil_echo", r#"{"text":"evil"}"#),
canned::tool_call_turn(
"f4",
"start_mcp_server",
r#"{"name":"evil","command":"node","args":["-e","0"]}"#,
),
canned::tool_call_turn("f5", "agent", r#"{"action":"start","prompt":"p"}"#),
canned::simple_text_turn("fabricated done"),
],
false,
)
.await?;
let fabricated = run_prompt(&mut client, &session, &|_| true).await?;
assert!(fabricated.asked.is_empty(), "{:?}", fabricated.titles());
assert_eq!(fabricated.completed(), 0, "{}", fabricated.rendered());
assert!(!fabricated.rendered().contains("mcp-ran:"));
client.request("shutdown", json!({})).await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = client.write.shutdown().await;
}
result
};
let (served, scripted) = tokio::join!(connection, script);
served?;
scripted?;
manager.shutdown_and_wait().await?;
host.shutdown().await;
Ok(())
}
-120
View File
@@ -1,120 +0,0 @@
use anyhow::{Result, anyhow};
use codewhale_runtime::config::{Config, ProviderConfig, ProvidersConfig, SnapshotsConfig};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf};
pub const STEP: Duration = Duration::from_secs(30);
pub fn fixture_config() -> Config {
let mut config = Config::default();
config.provider = Some("acp-fixture".into());
config.providers = Some(ProvidersConfig {
custom: HashMap::from([(
"acp-fixture".into(),
ProviderConfig {
kind: Some("openai-compatible".into()),
base_url: Some("http://127.0.0.1:18181/v1".into()),
model: Some("fixture-model".into()),
api_key: Some("local-test-key".into()),
..Default::default()
},
)]),
..Default::default()
});
config.snapshots = Some(SnapshotsConfig {
enabled: false,
..Default::default()
});
config.set_feature("mcp", false).unwrap();
config.set_feature("subagents", false).unwrap();
config
}
pub struct Client {
pub lines: tokio::io::Lines<BufReader<ReadHalf<DuplexStream>>>,
pub write: WriteHalf<DuplexStream>,
pub next_id: u64,
pub log: Option<Vec<(bool, Value)>>,
}
impl Client {
pub fn connect(record: bool) -> (Self, ReadHalf<DuplexStream>, WriteHalf<DuplexStream>) {
let (client, agent) = tokio::io::duplex(1 << 20);
let (agent_read, agent_write) = tokio::io::split(agent);
let (client_read, client_write) = tokio::io::split(client);
(
Client {
lines: BufReader::new(client_read).lines(),
write: client_write,
next_id: 0,
log: record.then(Vec::new),
},
agent_read,
agent_write,
)
}
pub async fn send(&mut self, message: Value) -> Result<()> {
let mut line = serde_json::to_vec(&message)?;
line.push(b'\n');
self.write.write_all(&line).await?;
self.write.flush().await?;
if let Some(log) = &mut self.log {
log.push((true, message));
}
Ok(())
}
pub async fn recv(&mut self) -> Result<Value> {
let line = tokio::time::timeout(STEP, self.lines.next_line())
.await
.map_err(|_| anyhow!("agent produced no frame within {STEP:?}"))??
.ok_or_else(|| anyhow!("agent closed the transport"))?;
let frame: Value = serde_json::from_str(&line)?;
if let Some(log) = &mut self.log {
log.push((false, frame.clone()));
}
Ok(frame)
}
pub async fn request(&mut self, method: &str, params: Value) -> Result<(Value, Vec<Value>)> {
self.next_id += 1;
let id = self.next_id;
self.send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
.await?;
self.until_response(id).await
}
pub async fn until_permission(&mut self) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["method"] == "session/request_permission" {
return Ok((frame, frames));
}
frames.push(frame);
}
}
pub async fn until_response(&mut self, id: u64) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
}
pub fn updates<'a>(frames: &'a [Value], kind: &str) -> Vec<&'a Value> {
frames
.iter()
.filter(|frame| frame.pointer("/params/update/sessionUpdate") == Some(&json!(kind)))
.collect()
}
pub fn text_of(frames: &[Value], kind: &str) -> String {
updates(frames, kind)
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/text")?.as_str())
.collect()
}
+2 -97
View File
@@ -139,7 +139,6 @@ impl BootstrapPublicationFixture {
if reused {
process_start.push_str("-different-generation");
}
std::fs::create_dir_all(harness.root.join("runtime")).unwrap();
let owner = codewhale_protocol::RuntimeOwnerReceipt {
version: 1,
data_dir: harness.root.join("runtime"),
@@ -169,7 +168,6 @@ impl BootstrapPublicationFixture {
match codewhale_app_server::daemon_client::probe_owner_for_host_startup(
self.owner.config_path.clone(),
Some(self.owner.socket_path.clone()),
&self.owner.data_dir,
)
.await
.unwrap()
@@ -252,105 +250,12 @@ async fn owner_bootstrap_unavailable_is_host_only_and_read_only() {
fixture.parent.socket_identity("daemon.sock").unwrap(),
identity
);
assert_eq!(
std::fs::read_dir(&fixture.owner.data_dir).unwrap().count(),
0,
assert!(
!fixture.owner.data_dir.exists(),
"standalone control must not open or mint a store"
);
}
#[tokio::test]
async fn dead_owner_whose_store_was_deleted_is_discarded_and_startup_proceeds() {
let fixture = BootstrapPublicationFixture::new("boot-gone", true, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
assert!(matches!(
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&fixture.harness.root.join("another-store"),
)
.await
.unwrap(),
codewhale_app_server::daemon_client::HostOwnerProbe::Unavailable(_)
));
fixture.assert_publication(&fixture.bytes);
let probe = codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&fixture.owner.data_dir,
)
.await
.unwrap();
assert!(matches!(
probe,
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
));
assert!(
fixture
.parent
.read_private_receipt("daemon.sock.owner.json", 16384)
.unwrap()
.is_none()
);
assert!(!fixture.owner.data_dir.exists());
fixture.bind_replacement(None).await.unwrap();
}
async fn assert_gone_store_spelled_as_is_discarded(
label: &str,
spell: impl FnOnce(&Path) -> PathBuf,
) {
let fixture = BootstrapPublicationFixture::new(label, true, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
let spelled = spell(&fixture.harness.root);
assert_ne!(spelled, fixture.owner.data_dir);
assert!(matches!(
codewhale_app_server::daemon_client::probe_owner_for_host_startup(
fixture.owner.config_path.clone(),
Some(fixture.owner.socket_path.clone()),
&spelled,
)
.await
.unwrap(),
codewhale_app_server::daemon_client::HostOwnerProbe::Absent
));
assert!(
fixture
.parent
.read_private_receipt("daemon.sock.owner.json", 16384)
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_through_a_symlink() {
assert_gone_store_spelled_as_is_discarded("boot-gone-link", |root| {
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
root.join("alias").join("runtime")
})
.await;
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn dead_owner_is_discarded_when_the_selected_store_is_spelled_in_another_letter_case() {
assert_gone_store_spelled_as_is_discarded("boot-gone-case", |root| {
let upper = PathBuf::from(root.to_string_lossy().to_uppercase());
assert!(upper.exists(), "this test needs a case-insensitive volume");
upper.join("RUNTIME")
})
.await;
}
#[tokio::test]
async fn live_owner_with_a_missing_store_is_never_discarded() {
let fixture = BootstrapPublicationFixture::new("boot-gone-live", false, false).await;
std::fs::remove_dir(&fixture.owner.data_dir).unwrap();
fixture.capture().await;
fixture.assert_publication(&fixture.bytes);
}
#[tokio::test]
async fn owner_bootstrap_live_process_and_reused_pid_refuse_retirement() {
for reused in [false, true] {
-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();
+12 -39
View File
@@ -117,7 +117,7 @@ struct Cli {
#[arg(
long,
value_name = "BOOL",
help = "Control usage reporting (default on; Codewhale + PostHog; \
help = "Control aggregate usage counting (default on; Codewhale + PostHog; \
durable off: config set telemetry false; CODEWHALE_TELEMETRY=0 always wins)"
)]
telemetry: Option<bool>,
@@ -234,10 +234,10 @@ enum Commands {
#[command(about = "Install a plugin bundle without trusting or enabling it")]
Install(TuiPassthroughArgs),
#[command(
about = "Inspect, review, trust, enable, disable or revoke plugin bundles",
about = "Inspect or restage reviewed plugin bundles",
alias = "plugin",
override_usage = "codewhale plugins <COMMAND>",
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot\n trust <NAME> [--json] Show the capability review; in a terminal, then ask\n trust <NAME> --review-token T [--yes] [--json] Trust exactly the content the bundle review token names\n enable <NAME> [--json] Enable a trusted plugin\n disable <NAME> [--json] Disable a plugin, keeping its trust\n revoke <NAME> [--json] Revoke trust\n\nThe bundle review token ties trust to exact content; it does not show that anyone read the review,\nand it is not the host-service review token. A --yes trust is recorded as the command line. When\nCODEWHALE_AGENT_PROJECT marks the process, as it does every command the agent runs, trust and enable\nare the agent's, for that project only; that mark is a label the agent could remove in Full Access."
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot"
)]
Plugins(TuiPassthroughArgs),
/// Bootstrap MCP config and/or skills directories.
@@ -265,16 +265,17 @@ Common forwarded flags:
--output-format <FORMAT> Output format: text or stream-json
--hooks Opt in to configured hooks (tool_call_before, shell_env)
Plain `codewhale exec` runs the agent with the normal tool surface under the
configured sandbox and approval posture. Use `--no-tools` for one answer with
no tools. `--auto` selects automatic approvals and Full Access by default. A
reply cut off at the provider's output limit is continued in the same turn, at
most 8 model steps unless `--max-turns` says otherwise.
Plain `codewhale exec` is a one-shot model response: one Engine turn with the
same system prompt as every other run, and no tools. Use `--auto` for
non-interactive agent-with-tools execution. Tools are offered only with
`--auto`, `--yolo`, `--allowed-tools`, or when resuming a session; limits such
as `--max-turns`, `--disallowed-tools` or `--sandbox`, and the output format,
never add tools. A reply cut off at the provider's output limit is continued
in the same turn, at most 8 model steps unless `--max-turns` says otherwise.
--auto defaults to Full Access: no shell sandbox and trust mode on. Use --sandbox workspace-write to keep the shell sandbox and file-tool workspace restrictions; stricter configured policies also apply. A denied sandbox retry requires --allow-sandbox-elevation or --sandbox danger-full-access.
Any --sandbox other than danger-full-access (or an equivalent sandbox_mode in config) disables trust mode.
Runs that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Answer-only (--no-tools) text/--json runs are not saved unless compaction needs recovery.
Runs that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Plain answer-only text/--json runs are not saved unless compaction needs recovery.
Put flags before the prompt. Put -- before a prompt starting with - or containing literal exec flags.
Piped stdin is the prompt when no prompt argument is given, or is appended to it; with a prompt argument, stdin that sends nothing for 2 seconds is ignored with a note on stderr.
")]
Exec(TuiPassthroughArgs),
/// Manage durable Agent fleet runs.
@@ -1957,7 +1958,7 @@ enum ConfigCommand {
Unset {
key: String,
},
/// Review usage reporting by Codewhale and PostHog (default on).
/// Review aggregate usage counting by Codewhale and PostHog (default on).
Telemetry {
/// Optional compatibility form: enable future sessions under this policy version.
#[arg(long, value_name = "VERSION")]
@@ -6792,10 +6793,6 @@ fn apply_tui_env(cli: &Cli, resolved_runtime: &ResolvedRuntimeOptions, passthrou
codewhale_config::TELEMETRY_FLOOR_ENV,
if floor { "1" } else { "0" },
);
set_tui_env(
codewhale_config::TELEMETRY_SOURCE_ENV,
resolved_runtime.telemetry_source.as_str(),
);
if let Some(endpoint) = resolved_runtime.telemetry_endpoint.as_ref() {
set_tui_env("CODEWHALE_TELEMETRY_ENDPOINT", endpoint);
}
@@ -7230,30 +7227,6 @@ mod tests {
);
}
#[test]
fn dispatcher_hands_the_telemetry_source_to_the_tui() {
let _lock = env_lock();
let _guards = [
ScopedEnvVar::remove("CODEWHALE_TELEMETRY"),
ScopedEnvVar::remove("DEEPSEEK_TELEMETRY"),
ScopedEnvVar::remove(codewhale_config::TELEMETRY_FLOOR_ENV),
ScopedEnvVar::remove(codewhale_config::TELEMETRY_SOURCE_ENV),
];
let cli = parse_ok(&["codewhale"]);
let mut runtime = resolved_runtime_for_test(ProviderKind::NvidiaNim, ProviderSource::Cli);
runtime.telemetry = false;
runtime.telemetry_source = codewhale_config::TelemetrySource::Config;
apply_tui_env(&cli, &runtime, &[]);
assert_eq!(std::env::var("CODEWHALE_TELEMETRY").as_deref(), Ok("false"));
assert!(std::env::var(codewhale_config::TELEMETRY_FLOOR_ENV).is_ok());
assert_eq!(
std::env::var(codewhale_config::TELEMETRY_SOURCE_ENV).as_deref(),
Ok("config")
);
}
#[test]
fn clap_command_definition_is_consistent() {
Cli::command().debug_assert();
+2 -129
View File
@@ -425,74 +425,6 @@ pub struct RuntimeRequestStats {
pub provider_reported_output_tokens: u64,
}
#[derive(Debug, Default, serde::Serialize)]
pub struct SessionUsageStats {
pub sessions: u64,
pub usage_receipts: u64,
pub usage_receipts_incomplete: u64,
pub provider_reported_input_tokens: u64,
pub provider_reported_output_tokens: u64,
pub sessions_with_total_tokens_only: u64,
pub total_tokens_only: u64,
}
fn record_session_usage(v: &Value, rollup: &mut Rollup) {
let Some(sources) = v
.pointer("/metadata/cost/accounted_usage_sources")
.and_then(Value::as_object)
else {
let total = v
.pointer("/metadata/total_tokens")
.and_then(Value::as_u64)
.unwrap_or(0);
if total > 0 {
let stats = &mut rollup.session_usage;
stats.sessions_with_total_tokens_only =
stats.sessions_with_total_tokens_only.saturating_add(1);
stats.total_tokens_only = stats.total_tokens_only.saturating_add(total);
}
return;
};
let stats = &mut rollup.session_usage;
let mut counted = false;
for contribution in sources.values() {
let record = contribution.get("record");
let reported = match record
.and_then(|record| record.pointer("/settlement/coverage"))
.and_then(Value::as_str)
{
Some(coverage) => matches!(coverage, "reported" | "incomplete"),
None => record
.and_then(|record| record.pointer("/usage/usage"))
.is_some_and(|usage| {
usage.get("input_tokens").and_then(Value::as_u64) != Some(0)
|| usage.get("output_tokens").and_then(Value::as_u64) != Some(0)
}),
};
if !reported {
continue;
}
let tokens = |name: &str| {
record
.and_then(|record| record.pointer(&format!("/usage/usage/{name}")))
.and_then(Value::as_u64)
};
let (Some(input), Some(output)) = (tokens("input_tokens"), tokens("output_tokens")) else {
stats.usage_receipts_incomplete = stats.usage_receipts_incomplete.saturating_add(1);
continue;
};
stats.usage_receipts = stats.usage_receipts.saturating_add(1);
stats.provider_reported_input_tokens =
stats.provider_reported_input_tokens.saturating_add(input);
stats.provider_reported_output_tokens =
stats.provider_reported_output_tokens.saturating_add(output);
counted = true;
}
if counted {
stats.sessions = stats.sessions.saturating_add(1);
}
}
/// Top-level rollup.
#[derive(Debug, Default, serde::Serialize)]
pub struct Rollup {
@@ -513,7 +445,6 @@ pub struct Rollup {
pub capacity: CapacityStats,
pub credentials: CredentialStats,
pub runtime_requests: RuntimeRequestStats,
pub session_usage: SessionUsageStats,
/// Total lines read across all sources.
pub total_lines: u64,
/// Lines successfully parsed.
@@ -1005,8 +936,6 @@ fn read_session_file(path: &Path, since: Option<DateTime<Utc>>, rollup: &mut Rol
rollup.touch_ts(&ts);
}
record_session_usage(&v, rollup);
// Walk messages looking for tool_use calls with associated results.
let messages = match v.get("messages").and_then(|m| m.as_array()) {
Some(m) => m,
@@ -1711,44 +1640,15 @@ fn print_human(rollup: &Rollup, since: Option<DateTime<Utc>>) {
);
}
if runtime.provider_usage_receipts == 0 {
println!(
"Provider usage receipts (Runtime events): (none recorded; this is not zero usage)"
);
println!("Provider usage receipts: (none recorded; this is not zero usage)");
} else {
println!(
"Provider usage receipts (Runtime events): {} records, {} input tokens, {} output tokens",
"Provider usage receipts: {} records, {} input tokens, {} output tokens",
fmt_num(runtime.provider_usage_receipts),
fmt_num(runtime.provider_reported_input_tokens),
fmt_num(runtime.provider_reported_output_tokens),
);
}
let session_usage = &rollup.session_usage;
if session_usage.usage_receipts == 0 {
println!(
"Provider usage receipts (saved terminal sessions): (none recorded; this is not zero usage)"
);
} else {
println!(
"Provider usage receipts (saved terminal sessions): {} records in {} sessions, {} input tokens, {} output tokens",
fmt_num(session_usage.usage_receipts),
fmt_num(session_usage.sessions),
fmt_num(session_usage.provider_reported_input_tokens),
fmt_num(session_usage.provider_reported_output_tokens),
);
}
if session_usage.sessions_with_total_tokens_only > 0 {
println!(
"Saved sessions with a combined token total only: {} sessions, {} tokens (no per-call usage receipts saved)",
fmt_num(session_usage.sessions_with_total_tokens_only),
fmt_num(session_usage.total_tokens_only),
);
}
if session_usage.usage_receipts_incomplete > 0 {
println!(
"Saved-session usage coverage: {} usage receipts incomplete, not counted as zero",
fmt_num(session_usage.usage_receipts_incomplete),
);
}
if runtime.diagnostics_unavailable_turn_receipts > 0
|| runtime.diagnostics_incomplete_turn_receipts > 0
|| runtime.terminal_receipts_without_identity > 0
@@ -2341,33 +2241,6 @@ mod tests {
assert_eq!(runtime.model_requests_started, 2, "status is not a request");
}
#[test]
fn saved_terminal_session_usage_is_counted_apart_from_runtime_events() {
let session = serde_json::json!({
"metadata": { "cost": { "accounted_usage_sources": {
"a": { "record": { "settlement": { "coverage": "reported" }, "usage": { "usage": { "input_tokens": 40, "output_tokens": 6 } } } },
"b": { "record": { "settlement": { "coverage": "missing" }, "usage": { "usage": { "input_tokens": 0, "output_tokens": 0 } } } },
"c": { "record": { "settlement": { "coverage": "incomplete" }, "usage": { "usage": { "input_tokens": 3 } } } }
} } }
});
let mut rollup = Rollup::default();
record_session_usage(&session, &mut rollup);
let usage = &rollup.session_usage;
assert_eq!(usage.usage_receipts, 1);
assert_eq!(usage.usage_receipts_incomplete, 1);
assert_eq!(usage.sessions, 1);
assert_eq!(usage.provider_reported_input_tokens, 40);
assert_eq!(usage.provider_reported_output_tokens, 6);
record_session_usage(&serde_json::json!({ "metadata": {} }), &mut rollup);
assert_eq!(rollup.session_usage.sessions, 1);
record_session_usage(
&serde_json::json!({ "metadata": { "total_tokens": 900 } }),
&mut rollup,
);
assert_eq!(rollup.session_usage.sessions_with_total_tokens_only, 1);
assert_eq!(rollup.session_usage.total_tokens_only, 900);
}
#[test]
fn runtime_usage_receipts_deduplicate_by_runtime_event_identity() {
let usage = runtime_event(
@@ -39,8 +39,6 @@ pub struct Receipt {
/// own record; empty when no turn recorded one.
pub postures: Vec<&'static str>,
pub totals: ReceiptTotals,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<ReceiptUsage>,
pub actions: Vec<ReceiptAction>,
/// Actions left off the list because it hit [`MAX_RECEIPT_ACTIONS`].
pub omitted_actions: usize,
@@ -49,50 +47,6 @@ pub struct Receipt {
pub claim_ceiling: [&'static str; 3],
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ReceiptUsage {
#[serde(skip_serializing_if = "Option::is_none")]
pub tokens: Option<ReceiptTokens>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cost: Option<ReceiptCost>,
}
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct ReceiptTokens {
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<u64>,
pub total: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_read: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_write: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub calls: Option<usize>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ReceiptCost {
pub state: ReceiptCostState,
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<f64>,
pub currency: &'static str,
pub priced_turns: u32,
pub unpriced_turns: u32,
pub report: String,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReceiptCostState {
Priced,
Partial,
Unknown,
}
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SourceKind {
@@ -263,7 +263,6 @@ fn empty_receipt() -> Receipt {
turn: None,
postures: vec![],
totals: ReceiptTotals::default(),
usage: None,
actions: vec![],
omitted_actions: 0,
not_recorded: vec![],
+12 -17
View File
@@ -217,21 +217,7 @@ pub fn cost(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
};
let projection = diagnostics.cost_projection();
let details = arg.is_some_and(|arg| arg.trim().eq_ignore_ascii_case("details"));
if cost_is_subscription_only(&projection) && !details {
return CommandResult::message(format!(
"Session Cost:\n ─────────────────────────────\n {SUBSCRIPTION_ONLY_COST_NOTE}\n\n Run /cost details for the route receipts."
));
}
match cost_report(&projection, presentation) {
Ok(report) => CommandResult::message(report),
Err(error) => CommandResult::error(error),
}
}
pub const SUBSCRIPTION_ONLY_COST_NOTE: &str = "These routes use subscription billing. Codewhale does not know the amount charged by your provider.";
pub fn cost_is_subscription_only(projection: &DebugCostProjection) -> bool {
projection.total == 0.0
let plan_only = projection.total == 0.0
&& projection.priced_turns == 0
&& projection.unpriced_turns == 0
&& !projection.legacy_coverage_unknown
@@ -241,10 +227,19 @@ pub fn cost_is_subscription_only(projection: &DebugCostProjection) -> bool {
receipt
.split_whitespace()
.any(|field| field == "billing_mode=subscription")
})
});
if plan_only && !details {
return CommandResult::message(
"Session Cost:\n ─────────────────────────────\n These routes use subscription billing. Codewhale does not know the amount charged by your provider.\n\n Run /cost details for the route receipts.".to_string(),
);
}
match cost_report(&projection, presentation) {
Ok(report) => CommandResult::message(report),
Err(error) => CommandResult::error(error),
}
}
pub fn cost_report(
fn cost_report(
cost: &DebugCostProjection,
presentation: &dyn CommandPresentationContext,
) -> Result<String, String> {
@@ -414,53 +414,6 @@ pub fn action_line(action: &ReceiptAction) -> String {
one_line(&line)
}
fn tokens_line(tokens: &ReceiptTokens) -> String {
let mut line = match (tokens.input, tokens.output) {
(Some(input), Some(output)) => {
format!("Tokens: {input} in · {output} out · {} total", tokens.total)
}
_ => format!("Tokens: {} total", tokens.total),
};
if let Some(calls) = tokens.calls {
line.push_str(&format!(
" · {}",
plural(calls, "provider call", "provider calls")
));
}
let detail: Vec<String> = [
("cache read", tokens.cache_read),
("cache write", tokens.cache_write),
("reasoning", tokens.reasoning),
]
.into_iter()
.filter_map(|(label, value)| value.map(|value| format!("{value} {label}")))
.collect();
if !detail.is_empty() {
line.push_str(&format!(" ({})", detail.join(", ")));
}
line
}
fn usage_block(usage: &ReceiptUsage) -> String {
let mut out = String::new();
if let Some(tokens) = &usage.tokens {
out.push_str(&one_line(&tokens_line(tokens)));
out.push('\n');
}
if let Some(cost) = &usage.cost {
out.push_str("Cost, as /cost reports it:\n");
for line in cost
.report
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
{
out.push_str(&format!(" {}\n", one_line(line)));
}
}
out
}
/// The readable receipt: header, totals, one line per action, then what the
/// record does not hold. Valid Markdown and plain enough for a terminal.
#[must_use]
@@ -504,10 +457,6 @@ pub fn render_markdown(receipt: &Receipt) -> String {
out.push_str("\n\n");
out.push_str(&one_line(&totals_line(receipt)));
out.push_str("\n\n");
if let Some(usage) = &receipt.usage {
out.push_str(&usage_block(usage));
out.push('\n');
}
let width = receipt.actions.len().to_string().len();
for action in &receipt.actions {
out.push_str(&format!(
-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"
+49 -70
View File
@@ -354,18 +354,6 @@
"cache_read_per_m": 0.145
},
"reason": "Fireworks hosts DeepSeek V4 Pro at its own published rates, as in the reviewed table in crates/engine/src/pricing.rs; Models.dev lists a flat 1.2/1.2 rate with a 0.6 cache-read rate for this deployment."
},
{
"provider": "google",
"id": "gemini-4-argon",
"max_output": 65536,
"reason": "Google publishes 65,536 output tokens by default for synchronous requests (https://aistudio.google.com/docs/models/gemini-4-argon, read 2026-11-10); Models.dev lists 32768."
},
{
"provider": "google",
"id": "gemini-4-carbon",
"max_output": 65536,
"reason": "Google publishes 65,536 output tokens by default for synchronous requests (https://aistudio.google.com/docs/models/gemini-4-carbon, read 2026-11-10); Models.dev lists 32768."
}
],
"reviewed": {
@@ -407,7 +395,7 @@
"reasoning"
],
"reasoning": true,
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"source": "https://models.dev/catalog.json anthropic/claude-fable-5-2, retrieved 2026-11-10",
"family": "Anthropic"
},
"claude-opus-5-5": {
@@ -1188,9 +1176,6 @@
"context_window": 1000000,
"max_output": 131072
},
"qwen4-max-preview": {
"source": "Token Plan Supported Models table, https://qwencloud.com/pricing/token-plan, verified 2026-11-11: id qwen4-max-preview (New), capabilities Text Generation, Reasoning Model, Visual Understanding; see source_receipts"
},
"step-3.5-flash": {
"context_window": 256000,
"modalities": [
@@ -1644,16 +1629,14 @@
"family": "Google"
},
"gemini-4-argon": {
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"source": "official vendor model docs, verified 2026-10-17; see source_receipts",
"context_window": 1048576,
"max_output": 65536,
"reasoning": true,
"family": "Google"
},
"gemini-4-carbon": {
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"source": "https://models.dev/catalog.json google/gemini-4-carbon, retrieved 2026-11-10",
"context_window": 1048576,
"max_output": 65536,
"max_output": 32768,
"reasoning": true,
"family": "Google"
},
@@ -1891,38 +1874,53 @@
"source": "preserved public presentation facts, not route availability"
},
"mistral-large-4": {
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-11; unreviewed",
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 262144,
"reasoning": true,
"family": "Mistral"
},
"mistral-large-4-0-8": {
"source": "models.dev mistral/mistral-large-4-0-8, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 262144,
"reasoning": true,
"family": "Mistral"
},
"minimax-m3.1-flash": {
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-11; unreviewed",
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-10; unreviewed",
"context_window": 1000000,
"max_output": 524288,
"reasoning": true,
"family": "Minimax"
"family": "MiniMax"
},
"kimi-k3.1-pro": {
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-11; unreviewed",
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 1048576,
"reasoning": true,
"family": "Moonshot"
"family": "Moonshot/Kimi"
},
"kimi-k3.1-pro-highspeed": {
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-11; unreviewed",
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 1048576,
"reasoning": true,
"family": "Moonshot"
"family": "Moonshot/Kimi"
},
"gpt-6.1-terra": {
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-11; unreviewed",
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-10; unreviewed",
"context_window": 1050000,
"max_output": 128000,
"reasoning": true,
"family": "OpenAi"
"family": "OpenAI"
},
"muse-ember-1.0": {
"source": "models.dev meta/muse-ember-1.0, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 524288,
"reasoning": true,
"family": "Meta"
}
},
"official_deepseek_aliases": {
@@ -4180,12 +4178,6 @@
"added_at": "2026-08-02",
"aliases": []
},
{
"id": "qwen4-max-preview",
"label": "Qwen",
"added_at": "2026-11-10",
"aliases": []
},
{
"id": "kimi-for-coding-highspeed",
"label": "Moonshot/Kimi",
@@ -4582,11 +4574,19 @@
{
"id": "mistral-large-4",
"label": "Mistral",
"added_at": "2026-11-05",
"added_at": "2026-10-06",
"aliases": [
"mistral-large-4"
]
},
{
"id": "mistral-large-4-0-8",
"label": "Mistral",
"added_at": "2026-10-06",
"aliases": [
"mistral-large-4-0-8"
]
},
{
"id": "MiniMax-M3.1-Flash",
"label": "MiniMax",
@@ -4618,6 +4618,14 @@
"aliases": [
"gpt-6.1-terra"
]
},
{
"id": "muse-ember-1.0",
"label": "Meta",
"added_at": "2026-11-09",
"aliases": [
"muse-ember-1.0"
]
}
],
"constants": {
@@ -4773,12 +4781,7 @@
"https://developers.openai.com/api/docs/models/gpt-6-sol": "verified 2026-10-17: $2 in / $0.20 cached / $10 out / $2.50 cache write; gpt-6.1-sol $2 / $0.10 / $10 / $2.50 and gpt-6-luna $0.10 / $0.01 / $0.50 per the changelog",
"https://developers.openai.com/api/docs/deprecations": "verified 2026-10-17: gpt-5.3-codex and gpt-5.1 move to gpt-6-sol and gpt-5.4-nano to gpt-6-luna, all shutting down 2027-04-01; the catalog has no lifecycle field, so the rows stay until shutdown",
"https://ai.google.dev/gemini-api/docs/changelog": "verified 2026-10-17: gemini-4-argon GA 2026-10-13, gemini-3.8-flash GA 2026-09-02; gemini-3.7-flash (auto-routed to gemini-3.8-flash) and gemini-3.5-flash (auto-routed to gemini-3.6-flash) deprecated 2026-10-08 and kept only as selector aliases",
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated; the output ceiling is recorded from its own model page (see the aistudio.google.com receipts, verified 2026-11-10)",
"https://aistudio.google.com/docs/models/gemini-4-carbon": "verified 2026-11-10: model code gemini-4-carbon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high, function calling, structured outputs, implicit caching only; thought signatures are not mentioned",
"https://aistudio.google.com/docs/models/gemini-4-argon": "verified 2026-11-10: model code gemini-4-argon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high",
"https://aistudio.google.com/docs/pricing": "verified 2026-11-10: gemini-4-carbon is $2 in / $10 out / $0.10 cached through 2026-11-30 and $4 / $20 / $0.20 from 2026-12-01; the catalog carries one undated price, so the row shows the current rate",
"https://platform.claude.com/docs/en/models/fable-5-2/overview": "verified 2026-11-10: claude-fable-5-2, 1M context, 128K output, adaptive thinking always on, $8 in / $40 out, text and image input",
"https://platform.claude.com/docs/en/build-with-claude/effort": "verified 2026-11-10: xhigh is its own effort level on Fable 5.2, Mythos 5.2, Fable 5.1, Fable 5, Opus 5.5, Opus 5, Opus 4.8, Opus 4.7, Sonnet 5.5, Sonnet 5 and Haiku 5.5; not every model that supports max supports xhigh",
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated but no output ceiling is, so none is recorded",
"https://docs.z.ai/guides/vlm/glm-5.3-flash": "verified 2026-10-17: model code glm-5.3-flash, 1M context, 128K output, thinking.type only supports enabled",
"https://dev.meta.ai/docs/models.md": "verified 2026-10-17: muse-spark-1.3 is recommended for new work, muse-spark-1.2 is the previous version, muse-spark-1.3-contributor has no max effort, muse-wonder-1.0 is a preview; all 1,048,576 context",
"https://dev.meta.ai/docs/pricing-rate-limits": "verified 2026-10-17: Muse Spark 1.3 and 1.2 $1.25 in / $0.15 cached / $4.25 out, contributor $0.10 / $0.002 / $0.20; Muse Wonder 1.0 $4 / $0.20 / $20; no long-context premium",
@@ -4788,8 +4791,7 @@
"https://api.deepinfra.com/models/list": "verified 2026-10-20: deepseek-ai/DeepSeek-V4.1-Flash-1017, deepseek-ai/DeepSeek-V4.1-Flash, deepseek-ai/DeepSeek-V4-Pro-0813 and deepseek-ai/DeepSeek-V4-Flash-0731 are listed and not deprecated, each with max_tokens 1048576 and the tools tag; both V4.1 Flash ids are tagged multimodal and reasoning, V4-Flash-0731 reasoning; V4-Pro-0813 carries can-disable-reasoning beside a non-reasoning tag, while its model page documents low, high and max reasoning effort and the Models.dev deepseek/deepseek-v4-pro-0813 row marks reasoning, so reasoning is recorded; the V4-Pro and V4-Flash previews are still listed; no separate output limit is published, so none is recorded",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4-Pro-0813": "verified 2026-10-20: the ModelScope card describes DeepSeek-V4-Pro-0813 as the official release of DeepSeek-V4-Pro on the preview model structure, whose card lists a 1M context length; no separate ModelScope API-Inference context limit is published there",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4.1-Flash": "verified 2026-10-20: the ModelScope card states contexts of up to one million tokens and a recommended 1M context_window; no separate ModelScope API-Inference context limit is published there",
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred",
"https://qwencloud.com/pricing/token-plan": "verified 2026-11-11: the Supported Models table lists qwen4-max-preview marked New with Text Generation, Reasoning Model and Visual Understanding, so image input is recorded; the page gives no context window, maximum output, tool calling or thinking control, so none is recorded and Codewhale sends no thinking switch"
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred"
},
"transports": [
{
@@ -5062,17 +5064,6 @@
"default_for_provider": false,
"source": "preserved reviewed exact transport snapshot; see source_receipts"
},
{
"provider": "modelstudio-token-plan",
"id": "qwen4-max-preview",
"endpoint_key": "chat",
"default_for_provider": false,
"source": "Token Plan Supported Models table, verified 2026-11-11; see source_receipts",
"limits": {},
"capabilities": {
"image_input": "supported"
}
},
{
"provider": "opencode-go",
"id": "qwen3.8-flash",
@@ -6891,22 +6882,10 @@
"@MODELSTUDIO_TEXT_MODELS"
],
"modelstudio-coding-plan": [
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
"@MODELSTUDIO_TEXT_MODELS"
],
"modelstudio-coding-plan-anthropic": [
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
"@MODELSTUDIO_TEXT_MODELS"
],
"mistral": [
"$DEFAULT_MISTRAL_MODEL",
+119 -63
View File
@@ -9,8 +9,8 @@
"default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline.",
"pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.",
"generated_by": "python3 scripts/catalog_models_dev.py seed render from scripts/catalog/models_dev_seed.toml and scripts/catalog/models_dev_seed.lock.json. Do not edit this file by hand; see docs/CATALOG_REFRESH.md.",
"upstream": "https://models.dev/catalog.json fetched 2026-11-10T21:14:00Z sha256 019e248c87ca384d63f16bfd2fde60f8e26b2c6fa1126927a1dac25fe88c9740",
"coverage": "23 providers, 136 provider model rows, 4 canonical model entries."
"upstream": "https://models.dev/catalog.json fetched 2026-11-10T22:14:03Z sha256 2d85d334089340e30ae6d3c6bbbcd8c7591e27b954e8089494348bb59d1bd7af",
"coverage": "23 providers, 137 provider model rows, 4 canonical model entries."
},
"models": {
"deepseek/deepseek-v4-pro-0813": {
@@ -3348,6 +3348,71 @@
"output": 20,
"cache_read": 0.2
}
},
"muse-ember-1.0": {
"id": "muse-ember-1.0",
"canonical_model_id": "meta/muse-ember-1.0",
"name": "Muse Ember",
"description": "Muse Ember is Meta's smallest and most efficient reasoning model, built for the work agents repeat all day: tool calls, coding subtasks, routing, extraction, and long-document parsing. It pairs with Muse Wonder or Muse Spark, which plan while Muse Ember handles the volume.",
"family": "muse",
"release_date": "2026-11-09",
"last_updated": "2026-11-09",
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": [
"minimal",
"low",
"medium",
"high",
"xhigh",
"max"
]
}
],
"tool_call": true,
"structured_output": true,
"temperature": true,
"open_weights": false,
"modalities": {
"input": [
"text",
"image",
"video",
"pdf",
"audio"
],
"output": [
"text"
]
},
"limit": {
"context": 1048576,
"output": 524288
},
"cost": {
"input": 0.1,
"output": 0.5,
"cache_read": 0.004,
"tiers": [
{
"input": 0.3,
"output": 1.5,
"cache_read": 0.012,
"tier": {
"type": "context",
"size": 200000
}
}
],
"context_over_200k": {
"input": 0.3,
"output": 1.5,
"cache_read": 0.012
}
}
}
}
},
@@ -5112,9 +5177,9 @@
"output": 384000
},
"cost": {
"input": 0.95526,
"output": 1.91052,
"cache_read": 0.079605
"input": 0.894012,
"output": 1.788024,
"cache_read": 0.074501
}
},
"deepseek/deepseek-v4-flash": {
@@ -8019,7 +8084,7 @@
"reasoning"
],
"reasoning": true,
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"source": "https://models.dev/catalog.json anthropic/claude-fable-5-2, retrieved 2026-11-10",
"family": "Anthropic"
},
"claude-opus-5-5": {
@@ -8800,9 +8865,6 @@
"context_window": 1000000,
"max_output": 131072
},
"qwen4-max-preview": {
"source": "Token Plan Supported Models table, https://qwencloud.com/pricing/token-plan, verified 2026-11-11: id qwen4-max-preview (New), capabilities Text Generation, Reasoning Model, Visual Understanding; see source_receipts"
},
"step-3.5-flash": {
"context_window": 256000,
"modalities": [
@@ -9256,16 +9318,14 @@
"family": "Google"
},
"gemini-4-argon": {
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"source": "official vendor model docs, verified 2026-10-17; see source_receipts",
"context_window": 1048576,
"max_output": 65536,
"reasoning": true,
"family": "Google"
},
"gemini-4-carbon": {
"source": "official vendor model docs, verified 2026-11-10; see source_receipts",
"source": "https://models.dev/catalog.json google/gemini-4-carbon, retrieved 2026-11-10",
"context_window": 1048576,
"max_output": 65536,
"max_output": 32768,
"reasoning": true,
"family": "Google"
},
@@ -9503,38 +9563,53 @@
"source": "preserved public presentation facts, not route availability"
},
"mistral-large-4": {
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-11; unreviewed",
"source": "models.dev mistral/mistral-large-4, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 262144,
"reasoning": true,
"family": "Mistral"
},
"mistral-large-4-0-8": {
"source": "models.dev mistral/mistral-large-4-0-8, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 262144,
"reasoning": true,
"family": "Mistral"
},
"minimax-m3.1-flash": {
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-11; unreviewed",
"source": "models.dev minimax/MiniMax-M3.1-Flash, discovered 2026-11-10; unreviewed",
"context_window": 1000000,
"max_output": 524288,
"reasoning": true,
"family": "Minimax"
"family": "MiniMax"
},
"kimi-k3.1-pro": {
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-11; unreviewed",
"source": "models.dev moonshotai/kimi-k3.1-pro, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 1048576,
"reasoning": true,
"family": "Moonshot"
"family": "Moonshot/Kimi"
},
"kimi-k3.1-pro-highspeed": {
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-11; unreviewed",
"source": "models.dev moonshotai/kimi-k3.1-pro-highspeed, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 1048576,
"reasoning": true,
"family": "Moonshot"
"family": "Moonshot/Kimi"
},
"gpt-6.1-terra": {
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-11; unreviewed",
"source": "models.dev openai/gpt-6.1-terra, discovered 2026-11-10; unreviewed",
"context_window": 1050000,
"max_output": 128000,
"reasoning": true,
"family": "OpenAi"
"family": "OpenAI"
},
"muse-ember-1.0": {
"source": "models.dev meta/muse-ember-1.0, discovered 2026-11-10; unreviewed",
"context_window": 1048576,
"max_output": 524288,
"reasoning": true,
"family": "Meta"
}
},
"official_deepseek_aliases": {
@@ -11792,12 +11867,6 @@
"added_at": "2026-08-02",
"aliases": []
},
{
"id": "qwen4-max-preview",
"label": "Qwen",
"added_at": "2026-11-10",
"aliases": []
},
{
"id": "kimi-for-coding-highspeed",
"label": "Moonshot/Kimi",
@@ -12194,11 +12263,19 @@
{
"id": "mistral-large-4",
"label": "Mistral",
"added_at": "2026-11-05",
"added_at": "2026-10-06",
"aliases": [
"mistral-large-4"
]
},
{
"id": "mistral-large-4-0-8",
"label": "Mistral",
"added_at": "2026-10-06",
"aliases": [
"mistral-large-4-0-8"
]
},
{
"id": "MiniMax-M3.1-Flash",
"label": "MiniMax",
@@ -12230,6 +12307,14 @@
"aliases": [
"gpt-6.1-terra"
]
},
{
"id": "muse-ember-1.0",
"label": "Meta",
"added_at": "2026-11-09",
"aliases": [
"muse-ember-1.0"
]
}
],
"constants": {
@@ -12385,12 +12470,7 @@
"https://developers.openai.com/api/docs/models/gpt-6-sol": "verified 2026-10-17: $2 in / $0.20 cached / $10 out / $2.50 cache write; gpt-6.1-sol $2 / $0.10 / $10 / $2.50 and gpt-6-luna $0.10 / $0.01 / $0.50 per the changelog",
"https://developers.openai.com/api/docs/deprecations": "verified 2026-10-17: gpt-5.3-codex and gpt-5.1 move to gpt-6-sol and gpt-5.4-nano to gpt-6-luna, all shutting down 2027-04-01; the catalog has no lifecycle field, so the rows stay until shutdown",
"https://ai.google.dev/gemini-api/docs/changelog": "verified 2026-10-17: gemini-4-argon GA 2026-10-13, gemini-3.8-flash GA 2026-09-02; gemini-3.7-flash (auto-routed to gemini-3.8-flash) and gemini-3.5-flash (auto-routed to gemini-3.6-flash) deprecated 2026-10-08 and kept only as selector aliases",
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated; the output ceiling is recorded from its own model page (see the aistudio.google.com receipts, verified 2026-11-10)",
"https://aistudio.google.com/docs/models/gemini-4-carbon": "verified 2026-11-10: model code gemini-4-carbon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high, function calling, structured outputs, implicit caching only; thought signatures are not mentioned",
"https://aistudio.google.com/docs/models/gemini-4-argon": "verified 2026-11-10: model code gemini-4-argon, 1,048,576 context, 65,536 output by default (up to 1,048,576 with long decode), thinking levels low, medium and high",
"https://aistudio.google.com/docs/pricing": "verified 2026-11-10: gemini-4-carbon is $2 in / $10 out / $0.10 cached through 2026-11-30 and $4 / $20 / $0.20 from 2026-12-01; the catalog carries one undated price, so the row shows the current rate",
"https://platform.claude.com/docs/en/models/fable-5-2/overview": "verified 2026-11-10: claude-fable-5-2, 1M context, 128K output, adaptive thinking always on, $8 in / $40 out, text and image input",
"https://platform.claude.com/docs/en/build-with-claude/effort": "verified 2026-11-10: xhigh is its own effort level on Fable 5.2, Mythos 5.2, Fable 5.1, Fable 5, Opus 5.5, Opus 5, Opus 4.8, Opus 4.7, Sonnet 5.5, Sonnet 5 and Haiku 5.5; not every model that supports max supports xhigh",
"https://ai.google.dev/gemini-api/docs/models": "verified 2026-10-17: gemini-4-argon and gemini-3.8-flash are stable; argon's 1M context is stated but no output ceiling is, so none is recorded",
"https://docs.z.ai/guides/vlm/glm-5.3-flash": "verified 2026-10-17: model code glm-5.3-flash, 1M context, 128K output, thinking.type only supports enabled",
"https://dev.meta.ai/docs/models.md": "verified 2026-10-17: muse-spark-1.3 is recommended for new work, muse-spark-1.2 is the previous version, muse-spark-1.3-contributor has no max effort, muse-wonder-1.0 is a preview; all 1,048,576 context",
"https://dev.meta.ai/docs/pricing-rate-limits": "verified 2026-10-17: Muse Spark 1.3 and 1.2 $1.25 in / $0.15 cached / $4.25 out, contributor $0.10 / $0.002 / $0.20; Muse Wonder 1.0 $4 / $0.20 / $20; no long-context premium",
@@ -12400,8 +12480,7 @@
"https://api.deepinfra.com/models/list": "verified 2026-10-20: deepseek-ai/DeepSeek-V4.1-Flash-1017, deepseek-ai/DeepSeek-V4.1-Flash, deepseek-ai/DeepSeek-V4-Pro-0813 and deepseek-ai/DeepSeek-V4-Flash-0731 are listed and not deprecated, each with max_tokens 1048576 and the tools tag; both V4.1 Flash ids are tagged multimodal and reasoning, V4-Flash-0731 reasoning; V4-Pro-0813 carries can-disable-reasoning beside a non-reasoning tag, while its model page documents low, high and max reasoning effort and the Models.dev deepseek/deepseek-v4-pro-0813 row marks reasoning, so reasoning is recorded; the V4-Pro and V4-Flash previews are still listed; no separate output limit is published, so none is recorded",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4-Pro-0813": "verified 2026-10-20: the ModelScope card describes DeepSeek-V4-Pro-0813 as the official release of DeepSeek-V4-Pro on the preview model structure, whose card lists a 1M context length; no separate ModelScope API-Inference context limit is published there",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4.1-Flash": "verified 2026-10-20: the ModelScope card states contexts of up to one million tokens and a recommended 1M context_window; no separate ModelScope API-Inference context limit is published there",
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred",
"https://qwencloud.com/pricing/token-plan": "verified 2026-11-11: the Supported Models table lists qwen4-max-preview marked New with Text Generation, Reasoning Model and Visual Understanding, so image input is recorded; the page gives no context window, maximum output, tool calling or thinking control, so none is recorded and Codewhale sends no thinking switch"
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred"
},
"transports": [
{
@@ -12674,17 +12753,6 @@
"default_for_provider": false,
"source": "preserved reviewed exact transport snapshot; see source_receipts"
},
{
"provider": "modelstudio-token-plan",
"id": "qwen4-max-preview",
"endpoint_key": "chat",
"default_for_provider": false,
"source": "Token Plan Supported Models table, verified 2026-11-11; see source_receipts",
"limits": {},
"capabilities": {
"image_input": "supported"
}
},
{
"provider": "opencode-go",
"id": "qwen3.8-flash",
@@ -14503,22 +14571,10 @@
"@MODELSTUDIO_TEXT_MODELS"
],
"modelstudio-coding-plan": [
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
"@MODELSTUDIO_TEXT_MODELS"
],
"modelstudio-coding-plan-anthropic": [
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
"@MODELSTUDIO_TEXT_MODELS"
],
"mistral": [
"$DEFAULT_MISTRAL_MODEL",
+1 -1
View File
@@ -614,7 +614,7 @@ mod tests {
let reviewed = bundled_reviewed();
reviewed.validate().expect("committed data");
assert_eq!(reviewed.selections.len(), 188);
assert_eq!(reviewed.transports.len(), 123);
assert_eq!(reviewed.transports.len(), 122);
let go = reviewed_transport("opencode-go", "gpt-5.6-luna").unwrap();
assert_eq!(go.endpoint_key, "responses");
assert_eq!(
+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.
+325 -87
View File
@@ -140,17 +140,48 @@ impl EnvRuntimeOverrides {
Self {
provider,
provider_source,
model: env_non_blank(&["CODEWHALE_MODEL", "DEEPSEEK_MODEL", "DEEPSEEK_DEFAULT_TEXT_MODEL"]),
volcengine_model: env_non_blank(&["VOLCENGINE_MODEL", "VOLCENGINE_ARK_MODEL"]),
wanjie_ark_model: env_non_blank(&["WANJIE_ARK_MODEL", "WANJIE_MODEL", "WANJIE_MAAS_MODEL"]),
openrouter_model: env_non_blank(&["OPENROUTER_MODEL"]),
orcarouter_model: env_non_blank(&["ORCAROUTER_MODEL"]),
moonshot_model: env_non_blank(&["MOONSHOT_MODEL", "KIMI_MODEL_NAME", "KIMI_MODEL"]),
xiaomi_mimo_model: env_non_blank(&["XIAOMI_MIMO_MODEL", "MIMO_MODEL"]),
xiaomi_mimo_mode: env_non_blank(&["XIAOMI_MIMO_MODE", "MIMO_MODE"]),
novita_model: env_non_blank(&["NOVITA_MODEL"]),
fireworks_model: env_non_blank(&["FIREWORKS_MODEL"]),
arcee_model: env_non_blank(&["ARCEE_MODEL"]),
model: std::env::var("CODEWHALE_MODEL")
.or_else(|_| std::env::var("DEEPSEEK_MODEL"))
.or_else(|_| std::env::var("DEEPSEEK_DEFAULT_TEXT_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_model: std::env::var("VOLCENGINE_MODEL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_model: std::env::var("WANJIE_ARK_MODEL")
.or_else(|_| std::env::var("WANJIE_MODEL"))
.or_else(|_| std::env::var("WANJIE_MAAS_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_model: std::env::var("OPENROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_model: std::env::var("ORCAROUTER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_model: std::env::var("MOONSHOT_MODEL")
.or_else(|_| std::env::var("KIMI_MODEL_NAME"))
.or_else(|_| std::env::var("KIMI_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_model: std::env::var("XIAOMI_MIMO_MODEL")
.or_else(|_| std::env::var("MIMO_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_mode: std::env::var("XIAOMI_MIMO_MODE")
.or_else(|_| std::env::var("MIMO_MODE"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_model: std::env::var("NOVITA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_model: std::env::var("FIREWORKS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_model: std::env::var("ARCEE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
verbosity: env_non_blank(&["CODEWHALE_VERBOSITY", "DEEPSEEK_VERBOSITY"]),
auth_mode: env_non_blank(&["CODEWHALE_AUTH_MODE", "DEEPSEEK_AUTH_MODE"]),
log_level: env_non_blank(&["CODEWHALE_LOG_LEVEL", "DEEPSEEK_LOG_LEVEL"]),
@@ -185,7 +216,9 @@ impl EnvRuntimeOverrides {
None
}
}),
http_headers: env_non_blank(&["CODEWHALE_HTTP_HEADERS", "DEEPSEEK_HTTP_HEADERS"])
http_headers: std::env::var("CODEWHALE_HTTP_HEADERS")
.or_else(|_| std::env::var("DEEPSEEK_HTTP_HEADERS"))
.ok()
.and_then(|value| match parse_http_headers(&value) {
Ok(h) => Some(h),
Err(_) => {
@@ -194,85 +227,290 @@ impl EnvRuntimeOverrides {
}
})
.filter(|headers| !headers.is_empty()),
active_route_base_url: env_non_blank(&["CODEWHALE_BASE_URL", "DEEPSEEK_BASE_URL"]),
deepseek_anthropic_base_url: env_non_blank(&["DEEPSEEK_ANTHROPIC_BASE_URL", "DEEPSEEK_CLAUDE_BASE_URL"]),
nvidia_base_url: env_non_blank(&["NVIDIA_NIM_BASE_URL", "NIM_BASE_URL", "NVIDIA_BASE_URL"]),
openai_base_url: env_non_blank(&["OPENAI_BASE_URL"]),
atlascloud_base_url: env_non_blank(&["ATLASCLOUD_BASE_URL"]),
volcengine_base_url: env_non_blank(&["VOLCENGINE_BASE_URL", "VOLCENGINE_ARK_BASE_URL", "ARK_BASE_URL"]),
wanjie_ark_base_url: env_non_blank(&["WANJIE_ARK_BASE_URL", "WANJIE_BASE_URL", "WANJIE_MAAS_BASE_URL"]),
openrouter_base_url: env_non_blank(&["OPENROUTER_BASE_URL"]),
orcarouter_base_url: env_non_blank(&["ORCAROUTER_BASE_URL"]),
xiaomi_mimo_base_url: env_non_blank(&["XIAOMI_MIMO_BASE_URL", "MIMO_BASE_URL"]),
novita_base_url: env_non_blank(&["NOVITA_BASE_URL"]),
fireworks_base_url: env_non_blank(&["FIREWORKS_BASE_URL"]),
siliconflow_base_url: env_non_blank(&["SILICONFLOW_BASE_URL"]),
siliconflow_model: env_non_blank(&["SILICONFLOW_MODEL"]),
arcee_base_url: env_non_blank(&["ARCEE_BASE_URL"]),
moonshot_base_url: env_non_blank(&["MOONSHOT_BASE_URL", "KIMI_BASE_URL"]),
sglang_base_url: env_non_blank(&["SGLANG_BASE_URL"]),
vllm_base_url: env_non_blank(&["VLLM_BASE_URL"]),
ollama_base_url: env_non_blank(&["OLLAMA_BASE_URL"]),
ollama_cloud_base_url: env_non_blank(&["OLLAMA_CLOUD_BASE_URL"]),
ollama_cloud_model: env_non_blank(&["OLLAMA_CLOUD_MODEL"]),
huggingface_base_url: env_non_blank(&["HUGGINGFACE_BASE_URL", "HF_BASE_URL"]),
huggingface_model: env_non_blank(&["HUGGINGFACE_MODEL", "HF_MODEL"]),
modelscope_base_url: env_non_blank(&["MODELSCOPE_BASE_URL"]),
modelscope_model: env_non_blank(&["MODELSCOPE_MODEL"]),
together_base_url: env_non_blank(&["TOGETHER_BASE_URL"]),
together_model: env_non_blank(&["TOGETHER_MODEL"]),
qianfan_base_url: env_non_blank(&["QIANFAN_BASE_URL", "BAIDU_QIANFAN_BASE_URL"]),
qianfan_model: env_non_blank(&["QIANFAN_MODEL", "BAIDU_QIANFAN_MODEL"]),
openai_codex_base_url: env_non_blank(&["OPENAI_CODEX_BASE_URL", "CODEX_BASE_URL"]),
openai_codex_model: env_non_blank(&["OPENAI_CODEX_MODEL", "CODEX_MODEL"]),
anthropic_base_url: env_non_blank(&["ANTHROPIC_BASE_URL"]),
anthropic_model: env_non_blank(&["ANTHROPIC_MODEL"]),
openmodel_base_url: env_non_blank(&["OPENMODEL_BASE_URL"]),
openmodel_model: env_non_blank(&["OPENMODEL_MODEL"]),
zai_base_url: env_non_blank(&["ZAI_BASE_URL", "Z_AI_BASE_URL", "ZHIPU_BASE_URL", "ZHIPUAI_BASE_URL", "BIGMODEL_BASE_URL"]),
zai_model: env_non_blank(&["ZAI_MODEL", "Z_AI_MODEL", "ZHIPU_MODEL", "ZHIPUAI_MODEL", "BIGMODEL_MODEL", "GLM_MODEL"]),
stepfun_base_url: env_non_blank(&["STEPFUN_BASE_URL", "STEP_BASE_URL"]),
stepfun_model: env_non_blank(&["STEPFUN_MODEL", "STEP_MODEL"]),
minimax_base_url: env_non_blank(&["MINIMAX_BASE_URL"]),
minimax_anthropic_base_url: env_non_blank(&["MINIMAX_ANTHROPIC_BASE_URL"]),
minimax_model: env_non_blank(&["MINIMAX_MODEL"]),
deepinfra_base_url: env_non_blank(&["DEEPINFRA_BASE_URL"]),
deepinfra_model: env_non_blank(&["DEEPINFRA_MODEL"]),
sakana_base_url: env_non_blank(&["SAKANA_BASE_URL"]),
sakana_model: env_non_blank(&["SAKANA_MODEL"]),
longcat_base_url: env_non_blank(&["LONGCAT_BASE_URL"]),
longcat_model: env_non_blank(&["LONGCAT_MODEL"]),
opencode_go_base_url: env_non_blank(&["OPENCODE_GO_BASE_URL"]),
opencode_go_model: env_non_blank(&["OPENCODE_GO_MODEL"]),
opencode_zen_base_url: env_non_blank(&["OPENCODE_ZEN_BASE_URL"]),
opencode_zen_model: env_non_blank(&["OPENCODE_ZEN_MODEL"]),
meta_base_url: env_non_blank(&["META_MODEL_API_BASE_URL", "MODEL_API_BASE_URL"]),
meta_model: env_non_blank(&["META_MODEL_API_MODEL", "MODEL_API_MODEL"]),
xai_base_url: env_non_blank(&["XAI_BASE_URL"]),
xai_model: env_non_blank(&["XAI_MODEL"]),
google_base_url: env_non_blank(&["GOOGLE_BASE_URL", "GEMINI_BASE_URL"]),
google_model: env_non_blank(&["GOOGLE_MODEL", "GEMINI_MODEL"]),
mistral_base_url: env_non_blank(&["MISTRAL_BASE_URL"]),
mistral_model: env_non_blank(&["MISTRAL_MODEL"]),
telecomjs_base_url: env_non_blank(&["TELECOMJS_BASE_URL"]),
telecomjs_model: env_non_blank(&["TELECOMJS_MODEL"]),
edenai_base_url: env_non_blank(&["EDENAI_BASE_URL"]),
edenai_model: env_non_blank(&["EDENAI_MODEL"]),
zenmux_base_url: env_non_blank(&["ZENMUX_BASE_URL"]),
zenmux_model: env_non_blank(&["ZENMUX_MODEL"]),
csdn_base_url: env_non_blank(&["CSDN_BASE_URL"]),
csdn_model: env_non_blank(&["CSDN_MODEL"]),
concentrate_base_url: env_non_blank(&["CONCENTRATE_BASE_URL"]),
concentrate_model: env_non_blank(&["CONCENTRATE_MODEL"]),
active_route_base_url: std::env::var("CODEWHALE_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
deepseek_anthropic_base_url: std::env::var("DEEPSEEK_ANTHROPIC_BASE_URL")
.or_else(|_| std::env::var("DEEPSEEK_CLAUDE_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
nvidia_base_url: std::env::var("NVIDIA_NIM_BASE_URL")
.or_else(|_| std::env::var("NIM_BASE_URL"))
.or_else(|_| std::env::var("NVIDIA_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_base_url: std::env::var("OPENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
atlascloud_base_url: std::env::var("ATLASCLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
volcengine_base_url: std::env::var("VOLCENGINE_BASE_URL")
.or_else(|_| std::env::var("VOLCENGINE_ARK_BASE_URL"))
.or_else(|_| std::env::var("ARK_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
wanjie_ark_base_url: std::env::var("WANJIE_ARK_BASE_URL")
.or_else(|_| std::env::var("WANJIE_BASE_URL"))
.or_else(|_| std::env::var("WANJIE_MAAS_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openrouter_base_url: std::env::var("OPENROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
orcarouter_base_url: std::env::var("ORCAROUTER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xiaomi_mimo_base_url: std::env::var("XIAOMI_MIMO_BASE_URL")
.or_else(|_| std::env::var("MIMO_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
novita_base_url: std::env::var("NOVITA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
fireworks_base_url: std::env::var("FIREWORKS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_base_url: std::env::var("SILICONFLOW_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
siliconflow_model: std::env::var("SILICONFLOW_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
arcee_base_url: std::env::var("ARCEE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
moonshot_base_url: std::env::var("MOONSHOT_BASE_URL")
.or_else(|_| std::env::var("KIMI_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
sglang_base_url: std::env::var("SGLANG_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
vllm_base_url: std::env::var("VLLM_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_base_url: std::env::var("OLLAMA_CLOUD_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_cloud_model: std::env::var("OLLAMA_CLOUD_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_base_url: std::env::var("HUGGINGFACE_BASE_URL")
.or_else(|_| std::env::var("HF_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
huggingface_model: std::env::var("HUGGINGFACE_MODEL")
.or_else(|_| std::env::var("HF_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_base_url: std::env::var("MODELSCOPE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelscope_model: std::env::var("MODELSCOPE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_base_url: std::env::var("TOGETHER_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
together_model: std::env::var("TOGETHER_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
qianfan_base_url: std::env::var("QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
qianfan_model: std::env::var("QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("BAIDU_QIANFAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
openai_codex_base_url: std::env::var("OPENAI_CODEX_BASE_URL")
.or_else(|_| std::env::var("CODEX_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
openai_codex_model: std::env::var("OPENAI_CODEX_MODEL")
.or_else(|_| std::env::var("CODEX_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_base_url: std::env::var("ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
anthropic_model: std::env::var("ANTHROPIC_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_base_url: std::env::var("OPENMODEL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
openmodel_model: std::env::var("OPENMODEL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zai_base_url: std::env::var("ZAI_BASE_URL")
.or_else(|_| std::env::var("Z_AI_BASE_URL"))
.or_else(|_| std::env::var("ZHIPU_BASE_URL"))
.or_else(|_| std::env::var("ZHIPUAI_BASE_URL"))
.or_else(|_| std::env::var("BIGMODEL_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
zai_model: std::env::var("ZAI_MODEL")
.or_else(|_| std::env::var("Z_AI_MODEL"))
.or_else(|_| std::env::var("ZHIPU_MODEL"))
.or_else(|_| std::env::var("ZHIPUAI_MODEL"))
.or_else(|_| std::env::var("BIGMODEL_MODEL"))
.or_else(|_| std::env::var("GLM_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_base_url: std::env::var("STEPFUN_BASE_URL")
.or_else(|_| std::env::var("STEP_BASE_URL"))
.ok()
.filter(|v| !v.trim().is_empty()),
stepfun_model: std::env::var("STEPFUN_MODEL")
.or_else(|_| std::env::var("STEP_MODEL"))
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_base_url: std::env::var("MINIMAX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_anthropic_base_url: std::env::var("MINIMAX_ANTHROPIC_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
minimax_model: std::env::var("MINIMAX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_base_url: std::env::var("DEEPINFRA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
deepinfra_model: std::env::var("DEEPINFRA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_base_url: std::env::var("SAKANA_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
sakana_model: std::env::var("SAKANA_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_base_url: std::env::var("LONGCAT_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
longcat_model: std::env::var("LONGCAT_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_base_url: std::env::var("OPENCODE_GO_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_go_model: std::env::var("OPENCODE_GO_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_base_url: std::env::var("OPENCODE_ZEN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
opencode_zen_model: std::env::var("OPENCODE_ZEN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
meta_base_url: std::env::var("META_MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
meta_model: std::env::var("META_MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("MODEL_API_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
xai_base_url: std::env::var("XAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
xai_model: std::env::var("XAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
google_base_url: std::env::var("GOOGLE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
google_model: std::env::var("GOOGLE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
.or_else(|| {
std::env::var("GEMINI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty())
}),
mistral_base_url: std::env::var("MISTRAL_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
mistral_model: std::env::var("MISTRAL_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_base_url: std::env::var("TELECOMJS_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
telecomjs_model: std::env::var("TELECOMJS_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_base_url: std::env::var("EDENAI_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
edenai_model: std::env::var("EDENAI_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_base_url: std::env::var("ZENMUX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
zenmux_model: std::env::var("ZENMUX_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_base_url: std::env::var("CSDN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
csdn_model: std::env::var("CSDN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_base_url: std::env::var("CONCENTRATE_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
concentrate_model: std::env::var("CONCENTRATE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
// `CODEWHALE_API_BASE` is a trust boundary, not a plain string:
// an origin this route would refuse to send a bearer to is
// dropped here rather than resolved into a route.
codewhale_base_url: provider::codewhale_api_base_from_env(),
codewhale_model: env_non_blank(&["CODEWHALE_MODEL"]),
modelstudio_token_plan_base_url: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_BASE_URL"]),
modelstudio_token_plan_model: env_non_blank(&["MODELSTUDIO_TOKEN_PLAN_MODEL"]),
modelstudio_coding_plan_base_url: env_non_blank(&["MODELSTUDIO_CODING_PLAN_BASE_URL"]),
modelstudio_coding_plan_model: env_non_blank(&["MODELSTUDIO_CODING_PLAN_MODEL"]),
codewhale_model: std::env::var("CODEWHALE_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_base_url: std::env::var("MODELSTUDIO_TOKEN_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_token_plan_model: std::env::var("MODELSTUDIO_TOKEN_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_base_url: std::env::var("MODELSTUDIO_CODING_PLAN_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
modelstudio_coding_plan_model: std::env::var("MODELSTUDIO_CODING_PLAN_MODEL")
.ok()
.filter(|v| !v.trim().is_empty()),
}
}
+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 -2
View File
@@ -1313,7 +1313,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
def(
"telemetry",
SettingKind::Bool(TELEMETRY),
"true",
"false",
ui(
TAB_TRUST,
"network",
@@ -1520,7 +1520,6 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
// Trust posture with a `/set` entry point but no row; surfaced where
// sandboxing acts, not as a settings sentence.
def("sandbox_mode", SettingKind::String, "", None),
def("agents", SettingKind::String, "", None),
// Route memory written by the pickers: per-provider defaults, enabled
// chooser sets, and pinned routes. Structured values no row could edit.
def("provider_models", SettingKind::String, "", None),
+7 -6
View File
@@ -27,11 +27,10 @@ pub fn resolve_stream_open_timeout(configured_secs: Option<u64>) -> std::time::D
secs.clamp(MIN_STREAM_OPEN_TIMEOUT_SECS, MAX_STREAM_OPEN_TIMEOUT_SECS),
),
_ => stream_open_timeout_from_env(
crate::env_overrides::env_non_blank(&[
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
])
.as_deref(),
std::env::var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS")
.or_else(|_| std::env::var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS"))
.ok()
.as_deref(),
),
}
}
@@ -51,7 +50,9 @@ pub fn stream_open_timeout_from_env(value: Option<&str>) -> std::time::Duration
/// `false`, ...) leaves HTTP/2 on.
#[must_use]
pub fn force_http1_from_env() -> bool {
crate::env_overrides::env_non_blank(&["CODEWHALE_FORCE_HTTP1", "DEEPSEEK_FORCE_HTTP1"])
std::env::var("CODEWHALE_FORCE_HTTP1")
.or_else(|_| std::env::var("DEEPSEEK_FORCE_HTTP1"))
.ok()
.map(|v| v.trim().to_ascii_lowercase())
.is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
}
-28
View File
@@ -33,8 +33,6 @@ pub const LEGACY_CLI_API_KEY_SOURCE_ENV: &str = "DEEPSEEK_API_KEY_SOURCE";
/// [`CLI_API_KEY_SOURCE_ENV`]. Set to `1`/`0` on every delegated run.
pub const TELEMETRY_FLOOR_ENV: &str = "CODEWHALE_TELEMETRY_FLOOR";
pub const TELEMETRY_SOURCE_ENV: &str = "CODEWHALE_TELEMETRY_SOURCE";
/// Whether an environment-level kill switch forces telemetry off here.
///
/// A floor is *not* the same as "telemetry resolved to false": off is the
@@ -94,32 +92,6 @@ impl TelemetrySource {
Self::Default => "default",
}
}
#[must_use]
pub fn from_label(label: &str) -> Option<Self> {
match label {
"cli" => Some(Self::Cli),
"env" => Some(Self::Env),
"config" => Some(Self::Config),
"default" => Some(Self::Default),
_ => None,
}
}
}
#[must_use]
pub fn dispatched_telemetry_source() -> Option<TelemetrySource> {
parse_bool(&std::env::var(TELEMETRY_FLOOR_ENV).ok()?).ok()?;
TelemetrySource::from_label(&std::env::var(TELEMETRY_SOURCE_ENV).ok()?)
}
#[must_use]
pub fn dispatched_telemetry_consent(file_telemetry: Option<bool>) -> (bool, TelemetrySource) {
let (on, source) = resolved_telemetry_consent(file_telemetry);
match (source, dispatched_telemetry_source()) {
(TelemetrySource::Env, Some(declared)) => (on, declared),
_ => (on, source),
}
}
/// Read the telemetry environment override, reporting an unreadable value
-120
View File
@@ -8763,7 +8763,6 @@ struct TelemetryEnvGuard {
codewhale: Option<OsString>,
deepseek: Option<OsString>,
floor: Option<OsString>,
source: Option<OsString>,
codewhale_endpoint: Option<OsString>,
deepseek_endpoint: Option<OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
@@ -8776,7 +8775,6 @@ impl TelemetryEnvGuard {
codewhale: env::var_os("CODEWHALE_TELEMETRY"),
deepseek: env::var_os("DEEPSEEK_TELEMETRY"),
floor: env::var_os(TELEMETRY_FLOOR_ENV),
source: env::var_os(TELEMETRY_SOURCE_ENV),
// The endpoint variables are cleared too. Resolution now has a
// shipped default, so an ambient endpoint in the developer's or
// CI's environment is the difference between pinning the default
@@ -8790,7 +8788,6 @@ impl TelemetryEnvGuard {
env::remove_var("CODEWHALE_TELEMETRY");
env::remove_var("DEEPSEEK_TELEMETRY");
env::remove_var(TELEMETRY_FLOOR_ENV);
env::remove_var(TELEMETRY_SOURCE_ENV);
env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT");
env::remove_var("DEEPSEEK_TELEMETRY_ENDPOINT");
}
@@ -8811,13 +8808,6 @@ impl TelemetryEnvGuard {
}
}
fn set_source(&self, value: &str) {
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
env::set_var(TELEMETRY_SOURCE_ENV, value);
}
}
fn set_floor(&self, value: &str) {
// Safety: test-only environment mutation guarded by the module mutex.
unsafe {
@@ -8831,7 +8821,6 @@ impl TelemetryEnvGuard {
env::remove_var("CODEWHALE_TELEMETRY");
env::remove_var("DEEPSEEK_TELEMETRY");
env::remove_var(TELEMETRY_FLOOR_ENV);
env::remove_var(TELEMETRY_SOURCE_ENV);
}
}
}
@@ -8852,10 +8841,6 @@ impl Drop for TelemetryEnvGuard {
Some(value) => env::set_var(TELEMETRY_FLOOR_ENV, value),
None => env::remove_var(TELEMETRY_FLOOR_ENV),
}
match self.source.take() {
Some(value) => env::set_var(TELEMETRY_SOURCE_ENV, value),
None => env::remove_var(TELEMETRY_SOURCE_ENV),
}
match self.codewhale_endpoint.take() {
Some(value) => env::set_var("CODEWHALE_TELEMETRY_ENDPOINT", value),
None => env::remove_var("CODEWHALE_TELEMETRY_ENDPOINT"),
@@ -9010,57 +8995,6 @@ fn telemetry_consent_names_its_source() {
assert_eq!(source, TelemetrySource::Env);
}
#[test]
fn dispatched_consent_names_the_dispatchers_source_not_its_hand_off() {
let guard = TelemetryEnvGuard::take();
guard.set("false");
guard.set_floor("0");
guard.set_source("config");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(source, TelemetrySource::Config);
guard.set("false");
guard.set_floor("1");
guard.set_source("env");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(source, TelemetrySource::Env);
guard.set("true");
guard.set_floor("0");
guard.set_source("default");
let (on, source) = dispatched_telemetry_consent(None);
assert!(on);
assert_eq!(source, TelemetrySource::Default);
guard.set("false");
guard.set_floor("1");
guard.set_source("cli");
let (on, source) = dispatched_telemetry_consent(None);
assert!(!on);
assert_eq!(source, TelemetrySource::Cli);
guard.clear();
guard.set("0");
guard.set_source("config");
let (on, source) = dispatched_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(
source,
TelemetrySource::Env,
"a source with no dispatcher floor statement is not believed"
);
guard.clear();
guard.set("0");
guard.set_floor("1");
guard.set_source("nonsense");
let (_, source) = dispatched_telemetry_consent(None);
assert_eq!(source, TelemetrySource::Env);
}
#[test]
fn resolved_runtime_options_mints_a_route_candidate() {
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
@@ -10767,60 +10701,6 @@ fn blank_or_unrecognized_env_overrides_do_not_shadow_config_or_legacy_vars() {
));
}
#[test]
fn blank_primary_env_vars_fall_through_to_their_legacy_aliases() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let names = [
"CODEWHALE_FORCE_HTTP1",
"DEEPSEEK_FORCE_HTTP1",
"CODEWHALE_STREAM_OPEN_TIMEOUT_SECS",
"DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS",
];
let saved: Vec<(&str, Option<OsString>)> = names
.iter()
.map(|name| (*name, env::var_os(name)))
.collect();
// SAFETY: env mutation is serialized by `env_lock` and restored below.
unsafe {
env::set_var("CODEWHALE_MODEL", "");
env::set_var("DEEPSEEK_MODEL", "legacy-model");
env::set_var("CODEWHALE_BASE_URL", " ");
env::set_var("DEEPSEEK_BASE_URL", "https://legacy.example/v1");
env::set_var("MOONSHOT_BASE_URL", "");
env::set_var("KIMI_BASE_URL", "https://kimi.example/v1");
env::set_var("CODEWHALE_FORCE_HTTP1", "");
env::set_var("DEEPSEEK_FORCE_HTTP1", "1");
env::set_var("CODEWHALE_STREAM_OPEN_TIMEOUT_SECS", "");
env::set_var("DEEPSEEK_STREAM_OPEN_TIMEOUT_SECS", "120");
}
let env_overrides = EnvRuntimeOverrides::load();
let force_http1 = crate::stream_timeouts::force_http1_from_env();
let open_timeout = crate::stream_timeouts::resolve_stream_open_timeout(None);
unsafe {
for (name, value) in saved {
match value {
Some(value) => env::set_var(name, value),
None => env::remove_var(name),
}
}
}
assert_eq!(env_overrides.model.as_deref(), Some("legacy-model"));
assert_eq!(
env_overrides.active_route_base_url.as_deref(),
Some("https://legacy.example/v1")
);
assert_eq!(
env_overrides.moonshot_base_url.as_deref(),
Some("https://kimi.example/v1")
);
assert!(force_http1);
assert_eq!(open_timeout, std::time::Duration::from_secs(120));
}
#[cfg(unix)]
#[test]
fn failed_legacy_state_copy_leaves_no_partial_primary() {
+2
View File
@@ -14,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
}
}
Loaded 100 of 976 files, more files were not shown because too many files have changed in this diff. Show more