mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-13 20:08:04 -10:00
Compare commits
102
Commits
No files matched your search
@@ -48,7 +48,7 @@
|
||||
tags: cnb:arch:amd64
|
||||
cpus: 16
|
||||
docker:
|
||||
image: rust:1.88-bookworm
|
||||
image: rust:1.100.0-bookworm
|
||||
# codewhale-cnb-bridge GitHub App credentials from the CNB KeyStore
|
||||
# (codewhale.net/codewhale-ci-secrets, github-bridge.yml), injected as
|
||||
# environment variables. Values are never printed.
|
||||
@@ -112,7 +112,7 @@
|
||||
tags: cnb:arch:amd64
|
||||
cpus: 16
|
||||
docker:
|
||||
image: rust:1.88-bookworm
|
||||
image: rust:1.100.0-bookworm
|
||||
stages:
|
||||
- name: install release dependencies
|
||||
script: |
|
||||
@@ -163,7 +163,7 @@ main:
|
||||
$:
|
||||
tag_push:
|
||||
- docker:
|
||||
image: rust:1.88-bookworm
|
||||
image: rust:1.100.0-bookworm
|
||||
stages:
|
||||
- name: build linux x64 release assets (static)
|
||||
# The static full-LTO link can also outlive CNB's default no-output
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
ARG RUST_VERSION=1.88
|
||||
ARG RUST_VERSION=1.100.0
|
||||
FROM rust:${RUST_VERSION}-slim-bookworm
|
||||
|
||||
# Native DBus headers and pkg-config are required by libdbus-sys during
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"context": "..",
|
||||
"dockerfile": "Dockerfile",
|
||||
"args": {
|
||||
"RUST_VERSION": "1.88"
|
||||
"RUST_VERSION": "1.100.0"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
|
||||
@@ -75,6 +75,7 @@ security boundaries and adds bridge-specific controls.
|
||||
- **Token gate**: All runtime API calls carry `CODEWHALE_RUNTIME_TOKEN`
|
||||
- **Chat allowlist**: Only chats/users listed in `WECOM_CHAT_ALLOWLIST` can interact. First-pairing mode (`WECOM_ALLOW_UNLISTED=true`) is meant for onboarding only
|
||||
- **Approval required**: Tool calls from WeCom sessions must be approved — either via explicit `/allow <id>` commands or natural-language keywords (`允许`, `yes`, `ok`, etc.)
|
||||
- **Thread ownership**: `/threads` and `/resume` cover only the threads the chat itself created; any other thread ID is refused with one fixed reply
|
||||
- **No workspace exposure**: Only prompts, status summaries, and approval requests are sent to WeCom. Workspace contents, shell output, and runtime internals stay on the local machine
|
||||
|
||||
### Reporting WeCom Bridge vulnerabilities
|
||||
|
||||
@@ -248,7 +248,7 @@ assert.match(nightly, /startsWith\(matrix\.target, 'aarch64-'\).*runner\.arch ==
|
||||
const nightlyArmMuslSetup = namedStep(nightly, "Install Linux ARM64 musl toolchain");
|
||||
assert.match(nightlyArmMuslSetup, /matrix\.target == 'aarch64-unknown-linux-musl'/);
|
||||
assert.match(nightlyArmMuslSetup, /apt-get install -y binutils musl-tools/);
|
||||
assert.match(nightlyArmMuslSetup, /rustup target add --toolchain stable aarch64-unknown-linux-musl/);
|
||||
assert.match(nightlyArmMuslSetup, /rustup target add aarch64-unknown-linux-musl/);
|
||||
const nightlyArmStaticSmoke = namedStep(
|
||||
nightly,
|
||||
"Verify static Linux ARM64 binary and launch",
|
||||
@@ -373,7 +373,7 @@ assert.deepEqual([...new Set(valuesForKey(artifacts, "target"))].sort(), expecte
|
||||
const releaseMuslBuild = namedStep(artifacts, "Build static Linux binaries (musl)");
|
||||
assert.match(releaseMuslBuild, /endsWith\(matrix\.target, '-unknown-linux-musl'\)/);
|
||||
assert.match(releaseMuslBuild, /apt-get install -y binutils musl-tools/);
|
||||
assert.match(releaseMuslBuild, /rustup target add --toolchain stable \$\{\{ matrix\.target \}\}/);
|
||||
assert.match(releaseMuslBuild, /rustup target add \$\{\{ matrix\.target \}\}/);
|
||||
assert.match(
|
||||
releaseMuslBuild,
|
||||
/cargo build --profile dist --locked --target \$\{\{ matrix\.target \}\} -p codewhale-cli/,
|
||||
@@ -810,6 +810,20 @@ assert.doesNotMatch(
|
||||
// A job that calls a reusable workflow (`uses:`) cannot carry the key at all —
|
||||
// GitHub rejects it — so the callee owns its own caps. That is why the artifact
|
||||
// bounds live in release-artifacts.yml rather than in its callers.
|
||||
function timeoutMinutes(value) {
|
||||
const literal = /^\d+$/.test(value);
|
||||
const conditional = value.match(
|
||||
/^\$\{\{\s*startsWith\(matrix\.os,\s*'macos-'\)\s*&&\s*(\d+)\s*\|\|\s*(\d+)\s*\}\}$/,
|
||||
);
|
||||
if (!literal && !conditional) return null;
|
||||
const macos = Number(literal ? value : conditional[1]);
|
||||
const other = Number(literal ? value : conditional[2]);
|
||||
if (![macos, other].every((cap) => Number.isInteger(cap) && cap > 0 && cap <= 360)) {
|
||||
return null;
|
||||
}
|
||||
return { macos, other };
|
||||
}
|
||||
|
||||
function jobsWithoutTimeout(source) {
|
||||
const lines = source.split("\n");
|
||||
const jobsAt = lines.findIndex((line) => /^jobs:\s*$/.test(line));
|
||||
@@ -823,7 +837,8 @@ function jobsWithoutTimeout(source) {
|
||||
for (let j = i + 1; j < lines.length; j += 1) {
|
||||
if (/^ [A-Za-z0-9_-]+:\s*$/.test(lines[j])) break;
|
||||
if (/^ uses:/.test(lines[j])) reusable = true;
|
||||
if (/^ timeout-minutes:\s*\d+\s*$/.test(lines[j])) capped = true;
|
||||
const timeout = lines[j].match(/^ timeout-minutes:\s*(.+?)\s*$/);
|
||||
if (timeout && timeoutMinutes(timeout[1])) capped = true;
|
||||
}
|
||||
if (!reusable && !capped) offenders.push(header[1]);
|
||||
}
|
||||
@@ -862,26 +877,41 @@ for (const [name, source] of [
|
||||
);
|
||||
}
|
||||
|
||||
// The Windows artifact build historically runs 40-45 minutes, so its cap has to
|
||||
// keep real margin — a tight bound here fails healthy releases.
|
||||
const buildTimeout = artifacts.match(/^ build:\n(?:.*\n)*? timeout-minutes: (\d+)$/m);
|
||||
assert.ok(buildTimeout, "release-artifacts build job must be capped");
|
||||
assert.ok(
|
||||
Number(buildTimeout[1]) >= 60,
|
||||
`artifact build cap ${buildTimeout[1]}m leaves no margin over a healthy 40-45m Windows build`,
|
||||
);
|
||||
|
||||
function jobTimeout(source, job) {
|
||||
function jobTimeout(source, job, platform = "other") {
|
||||
const match = source.match(
|
||||
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (\\d+)$`, "m"),
|
||||
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (.+)$`, "m"),
|
||||
);
|
||||
assert.ok(match, `${job} must declare timeout-minutes`);
|
||||
return Number(match[1]);
|
||||
const caps = timeoutMinutes(match[1].trim());
|
||||
assert.ok(caps, `${job} must declare a bounded timeout`);
|
||||
return caps[platform];
|
||||
}
|
||||
|
||||
// Fast setup and packaging fail quickly. Native builds need the same finite
|
||||
// cold-build allowance as full CI: both 0.10.1 macOS artifacts hit the old
|
||||
// 90m compilation cap before reaching their launch and inventory checks.
|
||||
const macosTimeout = "${{ startsWith(matrix.os, 'macos-') && 360 || 210 }}";
|
||||
assert.deepEqual(timeoutMinutes(macosTimeout), { macos: 360, other: 210 });
|
||||
for (const invalid of [
|
||||
"0", "361", "Infinity", "${{ matrix.timeout }}",
|
||||
"${{ startsWith(matrix.os, 'macos-') && 361 || 210 }}",
|
||||
"${{ startsWith(matrix.os, 'macos-') && 360 || 0 }}",
|
||||
]) {
|
||||
assert.equal(timeoutMinutes(invalid), null, `${invalid} must not count as a bounded cap`);
|
||||
}
|
||||
assert.deepEqual(
|
||||
jobsWithoutTimeout(`jobs:\n capped:\n timeout-minutes: ${macosTimeout}\n`),
|
||||
[],
|
||||
);
|
||||
assert.equal(jobTimeout(artifacts, "build", "macos"), 360);
|
||||
assert.equal(jobTimeout(artifacts, "build"), 210);
|
||||
const artifactCacheSetup = artifacts.match(
|
||||
/ - uses: mozilla-actions\/sccache-action@[^\n]+\n(?:(?! - ).*\n)*/,
|
||||
);
|
||||
assert.ok(artifactCacheSetup, "artifact compiler cache setup must be explicit");
|
||||
assert.match(artifactCacheSetup[0], /^ if: runner\.os == 'Linux'$/m);
|
||||
assert.match(
|
||||
namedStep(artifacts, "Enable sccache"),
|
||||
/^ if: steps\.sccache\.outcome == 'success' && runner\.os == 'Linux'$/m,
|
||||
);
|
||||
|
||||
assert.equal(jobTimeout(candidate, "resolve"), 10);
|
||||
assert.equal(jobTimeout(candidate, "web"), 15);
|
||||
assert.equal(jobTimeout(artifacts, "pin"), 10);
|
||||
@@ -894,7 +924,7 @@ for (const job of ["bundle", "windows-installer", "assemble", "smoke"]) {
|
||||
}
|
||||
assert.equal(
|
||||
jobTimeout(nightly, "build"), jobTimeout(artifacts, "build"),
|
||||
"nightly and release must share the native cold-build allowance",
|
||||
"nightly and non-macOS release builds must share the cold-build allowance",
|
||||
);
|
||||
assert.equal(jobTimeout(release, "resolve"), 10);
|
||||
// The 0.10.1 cold parity build exhausted 45 minutes before tests started.
|
||||
@@ -904,6 +934,76 @@ assert.ok(
|
||||
"release parity must allow the full CI suite's cold-build budget",
|
||||
);
|
||||
|
||||
const pinnedToolchain = read("rust-toolchain.toml").match(/^channel\s*=\s*"([^"]+)"/m)[1];
|
||||
assert.match(pinnedToolchain, /^\d+\.\d+\.\d+$/, "rust-toolchain.toml must pin an exact Rust release");
|
||||
const msrv = read("Cargo.toml").match(/^rust-version\s*=\s*"([^"]+)"/m)[1];
|
||||
assert.match(msrv, /^\d+\.\d+$/, "Cargo.toml must declare a minor MSRV");
|
||||
const escapeRegExp = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const stripWorkflowComments = (source) =>
|
||||
source
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/(^|\s)#.*$/, ""))
|
||||
.join("\n");
|
||||
const workflowDir = path.join(repoRoot, ".github", "workflows");
|
||||
const workflowSources = new Map();
|
||||
for (const file of fs.readdirSync(workflowDir).filter((name) => /\.ya?ml$/.test(name)).sort()) {
|
||||
workflowSources.set(file, stripWorkflowComments(read(`.github/workflows/${file}`)));
|
||||
}
|
||||
const ciSource = workflowSources.get("ci.yml");
|
||||
const msrvHeader = /^ msrv:[ \t]*$/m.exec(ciSource);
|
||||
assert.ok(msrvHeader, "ci.yml must keep an MSRV job");
|
||||
const msrvStart = msrvHeader.index;
|
||||
const afterMsrvHeader = msrvStart + msrvHeader[0].length;
|
||||
const nextJob = /^ \S/m.exec(ciSource.slice(afterMsrvHeader));
|
||||
const msrvEnd = nextJob ? afterMsrvHeader + nextJob.index : ciSource.length;
|
||||
const selectorValue = (value) => value.trim().replace(/^(["'])(.*)\1$/, "$2");
|
||||
const inMsrvJob = (index) => index >= msrvStart && index < msrvEnd;
|
||||
for (const [file, source] of workflowSources) {
|
||||
const selectors = [
|
||||
...[...source.matchAll(/dtolnay\/rust-toolchain@([^\s]+)/g)].map((m) => ["action ref", m[1], m.index]),
|
||||
...[...source.matchAll(/^[ \t]+toolchain:[ \t]*([^\r\n]*)$/gm)].map((m) => ["toolchain input", selectorValue(m[1]), m.index]),
|
||||
...[...source.matchAll(/^[ \t]+rust-version:[ \t]*([^\r\n]*)$/gm)].map((m) => ["rust-version input", selectorValue(m[1]), m.index]),
|
||||
...[...source.matchAll(/^[ \t]+RUSTUP_TOOLCHAIN:[ \t]*([^\r\n]*)$/gm)].map((m) => ["RUSTUP_TOOLCHAIN", selectorValue(m[1]), m.index]),
|
||||
...[...source.matchAll(/\bcargo\s+\+\s*["']?([^\s"']+)/g)].map((m) => ["cargo + selector", m[1], m.index]),
|
||||
];
|
||||
for (const [kind, value, index] of selectors) {
|
||||
if (kind === "action ref" && (/^[0-9a-f]{40}$/.test(value) || value === "master")) continue;
|
||||
const expected = file === "ci.yml" && inMsrvJob(index) ? msrv : pinnedToolchain;
|
||||
assert.equal(value, expected, `${file}: ${kind} ${value} must equal ${expected}`);
|
||||
}
|
||||
assert.doesNotMatch(source, /rustup (?:target|component) add --toolchain/, `${file}: rustup adds must follow rust-toolchain.toml`);
|
||||
}
|
||||
const msrvJob = ciSource.slice(msrvStart, msrvEnd);
|
||||
assert.match(msrvJob, new RegExp(`RUSTUP_TOOLCHAIN: "${escapeRegExp(msrv)}"`), "the MSRV job must override rust-toolchain.toml for every step");
|
||||
assert.match(msrvJob, new RegExp(`toolchain: "?${escapeRegExp(msrv)}"?`));
|
||||
assert.match(msrvJob, new RegExp(`cargo \\+${escapeRegExp(msrv)} check --workspace --locked`));
|
||||
assert.doesNotMatch(msrvJob, new RegExp(`\\b${escapeRegExp(pinnedToolchain)}\\b`), "the MSRV job must not select the pinned release toolchain");
|
||||
|
||||
const rootDockerfile = read("Dockerfile");
|
||||
const devcontainerDockerfile = read(".devcontainer/Dockerfile");
|
||||
const devcontainerConfig = read(".devcontainer/devcontainer.json");
|
||||
const tencentCnb = read("deploy/tencent-lighthouse/cnb/cnb.yml.example");
|
||||
const pinnedTag = `${pinnedToolchain}-bookworm`;
|
||||
for (const [label, source] of [
|
||||
["Dockerfile", rootDockerfile],
|
||||
[".devcontainer/Dockerfile", devcontainerDockerfile],
|
||||
]) {
|
||||
assert.match(source, new RegExp(`^ARG RUST_VERSION=${escapeRegExp(pinnedToolchain)}$`, "m"), `${label} must default RUST_VERSION to the pinned toolchain`);
|
||||
assert.match(source, /^FROM .*\brust:\$\{RUST_VERSION\}/m, `${label} must build from the RUST_VERSION image`);
|
||||
}
|
||||
assert.match(devcontainerConfig, new RegExp(`"RUST_VERSION":\\s*"${escapeRegExp(pinnedToolchain)}"`), ".devcontainer/devcontainer.json must pass the pinned RUST_VERSION build arg");
|
||||
for (const [label, source] of [
|
||||
[".cnb.yml", cnb],
|
||||
["deploy/tencent-lighthouse/cnb/cnb.yml.example", tencentCnb],
|
||||
]) {
|
||||
const tags = [...stripWorkflowComments(source).matchAll(/^[ \t]*image:[ \t]*([^\r\n]*)$/gm)]
|
||||
.map((m) => selectorValue(m[1]))
|
||||
.filter((value) => value.startsWith("rust:"))
|
||||
.map((value) => value.slice("rust:".length));
|
||||
assert.ok(tags.length > 0, `${label} must declare a Rust build image`);
|
||||
assert.deepEqual([...new Set(tags)], [pinnedTag], `${label}: Rust build images must match rust-toolchain.toml`);
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 8-target/38-asset release candidate.",
|
||||
);
|
||||
@@ -23,17 +23,17 @@ jobs:
|
||||
continue-on-error: ${{ matrix.checks == 'advisories' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
# rust-toolchain.toml pins `channel = "stable"` with no target, so
|
||||
# rust-toolchain.toml pins `channel = "1.100.0"` with no target, so
|
||||
# inside the action's alpine (musl) container rustup resolves it to
|
||||
# `stable-x86_64-unknown-linux-musl` — which the container does not
|
||||
# `1.100.0-x86_64-unknown-linux-musl`, which the container does not
|
||||
# have. Without provisioning, the action's `rustup show` step prints
|
||||
# "error: override toolchain 'stable-x86_64-unknown-linux-musl' is not
|
||||
# "error: override toolchain '1.100.0-x86_64-unknown-linux-musl' is not
|
||||
# installed" and then auto-installs mid-run (network-dependent ~12s
|
||||
# detour, hard failure if the download stalls). `rust-version` makes the
|
||||
# entrypoint run `rustup default stable` (same musl host triple) before
|
||||
# entrypoint run `rustup default 1.100.0` (same musl host triple) before
|
||||
# cargo-deny touches the workspace, so the toolchain file resolves to an
|
||||
# already-installed toolchain on every run.
|
||||
- uses: EmbarkStudios/cargo-deny-action@v2
|
||||
with:
|
||||
command: check ${{ matrix.checks }}
|
||||
rust-version: stable
|
||||
rust-version: 1.100.0
|
||||
+13
-11
@@ -203,7 +203,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
- uses: actions/[email protected]
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -428,7 +428,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.100.0
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
# The GitHub Actions cache backend is main-only, mirroring
|
||||
@@ -487,7 +487,7 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.100.0
|
||||
components: rustfmt, clippy
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
@@ -728,6 +728,8 @@ jobs:
|
||||
if: github.event_name == 'workflow_dispatch' && needs.changes.outputs.heavy == 'true'
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTUP_TOOLCHAIN: "1.89"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
@@ -751,7 +753,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
# Main-only GitHub Actions cache backend; see the Safety gate job.
|
||||
@@ -825,11 +827,11 @@ jobs:
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
- name: Install macOS LLVM tools
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
|
||||
run: rustup component add llvm-tools --toolchain stable
|
||||
run: rustup component add llvm-tools
|
||||
- name: Omit macOS build debug metadata
|
||||
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
|
||||
shell: bash
|
||||
@@ -1222,7 +1224,7 @@ jobs:
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
shell: pwsh
|
||||
run: ./scripts/installer/update-user-path.tests.ps1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
- name: Check every workspace target compiles on Windows
|
||||
if: needs.changes.outputs.heavy == 'true'
|
||||
@@ -1267,7 +1269,7 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
cache-bin: false
|
||||
@@ -1305,7 +1307,7 @@ jobs:
|
||||
run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context."
|
||||
- uses: actions/checkout@v7
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
@@ -1364,7 +1366,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
# Main-only GitHub Actions cache backend; see the Safety gate job.
|
||||
@@ -1423,7 +1425,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.100.0
|
||||
components: rustfmt
|
||||
|
||||
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@master
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.100.0
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: mozilla-actions/[email protected]
|
||||
id: sccache
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y binutils musl-tools
|
||||
rustup target add --toolchain stable aarch64-unknown-linux-musl
|
||||
rustup target add aarch64-unknown-linux-musl
|
||||
- name: Build
|
||||
shell: bash
|
||||
# Nightly artifacts are disposable smoke binaries (14-day retention),
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install Rust target
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@1.100.0
|
||||
with:
|
||||
targets: aarch64-unknown-linux-ohos
|
||||
- name: Install OpenHarmony native SDK
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: pet/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
- run: npm --prefix pet ci --ignore-scripts
|
||||
- run: npm --prefix pet run check
|
||||
- run: npm --prefix pet run sync
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
cache-dependency-path: pet/package-lock.json
|
||||
- run: npm --prefix pet ci --ignore-scripts
|
||||
- run: npm --prefix pet run check
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
if: runner.os == 'macOS'
|
||||
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
|
||||
if: runner.os == 'macOS'
|
||||
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.100.0
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
if: runner.os == 'Linux'
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y binutils musl-tools
|
||||
rustup target add --toolchain stable ${{ matrix.target }}
|
||||
rustup target add ${{ matrix.target }}
|
||||
cargo build --profile dist --locked --target ${{ matrix.target }} -p codewhale-cli
|
||||
- name: Configure Android NDK linker
|
||||
if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux'
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
package-manager-cache: false
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # stable 2026-07-18
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.100.0
|
||||
- name: Match dispatch to the requested commit
|
||||
id: source
|
||||
shell: bash
|
||||
@@ -68,6 +68,8 @@ jobs:
|
||||
|
||||
echo "sha=${actual}" >> "${GITHUB_OUTPUT}"
|
||||
echo "version=${workspace_version}" >> "${GITHUB_OUTPUT}"
|
||||
- name: Check the first-party catalog snapshot is current
|
||||
run: ./scripts/refresh-first-party-marketplace.sh --check
|
||||
- name: Check version and OHOS release contracts
|
||||
# --require-dated-release, as auto-tag.yml and release.yml run it:
|
||||
# release.yml only accepts an RC receipt for the exact tag SHA, so an
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
- uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master 2026-07-18
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: 1.100.0
|
||||
components: clippy, rustfmt
|
||||
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
|
||||
id: sccache
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
- name: Run cargo audit
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ inputs.expected_sha }}
|
||||
fetch-depth: 1
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: dtolnay/rust-toolchain@1.100.0
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- name: Run focused tests
|
||||
shell: bash
|
||||
|
||||
+41
-14
@@ -7,9 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- **Plugin service hardening (terminals, remote pairing, network):** plugin background terminal sessions enforce the per-plugin cap inside the shared session registry after approval waits and close when the plugin owner is revoked or disabled (CW-8051); plugin remote pairing and status serve reviewed plugins only, refuse child-agent and stale requests, validate pairing labels and one-time loopback redemption through host secrets, and keep `remote` in the refused Cordis service and inspect catalogs (CW-8000); plugin network credentials are bound to a single `https` origin per plugin build in a private credential file, check live session posture and destination guards before egress, serialize first-send confirmation per site through `plugin_network_send`, and scrub returned URLs, headers and bodies before returning to plugins (CW-7979).
|
||||
- **Agent `cwd` in sibling worktrees:** spawning an agent with `cwd` pointing to an existing linked worktree (or a subdirectory inside it) under the repository's `.codewhale-worktrees/<repo>/` root is now accepted when both share the same repository common git directory; outside directories and foreign or plain directories under `.codewhale-worktrees/` stay refused. (CW-8215)
|
||||
- **DSH market npm installs and extension catalog service:** DSH market `npm` entries with an exact semantic version (and optional `https` registry) now resolve to reviewed registry-tarball install plans (`npm:<pkg>@<ver>` or exact `.tgz` URL) without executing `npm` or bypassing plugin trust review. Inside the extension host, the `dsh/catalog` broker arm now plans reviewed installs without writing to disk, returns only verified approved provider routes scoped to the calling plugin, and serves account balance read-only while withholding credentials and account mutations (CW-7988).
|
||||
- **Plugin settings service backend (0.10.3):** adds the internal `settings` host-service handler (`describe`, `update`, `replace`, `mutate`) and extension-host adapter helper so plugins can inspect and atomically edit only their own reviewed `[plugins."<name>".config]` keys in `config.toml` through the session permission gate and live write posture, leaving comments, unrelated tables and other plugins' configuration untouched (writes return `applies: "reload"` and take effect at the next reload). This backend is not yet wired into live host-service dispatch or plugin grants, so no active plugin exposes it yet. (CW-7983)
|
||||
- **Runtime thread completion wakes:** idle non-ACP runtime threads automatically coalesce finished background shell jobs and completed child sub-agents into a bounded runtime continuation turn without dropping completions during busy turns or triggering wakes on cancelled, archived, or already-drained work. (CW-8215)
|
||||
- **Plugin install summary builder (crate API):** `codewhale-plugins` adds `install::build_summary`, a pure one-line install and update summary builder with stable message IDs for plugins and DSH packages; no CLI, TUI or desktop caller uses it yet. (CW-8182)
|
||||
- **Live reload for external plugin and MCP changes:** when another process installs, enables or disables a plugin or updates the configured MCP file, an idle TUI session now applies the change automatically and records any added or removed skills in the transcript (provider plugin changes still note they apply in a new session); while a turn is running, a toast notifies you and applies the change once idle. (CW-8183)
|
||||
- **First-party plugin catalog updates:** saved `codewhale` marketplace catalogs that came from an earlier embedded snapshot revision now update to the shipped snapshot on load while leaving custom, moving-branch, desktop-bundled, and removed catalogs untouched; release-candidate checks also verify that the embedded snapshot matches the marketplace files it installs from. (CW-8185)
|
||||
- **Network guard and portable config bundles:** `/network allow` now admits a local or private address only for `fetch_url`, `web.run`, and `config import` when the host is a `localhost` name (resolving to loopback only) or a local/private IP literal you typed and explicitly allowed (with IPv6 `deny` rules matching across bracketed and uncompressed spellings and wildcard rules never matching IP literals); domain names resolving to private addresses, skill and plugin installs, extension-host requests, and all cloud metadata forms (including `64:ff9b:1::/48` and `::ffff:0:0:0/96` embedded addresses) plus `0.0.0.0/8` stay refused on every hop. `codewhale config import <url>` now validates every redirect hop and refuses cross-host redirects to visibly local hosts, and portable config bundles reject on import and omit on export visibly local `network.allow` entries (including `localhost` names, local-domain wildcards such as `.localdomain` and `*.localhost`, and local IP literals).
|
||||
- **Skill install and update controller:** installing an already installed skill now updates it in place (reporting any replaced source and preserving `skills_state.toml` enabled/disabled state), local folders and `.tar.gz`/`.tgz` archives install and update as managed skills with path-safety and archive-bound checks, person-started global installs are trusted automatically without marking project skills, and mutations emit a one-line outcome receipt. (CW-8184)
|
||||
- **Tool deny rules across scheduled work:** tasks, automations, delayed messages and the threads that run them now keep the tool deny rules of the session that created them; older versions refuse such records instead of running them without those rules.
|
||||
- **Sub-minute delayed messages and plan replanning triggers:** `send_later` now accepts `delay_seconds` alongside `delay_minutes` and `fire_at` (requiring exactly one of the three), and the automation scheduler wakes immediately on trigger creation or cancellation so sub-minute continuations fire promptly. Plans also support `replan_if` conditions across updates, snapshots, plan cards, and session relay handoff output.
|
||||
- **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.
|
||||
- **Project MCP approvals:** Project `.codewhale/mcp.json` servers now load only after you approve the file's exact contents with `/mcp review` and `/mcp approve <digest>`, as project hooks do. A changed file loads none of its servers until it is approved again, and existing project files must be approved once after upgrading.
|
||||
|
||||
- **Source-build toolchain:** Rust 1.100.0 is pinned consistently across local source builds, CI, Docker, Dev Containers and CNB recipes. The minimum supported Rust version remains 1.89.
|
||||
- **Terminal input recovery:** retains a wake event from a replaced reader, suppresses retired events observed during a child-terminal handoff, and closes only the handoff window owned by that scope.
|
||||
|
||||
- **Plugin index hooks:** hooks with the same name and event now have distinct row IDs, so clients can keep every configured hook visible.
|
||||
- **Extension service approvals:** plugin service acts use the session permission gate and retain their approved or refused act records when a tool call fails. Closing a call seals its broker-act record so a late approval cannot authorize an unrecorded act. Ordinary nested-call receipts may still show an in-flight outcome when cancellation ends the call early.
|
||||
- **Extension workspace confinement:** plugin workspace file reads, listings, and overlay writes now verify pinned ancestors and non-linked targets at the point of use without letting session trust flags escape the workspace, reject Windows alternate data streams and 8.3 short-name aliases for protected metadata directories, and check caller deny rules for git review queries before requesting approval. (CW-8056)
|
||||
- **Delayed-message workspaces and shell stdin approvals:** `send_later` `schedule` now keeps its workspace inside the session's reachable paths instead of accepting an arbitrary directory, and `send_later` approval prompts show the requested workspace; session shell grants now match exact inputs when `stdin` (`stdin`, `input`, `data`) is supplied instead of reusing a command-only grant across different `stdin`.
|
||||
- **Sub-agent approvals and worktree isolation:** a child sub-agent's built-in verification calls now ask the same approval as the main session, a read-only agent started with `workspace_policy: "worktree"` shows the approval card, and a sub-agent worktree is refused when its root or nested adoption path passes through a symlink.
|
||||
|
||||
- **Installers no longer say Computer Use comes bundled:** the macOS install script and the install guide no longer print or state that Computer Use ships with the binary or needs Node.js 20. Computer Use is released separately and stays off until you turn it on.
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and every value is also in words, so the model and other clients get the same facts as text. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Charts in the transcript:** the model can now show numbers as a small chart with the new read-only `show_data` tool: a one-row sparkline, up to 24 labelled bars, or a multi-row trend. The TUI paints it in the transcript with the terminal kit, including after a session is restored, and a plain-text summary accompanies each chart for the model and other clients. Input the chart cannot show honestly (a non-finite number, more than 1024 points or 24 bars, a label over 120 characters) is refused with the limit it broke.
|
||||
- **Terminal widgets:** narrow action hints wrap, scrollbars reach the final row, progress bars show fractional cells, focused tabs and panels are clearer, and the shared terminal kit supports measured ocean themes and reduced-motion-aware water drift.
|
||||
- **Plugin lifecycle from the shell:** `codewhale plugins` now has `install <spec>`, `show <name>`, `update <name>` and `uninstall <name>` (each with `--json`) alongside `status`, `restage`, `trust`, `enable`, `disable` and `revoke`. `plugins install` uses the same reviewed installer as `codewhale install`, `show` prints the capability review without prompting or trusting, `update` refreshes from the recorded source and reports either the new version or `already current`, and `uninstall` disables an enabled bundle first and asks in a terminal unless `--yes` is passed (non-interactive and `--json` runs require `--yes`). (CW-8161)
|
||||
|
||||
- **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).
|
||||
@@ -23,8 +52,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- **Plugin index (Runtime API):** a new `GET /v1/apps/plugin-index` route lists plugins, skills, connectors, hooks and hand-written commands with kind, source and state, and `/v1/commands` rows say which plugin (`plugin_id`) added a command and whether a command came from the project (`workspace_sourced`) (CW-8092).
|
||||
- **Model commands and routing:** `codewhale model list` now lists every custom endpoint you have configured, under its own name. `codewhale model resolve` no longer refuses a model on a custom endpoint, router or local server whose model list has not been fetched yet; once a list is fetched, a model missing from it is still refused. Automatic routing marks a context window as verified only when the route itself supplies it, and no longer reports a guessed output limit.
|
||||
- **ACP:** when a plugin tool asks the core to run a tool that needs approval, the editor now gets a `session/request_permission` on the plugin tool's call that names the nested tool and its input; before, the prompt ended with an internal error (CW-8050).
|
||||
- **Website maintainer and cron secrets:** the manual cron trigger is rate limited, admin sign-in and the cron secret require a 32 to 512 character secret with at least 8 distinct characters that is not a repeated pattern, and changing the maintainer token signs out existing admin sessions.
|
||||
- **Chat bridges thread ownership:** Chat bridges now list and resume only the threads the chat itself created: `/threads` shows owned threads, `/resume` refuses any other thread id with one fixed message, and `/new` always starts a new thread. In an existing thread map each chat keeps only the thread it is bound to, so threads it created earlier and left are no longer listed or resumable; a thread that several chats already pointed at is usable from none of them (send `/new`). Review `thread-map.json` after upgrading.
|
||||
- **Skill and plugin installs:** skill and plugin installs now enforce the uncompressed size cap on the whole decompressed archive, and reject archive entries with names longer than 4096 bytes.
|
||||
- **Built-in execution rules:** a `Bash` deny rule now also blocks built-in test and verifier runs (`Run` / `run_tests` / `run_verifiers`), `workflow` runs with `verify: true`, and delayed-message scheduling (`send_later` `schedule`) while keeping task reads, status checks, management actions, and the bounded verifier's own default verification runs working.
|
||||
- **README and contributor credit:** the repository front page now opens the way the website does: the line "The model can be anyone's. The agent should be yours.", one sentence on what Codewhale is, the install command and one picture, with reference detail moved into the linked guides; the 18 translated READMEs follow the same structure and say they are machine-drafted. The contributor wall and the website now count the same 433 people (407 GitHub accounts and the commit authors whose email is not linked to an account), where the website had counted accounts only. `DEPENDENCY_SECURITY.md` moved into `docs/dependency-maintenance.md`, and `.github/SUPPORT.md` says where to ask for help.
|
||||
- **Website Plugins page:** The website's Plugins page now describes what the released version does, and a labelled section says what 0.10.3 adds: one Plugins place, Standard and Creator mode, and the Permissions control.
|
||||
- **Website `/app` captures:** the `/app` page's desktop build screenshots (`conversation.webp`, `approval.webp`, `model-picker.webp`) and their alt and caption text have been refreshed from the current 0.10.3 desktop UI.
|
||||
- **First-time guide on the website:** a new docs page, "Use Codewhale for the first time" (`/docs/first-time`, English and Simplified Chinese), walks someone who has never used a terminal through opening one, installing, choosing the folder Codewhale works in and when that choice matters, opening a terminal in a folder from Finder or File Explorer, connecting a model, a first task, and stopping, undoing and quitting. The install page gains a short "New to the terminal?" block for macOS and Windows that links to it, and `llms.txt` carries the same steps.
|
||||
- **Plugin docs and plugin-creator:** Docs now say everything you add is a plugin. The bundled plugin-creator skill no longer tells an agent never to trust or enable a plugin (in Full Access it may, for one it wrote and installed from its project), points code and screen plugins at cordis-plugin-development, and an untouched copy from 0.10.2 now upgrades. The Chinese extension and Runtime API docs now say editor ACP sessions get plugin tools and configured MCP servers.
|
||||
- **Model thinking:** New installs show model thinking by default: it streams live in the transcript and collapses under a "Thought for …" header when the answer starts. Existing installs keep their saved setting; turn it on under Model reasoning in chat in /config.
|
||||
@@ -105,6 +139,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Credential redaction:** text, JSON, and workflow-disclosure redaction now masks multi-line private-key blocks across PGP armor headers (including `Comment:` URLs), longer preambles, and 16-character wrapped lines; masks credential values inside nested or escaped JSON assignments, escaped quotes, `\u003c`/`\u003e` delimiters, and spaced or nested JSON objects and arrays; and treats plural token keys (`API_TOKENS`, `ACCESS_TOKENS`) and credential-shaped `tokens` values as credentials while keeping model and tokenizer metric names (`max_tokens`, `tokenizer`, `token_limit`, `tokens=5`) visible.
|
||||
- Child processes now inherit only the standard locale variables (`LC_ALL`, `LC_CTYPE` and the other locale categories, plus `LC_TERMINAL` and `LC_TERMINAL_VERSION`) and a listed set of `DOTNET_` host, SDK and runtime settings. Other `DOTNET_` variables, including application configuration keys, are no longer passed to child processes or MCP servers.
|
||||
- **Deny rules and command safety:** a command whose command word is not a plain literal, or that redefines a command name earlier in the same call, is now refused while a deny rule is configured; the background destroyer hold reads commands the shell tokenizer rejects and decodes ANSI-C escapes inside shell payloads; and `sed`, `find`, `sort`, `rg`, `fd`, `uniq`, `env`, `less`, `more`, `ag`, `info` and `git` count as read-only only when their words are literal and use read-only flags, with `codewhale --version` and Windows trailing-backslash paths normalized before checking.
|
||||
- **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.
|
||||
@@ -456,6 +493,8 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Terminal Plugins screen:** The terminal's Extensions screen is now called Plugins; its tabs filter by kind (Installed, Skills, Connectors, Hooks, Marketplace), and `/plugin` is listed with the main commands. `/skills`, `/mcp` and `/hooks` work as before.
|
||||
|
||||
- **`read` pages in lines only (breaking).** One `read` call returns at
|
||||
most 2,000 lines — the default and the maximum `limit` — inside a 50 KiB
|
||||
output cap, and a line longer than 2,000 chars comes back truncated with
|
||||
@@ -686,12 +725,12 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
- **Windows uninstall removes the receipt and update leftovers.** The installer's uninstaller also deletes `.codewhale-install.sha256` and `codewhale.old-*`/`codew.old-*` left by a self-update, so `bin` can be removed. Refs #7091.
|
||||
- **Windows `/undo` matches recorded file paths regardless of case.** A write recorded under a different drive-letter or directory case, or a `\\?\` prefix, is no longer dropped from the restore plan. Refs #6970.
|
||||
- **Windows: Fleet and metrics read live files again.** Replaying a Fleet ledger and reading audit or receipt records no longer fail with a sharing violation while their writer is still open; the same regular-file, link and identity checks apply. Plugin enablement now says which snapshot check refused (stage root, content identity or final check) instead of one generic message. Generated protocol and SDK exports keep LF line endings in Windows checkouts.
|
||||
- **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”.
|
||||
- **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 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.
|
||||
- **A full usage journal no longer fails a turn before dispatch.** The request runs with cost unknown instead of stopping on “native usage journal capacity reached”.
|
||||
- **Reverting a file works in any posture.** Restoring a file the thread's own turns changed no longer requires `/trust` or Full Access; the exact snapshot and reviewed-hash checks still apply. **`fetch_url` can reach a local or private host you allowed** with `/network allow <host>`; cloud metadata stays blocked. Both refusals now name the setting.
|
||||
- **Reverting a file works in any posture.** Restoring a file the thread's own turns changed no longer requires `/trust` or Full Access; the exact snapshot and reviewed-hash checks still apply. **`fetch_url` can reach a typed `localhost` name or local IP literal you allowed** with `/network allow <host>` (domain names resolving to private addresses and cloud metadata stay blocked). Both refusals now name the setting.
|
||||
- **`codewhale exec` offers tools by default**, still under the configured sandbox and approval posture. `--no-tools` is the one-shot. A pipe on stdin is the prompt when no arguments are given, or is appended to them.
|
||||
- **A child no longer dies when usage settlement is not acknowledged.** The turn continues and cost stays unknown, matching the parent path.
|
||||
- **Workspace memory works without a git origin.** A plain folder or a repo with no `origin` remote gets a stable workspace id. `codewhale plugin list` is an alias of `plugins status`. A heavy command runs after 30 seconds instead of waiting forever for an admission slot. An MCP registration 4xx names the status and the manual client-id path.
|
||||
@@ -723,12 +762,6 @@ 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.
|
||||
@@ -757,12 +790,6 @@ work is declared complete here. Items moved to 0.10.4 by name are marked below.
|
||||
bare self-package exports resolve inside the selected package directory.
|
||||
Invalid or incompatible ranges and paths outside the reviewed package still
|
||||
refuse. Real published-plugin activation remains unqualified.
|
||||
- **Coordination lock diagnostics (#7232):** stale lock-file contents no longer
|
||||
identify an owner PID. A refused legacy admission keeps its reason in Work
|
||||
and shows one notice per cause instead of repeating cleanup warnings on
|
||||
every sidebar refresh. Legacy migration still requires the actual holder to
|
||||
exit and the refused owning Runtime to restart; locks and retained state
|
||||
remain protected.
|
||||
- **A local proxy that stops mid-session no longer strands the session (#7231):** the model, catalog, search, vision, finance, MCP-registry, profile and remote-control clients watch a loopback proxy port and go direct while it refuses connections, then resume proxying when it returns. Only a definite refusal from every resolved address changes the route; remote proxies, `NO_PROXY` matches, credentials and the existing precedence are unchanged. Set `CODEWHALE_LOCAL_PROXY_FALLBACK=0` to keep the old build-time snapshot.
|
||||
- **Proxy diagnostic privacy ([#7233](https://github.com/codewhale-hq/codewhale/issues/7233)):** malformed MCP proxy warnings omit configured values; local-proxy log labels mask userinfo and omit URL paths, queries and fragments. Routing is unchanged. All 23 existing proxy tests passed on macOS; runtime log capture and other platforms remain unqualified. Thanks [@SparkofSpike](https://github.com/SparkofSpike) for the report.
|
||||
- **Fork, undo and retry work after a workspace drift notice (#7234):** the history-boundary walks now treat a whole `<context_update>` envelope as engine-owned, so a session that received one can be cut at every saved turn. A person quoting a notice or a truncated envelope still counts as their own prompt.
|
||||
|
||||
Generated
+5
@@ -1037,6 +1037,7 @@ dependencies = [
|
||||
"codewhale-engine",
|
||||
"codewhale-execpolicy",
|
||||
"codewhale-lane",
|
||||
"codewhale-net",
|
||||
"codewhale-paths",
|
||||
"codewhale-release",
|
||||
"codewhale-runtime",
|
||||
@@ -1196,6 +1197,7 @@ dependencies = [
|
||||
"codewhale-paths",
|
||||
"codewhale-plugins",
|
||||
"codewhale-protocol",
|
||||
"codewhale-ratatui",
|
||||
"codewhale-release",
|
||||
"codewhale-repl",
|
||||
"codewhale-runtime",
|
||||
@@ -1715,9 +1717,11 @@ dependencies = [
|
||||
"codewhale-runtime",
|
||||
"codewhale-skill-install",
|
||||
"codewhale-util",
|
||||
"flate2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 1.1.6+spec-1.1.0",
|
||||
@@ -1939,6 +1943,7 @@ name = "codewhale-workflow"
|
||||
version = "0.10.3"
|
||||
dependencies = [
|
||||
"codewhale-protocol",
|
||||
"codewhale-sanitize",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
# Or mount an env file:
|
||||
# docker run --rm -it --env-file .env codewhale
|
||||
|
||||
ARG RUST_VERSION=1.88
|
||||
ARG RUST_VERSION=1.100.0
|
||||
|
||||
# ── Stage 1: Build ────────────────────────────────────────────────────
|
||||
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
|
||||
|
||||
@@ -119,12 +119,9 @@ 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).
|
||||
still meets your approval rules and keeps its receipt. [Computer Use](https://github.com/codewhale-hq/codewhale-cu-plugin)
|
||||
is a separate plugin released on its own (experimental, macOS only), and stays
|
||||
off until you review its access and turn it on.
|
||||
|
||||
### Ways to run it
|
||||
|
||||
|
||||
@@ -387,7 +387,7 @@ impl RuntimeWorkspaceScopes {
|
||||
let owner = self.clone();
|
||||
codewhale_runtime::runtime_control::owner_work(move || {
|
||||
let (canonical, directory) =
|
||||
codewhale_util::workspace_dir::open_workspace_directory(&lexical)?;
|
||||
codewhale_util::workspace_dir::open_pinned_workspace_directory(&lexical)?;
|
||||
let directory = Arc::new(directory);
|
||||
let mut scopes = owner.scopes.lock();
|
||||
if let Some(scope) = scopes.get(&lexical) {
|
||||
@@ -7216,28 +7216,14 @@ fn mcp_management_config(
|
||||
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
|
||||
let global = codewhale_engine::mcp::load_config(&global_path)
|
||||
.map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?;
|
||||
let project_path = codewhale_engine::mcp::workspace_mcp_config_path(&state.workspace);
|
||||
let same_source = project_path == global_path
|
||||
|| project_path
|
||||
.canonicalize()
|
||||
.ok()
|
||||
.zip(global_path.canonicalize().ok())
|
||||
.is_some_and(|(project, global)| project == global);
|
||||
let project = if !same_source
|
||||
&& codewhale_runtime::config::is_workspace_trusted(&state.workspace)
|
||||
{
|
||||
codewhale_engine::mcp::load_config(&project_path)
|
||||
.map_err(|e| ApiError::internal(format!("Failed to load project MCP config: {e}")))?
|
||||
} else {
|
||||
codewhale_mcp::config::McpConfig::default()
|
||||
};
|
||||
let project = codewhale_engine::mcp::project_server_names(&global_path, &state.workspace);
|
||||
let origins = config
|
||||
.servers
|
||||
.iter()
|
||||
.map(|(name, server)| {
|
||||
let origin = if server.reviewed_plugin.is_some() {
|
||||
"plugin"
|
||||
} else if project.servers.contains_key(name) {
|
||||
} else if project.contains(name) {
|
||||
"project"
|
||||
} else if global.servers.contains_key(name) {
|
||||
"global"
|
||||
@@ -8477,6 +8463,7 @@ async fn create_trigger(
|
||||
owner_session_id: Some(owner_session_id),
|
||||
parent_trigger_id: None,
|
||||
destination_thread_id: destination,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
automation_mutation(
|
||||
&state,
|
||||
|
||||
@@ -568,6 +568,7 @@ fn messages_from_thread_detail_batches_tool_results() {
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
let turn = TurnRecord {
|
||||
source_usage_origin_turn_id: None,
|
||||
@@ -801,6 +802,7 @@ fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
},
|
||||
turns: Vec::new(),
|
||||
items: Vec::new(),
|
||||
@@ -25095,6 +25097,10 @@ async fn mcp_management_project_ownership_blocks_global_shadow_mutations() -> Re
|
||||
let project = json!({"servers":{"shared":{"command":root.path().join("missing-project-executable")},"project":{"command":root.path().join("missing-project-executable")}}}).to_string();
|
||||
fs::write(root.path().join("mcp.json"), &global)?;
|
||||
fs::write(workspace.join(".codewhale/mcp.json"), &project)?;
|
||||
let reviewed = codewhale_engine::mcp::project_authority::review_project_mcp(&workspace)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
codewhale_engine::mcp::project_authority::approve_project_mcp(&workspace, &reviewed.digest)
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
let (addr, _, handle) = spawn_test_server_with_root_token_mobile_workspace(
|
||||
root.path().to_owned(),
|
||||
root.path().join("sessions"),
|
||||
|
||||
@@ -1202,15 +1202,15 @@ async fn real_owner_two_workspace_services_share_scope_caches_and_settle_global_
|
||||
{
|
||||
let error = fs::rename(&selected_workspace, &retired_workspace)
|
||||
.expect_err("a live Runtime owner must prevent workspace replacement");
|
||||
assert_eq!(error.raw_os_error(), Some(5), "{error}");
|
||||
assert_eq!(error.raw_os_error(), Some(32), "{error}");
|
||||
assert!(!retired_workspace.exists());
|
||||
}
|
||||
manager.shutdown_and_wait().await?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let error = fs::rename(&selected_workspace, &retired_workspace)
|
||||
.expect_err("Engine shutdown must retain the Runtime owner's workspace lock");
|
||||
assert_eq!(error.raw_os_error(), Some(5), "{error}");
|
||||
.expect_err("Engine shutdown must retain the Runtime owner's pinned workspace");
|
||||
assert_eq!(error.raw_os_error(), Some(32), "{error}");
|
||||
assert!(!retired_workspace.exists());
|
||||
assert!(Arc::ptr_eq(
|
||||
&scopes.admit(selected_workspace.clone()).await?,
|
||||
|
||||
@@ -1588,6 +1588,7 @@ async fn mutate_in_owner(
|
||||
create.model_provider_id = thread.model_provider_id.clone();
|
||||
create.reasoning_effort = thread.reasoning_effort.clone();
|
||||
create.allowed_tools = thread.allowed_tools.clone();
|
||||
create.disallowed_tools = thread.disallowed_tools.clone();
|
||||
create.mode = Some(thread.mode.clone());
|
||||
create.permission_posture = thread.permission_posture.clone();
|
||||
create.allow_shell = Some(thread.allow_shell);
|
||||
|
||||
@@ -580,6 +580,7 @@ impl RuntimeChatRelayHost {
|
||||
dynamic_tools: Vec::new(),
|
||||
environments: Vec::new(),
|
||||
add_dirs: Vec::new(),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Runtime Chat could not create its replay fixture.".to_string())?
|
||||
@@ -725,6 +726,7 @@ impl RuntimeChatRelayHost {
|
||||
agent_preset: None,
|
||||
dynamic_tools: Vec::new(),
|
||||
environments: Vec::new(),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
|
||||
@@ -34,6 +34,7 @@ codewhale-engine = { path = "../engine", version = "0.10.3" }
|
||||
codewhale-app-server = { path = "../app-server", version = "0.10.3" }
|
||||
codewhale-config = { path = "../config", version = "0.10.3" }
|
||||
codewhale-lane = { path = "../lane", version = "0.10.3" }
|
||||
codewhale-net = { path = "../net", version = "0.10.3" }
|
||||
codewhale-workflow = { path = "../workflow", version = "0.10.3" }
|
||||
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.3" }
|
||||
codewhale-paths = { path = "../paths", version = "0.10.3" }
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
//! - Input size is capped (5 MiB, matching the skill installer's cap).
|
||||
//! - HTTPS only for remote fetch, except plain `http` on loopback; redirects
|
||||
//! are followed at most a bounded number of times within the same scheme.
|
||||
//! The first request and every redirect honor the `[network]` policy and
|
||||
//! refuse loopback, private, link-local and cloud-metadata destinations,
|
||||
//! except a visibly local host the person listed or typed as the URL.
|
||||
//! - Bundle-declared file paths must resolve inside the target config
|
||||
//! directory; traversal and symlink escapes are refused.
|
||||
//! - Project scope never mutates the user-global document and vice versa.
|
||||
@@ -573,13 +576,29 @@ fn nonportable_path_reason(path: &str) -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
fn is_network_allow_path(path: &str) -> bool {
|
||||
let mut segments = path.split('.').map(normalize_bundle_key).rev();
|
||||
matches!(
|
||||
(segments.next().as_deref(), segments.next().as_deref()),
|
||||
(Some("allow"), Some("network"))
|
||||
)
|
||||
}
|
||||
|
||||
/// Telemetry opt-out is safe to move between machines, but opt-in is durable
|
||||
/// user consent coupled to SetupState. A portable bundle may tighten that
|
||||
/// consent (`false`); it must never manufacture or transfer `true`.
|
||||
fn nonportable_value_reason(path: &str, value: &toml::Value) -> Option<&'static str> {
|
||||
let top_level_telemetry = !path.contains('.') && normalize_bundle_key(path) == "telemetry";
|
||||
(top_level_telemetry && matches!(value, toml::Value::Boolean(true)))
|
||||
.then_some("would port telemetry opt-in consent between machines")
|
||||
if top_level_telemetry && matches!(value, toml::Value::Boolean(true)) {
|
||||
return Some("would port telemetry opt-in consent between machines");
|
||||
}
|
||||
if is_network_allow_path(path)
|
||||
&& let toml::Value::String(host) = value
|
||||
&& codewhale_net::guard::is_visibly_local_host(host)
|
||||
{
|
||||
return Some("network.allow entry would grant local-network authority");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -798,67 +817,85 @@ pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result<PathBuf>
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Fetch a bundle over HTTPS (or plain http on loopback only) with a hard
|
||||
/// size cap, a timeout, and bounded redirects. Mirrors the skill installer's
|
||||
/// fetch bounds.
|
||||
pub fn fetch_bundle(url: &str) -> Result<Vec<u8>> {
|
||||
let mut current_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?;
|
||||
validate_bundle_url(¤t_url)?;
|
||||
let initial_scheme = current_url.scheme().to_string();
|
||||
/// size cap, a timeout, and bounded redirects. The first request and every
|
||||
/// redirect go through the shared guarded download: the network policy
|
||||
/// applies, the host is resolved and checked, and loopback, private,
|
||||
/// link-local and cloud-metadata destinations are refused unless the person
|
||||
/// listed a visibly local host in `network.allow`. A loopback host typed as
|
||||
/// the URL itself is admitted without a listing.
|
||||
pub fn fetch_bundle(
|
||||
url: &str,
|
||||
network: &codewhale_net::network_policy::NetworkPolicy,
|
||||
) -> Result<Vec<u8>> {
|
||||
let initial_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?;
|
||||
validate_bundle_url(&initial_url)?;
|
||||
let initial_scheme = initial_url.scheme().to_string();
|
||||
let initial_host = initial_url
|
||||
.host_str()
|
||||
.context("bundle URL must include a host")?
|
||||
.to_string();
|
||||
|
||||
let client = codewhale_release::platform_blocking_http_client_builder()
|
||||
.timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
|
||||
// Redirect targets must pass the same scheme/host policy as the
|
||||
// initial request, so redirects are followed explicitly below.
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.map_err(|_| anyhow!("building bundle fetch client failed"))?;
|
||||
let mut redirects = 0usize;
|
||||
let response = loop {
|
||||
let response = client
|
||||
.get(current_url.clone())
|
||||
.send()
|
||||
// reqwest errors can include the full URL (including its query or
|
||||
// userinfo), so keep transport failures deliberately URL-free.
|
||||
.map_err(|_| anyhow!("bundle fetch request failed"))?;
|
||||
|
||||
if !response.status().is_redirection() {
|
||||
break response;
|
||||
}
|
||||
if redirects >= MAX_REDIRECTS {
|
||||
bail!("bundle fetch exceeded the five-redirect limit");
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(reqwest::header::LOCATION)
|
||||
.ok_or_else(|| anyhow!("bundle redirect is missing a valid Location header"))?
|
||||
.to_str()
|
||||
.map_err(|_| anyhow!("bundle redirect is missing a valid Location header"))?;
|
||||
let next_url = current_url
|
||||
.join(location)
|
||||
.map_err(|_| anyhow!("bundle redirect Location is invalid"))?;
|
||||
validate_bundle_redirect(&initial_scheme, &next_url)?;
|
||||
current_url = next_url;
|
||||
redirects += 1;
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"bundle fetch failed with HTTP status {}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
let mut policy = network.clone();
|
||||
if is_loopback_bundle_host(&initial_host) {
|
||||
policy.add_allow(&initial_host);
|
||||
}
|
||||
let check =
|
||||
move |next: &reqwest::Url| validate_bundle_redirect(&initial_scheme, &initial_host, next);
|
||||
let request = codewhale_net::guard::GuardedGet::new(
|
||||
url,
|
||||
&policy,
|
||||
"config_import",
|
||||
codewhale_net::guard::PrivateTargets::TypedLocalHosts,
|
||||
)
|
||||
.timeouts(
|
||||
std::time::Duration::from_secs(FETCH_TIMEOUT_SECS),
|
||||
std::time::Duration::from_secs(FETCH_TIMEOUT_SECS),
|
||||
)
|
||||
.max_redirects(MAX_REDIRECTS)
|
||||
.redirect_check(&check);
|
||||
|
||||
// Read at most MAX_BUNDLE_BYTES + 1 so an oversize body is detected
|
||||
// rather than silently truncated.
|
||||
let mut buffer = Vec::new();
|
||||
let body = response;
|
||||
body.take(MAX_BUNDLE_BYTES + 1)
|
||||
.read_to_end(&mut buffer)
|
||||
.map_err(|_| anyhow!("reading remote bundle failed"))?;
|
||||
if buffer.len() as u64 > MAX_BUNDLE_BYTES {
|
||||
bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
|
||||
}
|
||||
Ok(buffer)
|
||||
run_blocking(async move {
|
||||
let mut response = request.send().await?;
|
||||
if !response.status().is_success() {
|
||||
bail!(
|
||||
"bundle fetch failed with HTTP status {}",
|
||||
response.status().as_u16()
|
||||
);
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > MAX_BUNDLE_BYTES)
|
||||
{
|
||||
bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
|
||||
}
|
||||
let mut buffer = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|_| anyhow!("reading remote bundle failed"))?
|
||||
{
|
||||
if (buffer.len() + chunk.len()) as u64 > MAX_BUNDLE_BYTES {
|
||||
bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused");
|
||||
}
|
||||
buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(buffer)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_blocking<T: Send>(future: impl std::future::Future<Output = Result<T>> + Send) -> Result<T> {
|
||||
std::thread::scope(|scope| {
|
||||
scope
|
||||
.spawn(|| {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|_| anyhow!("building bundle fetch runtime failed"))?
|
||||
.block_on(future)
|
||||
})
|
||||
.join()
|
||||
.map_err(|_| anyhow!("bundle fetch thread failed"))?
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
|
||||
@@ -877,19 +914,37 @@ fn validate_bundle_url(url: &reqwest::Url) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_bundle_redirect(initial_scheme: &str, next_url: &reqwest::Url) -> Result<()> {
|
||||
fn validate_bundle_redirect(
|
||||
initial_scheme: &str,
|
||||
initial_host: &str,
|
||||
next_url: &reqwest::Url,
|
||||
) -> Result<()> {
|
||||
validate_bundle_url(next_url)?;
|
||||
if next_url.scheme() != initial_scheme {
|
||||
bail!("bundle redirects may not change URL scheme");
|
||||
}
|
||||
let next_host = next_url
|
||||
.host_str()
|
||||
.context("bundle URL must include a host")?;
|
||||
if codewhale_net::guard::is_visibly_local_host(next_host)
|
||||
&& !same_bundle_host(initial_host, next_host)
|
||||
{
|
||||
bail!("bundle redirects may not target a different local host");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn same_bundle_host(left: &str, right: &str) -> bool {
|
||||
let normalize = |host: &str| host.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
normalize(left) == normalize(right)
|
||||
}
|
||||
|
||||
fn is_loopback_bundle_host(host: &str) -> bool {
|
||||
let normalized = host
|
||||
.strip_prefix('[')
|
||||
.and_then(|value| value.strip_suffix(']'))
|
||||
.unwrap_or(host);
|
||||
.unwrap_or(host)
|
||||
.trim_end_matches('.');
|
||||
normalized.eq_ignore_ascii_case("localhost")
|
||||
|| normalized.to_ascii_lowercase().ends_with(".localhost")
|
||||
|| normalized
|
||||
@@ -1571,7 +1626,13 @@ pub fn run_import(
|
||||
}
|
||||
buffer
|
||||
} else if remote_source {
|
||||
fetch_bundle(&args.source)?
|
||||
let network = store
|
||||
.config
|
||||
.network
|
||||
.clone()
|
||||
.map(codewhale_net::network_policy::NetworkPolicy::from)
|
||||
.unwrap_or_default();
|
||||
fetch_bundle(&args.source, &network)?
|
||||
} else {
|
||||
let path = PathBuf::from(&args.source);
|
||||
let metadata = std::fs::metadata(&path)
|
||||
@@ -1672,6 +1733,7 @@ pub fn run_export(args: &ExportArgs, store: &codewhale_config::ConfigStore) -> R
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codewhale_config::ConfigStore;
|
||||
use codewhale_net::network_policy::NetworkPolicy;
|
||||
use std::io::Write;
|
||||
use std::net::{Ipv4Addr, TcpListener};
|
||||
|
||||
@@ -4183,7 +4245,7 @@ label = "safe-nested-workspace-label"
|
||||
|
||||
#[test]
|
||||
fn http_non_loopback_fetch_is_refused_without_network_access() {
|
||||
let err = fetch_bundle("http://example.com/bundle.toml")
|
||||
let err = fetch_bundle("http://example.com/bundle.toml", &NetworkPolicy::default())
|
||||
.expect_err("plain http to a public host must be refused");
|
||||
assert!(err.to_string().contains("loopback"), "{err:#}");
|
||||
assert!(!err.to_string().contains("example.com"), "{err:#}");
|
||||
@@ -4196,7 +4258,8 @@ label = "safe-nested-workspace-label"
|
||||
let response = http_response("302 Found", &[("Location", location.as_str())], b"");
|
||||
let (url, server) = spawn_bundle_http_server(vec![response]);
|
||||
|
||||
let error = fetch_bundle(&url).expect_err("redirect target must be revalidated");
|
||||
let error = fetch_bundle(&url, &NetworkPolicy::default())
|
||||
.expect_err("redirect target must be revalidated");
|
||||
let rendered = format!("{error:#}");
|
||||
assert!(rendered.contains("loopback"), "{rendered}");
|
||||
assert!(!rendered.contains("example.com"), "{rendered}");
|
||||
@@ -4204,13 +4267,149 @@ label = "safe-nested-workspace-label"
|
||||
assert_eq!(server.join().expect("server joins"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibly_local_network_allow_entries_are_rejected_while_public_hosts_import() {
|
||||
let local_bundle = parse_bundle_str(
|
||||
r#"
|
||||
schema_version = 1
|
||||
kind = "codewhale.portable-config"
|
||||
|
||||
[global.network]
|
||||
default = "prompt"
|
||||
allow = ["docs.example.com", "localhost", "10.0.0.5"]
|
||||
"#,
|
||||
"local-allow.toml",
|
||||
)
|
||||
.expect("local allow bundle parses");
|
||||
let rejected = find_rejected_entries(&local_bundle);
|
||||
assert_eq!(rejected.len(), 1, "{rejected:?}");
|
||||
assert_eq!(rejected[0].key, "global.network");
|
||||
assert!(rejected[0].reason.contains("network.allow"), "{rejected:?}");
|
||||
assert!(!rejected[0].reason.contains("localhost"), "{rejected:?}");
|
||||
assert!(!rejected[0].reason.contains("10.0.0.5"), "{rejected:?}");
|
||||
|
||||
let mut store = isolated_store();
|
||||
let before = std::fs::read(store.path()).expect("config before import");
|
||||
let dir = store.path().parent().expect("config dir").to_path_buf();
|
||||
apply_bundle(&local_bundle, &mut store, BundleScope::Global, &dir)
|
||||
.expect_err("bundle adding visibly local network.allow entries must fail");
|
||||
assert_eq!(
|
||||
std::fs::read(store.path()).expect("config after rejected import"),
|
||||
before
|
||||
);
|
||||
|
||||
let public_bundle = parse_bundle_str(
|
||||
r#"
|
||||
schema_version = 1
|
||||
kind = "codewhale.portable-config"
|
||||
|
||||
[global.network]
|
||||
default = "prompt"
|
||||
allow = ["docs.example.com"]
|
||||
"#,
|
||||
"public-allow.toml",
|
||||
)
|
||||
.expect("public allow bundle parses");
|
||||
assert!(find_rejected_entries(&public_bundle).is_empty());
|
||||
let receipt = apply_bundle(&public_bundle, &mut store, BundleScope::Global, &dir)
|
||||
.expect("public network.allow entry must import");
|
||||
assert!(receipt.plan.rejected.is_empty());
|
||||
assert_eq!(
|
||||
store
|
||||
.config
|
||||
.network
|
||||
.as_ref()
|
||||
.map(|network| network.allow.as_slice()),
|
||||
Some(&["docs.example.com".to_string()][..])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn localdomain_and_localhost_wildcard_network_allow_entries_are_rejected_and_scrubbed() {
|
||||
for (section, entry) in [
|
||||
("global", ".localdomain"),
|
||||
("global", "*.localdomain"),
|
||||
("global", ".localhost"),
|
||||
("global", "*.localhost"),
|
||||
("global", "*.dev.localhost"),
|
||||
("project", "*.localdomain"),
|
||||
] {
|
||||
let bundle_text = format!(
|
||||
r#"
|
||||
schema_version = 1
|
||||
kind = "codewhale.portable-config"
|
||||
|
||||
[{section}.network]
|
||||
default = "prompt"
|
||||
allow = ["docs.example.com", "{entry}"]
|
||||
"#
|
||||
);
|
||||
let bundle = parse_bundle_str(&bundle_text, "wildcard-local-allow.toml")
|
||||
.expect("wildcard allow bundle parses");
|
||||
let rejected = find_rejected_entries(&bundle);
|
||||
assert_eq!(rejected.len(), 1, "{section} {entry}: {rejected:?}");
|
||||
assert_eq!(rejected[0].key, format!("{section}.network"));
|
||||
assert!(rejected[0].reason.contains("network.allow"), "{rejected:?}");
|
||||
assert!(!rejected[0].reason.contains(entry), "{rejected:?}");
|
||||
}
|
||||
|
||||
let combined = parse_bundle_str(
|
||||
r#"
|
||||
schema_version = 1
|
||||
kind = "codewhale.portable-config"
|
||||
|
||||
[global.network]
|
||||
default = "prompt"
|
||||
allow = [".localdomain", "*.localdomain", "*.localhost"]
|
||||
"#,
|
||||
"combined-wildcard-local-allow.toml",
|
||||
)
|
||||
.expect("combined wildcard bundle parses");
|
||||
let mut store = isolated_store();
|
||||
let before = std::fs::read(store.path()).expect("config before import");
|
||||
let dir = store.path().parent().expect("config dir").to_path_buf();
|
||||
apply_bundle(&combined, &mut store, BundleScope::Global, &dir)
|
||||
.expect_err("bundle adding local-domain wildcard network.allow entries must fail");
|
||||
assert_eq!(
|
||||
std::fs::read(store.path()).expect("config after rejected import"),
|
||||
before
|
||||
);
|
||||
|
||||
let config: ConfigToml = toml::from_str(
|
||||
r#"
|
||||
[network]
|
||||
default = "prompt"
|
||||
allow = ["docs.example.com", ".localdomain", "*.localdomain", ".localhost", "*.localhost", "localhost", "10.0.0.5"]
|
||||
"#,
|
||||
)
|
||||
.expect("config with local network.allow entries parses");
|
||||
let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default())
|
||||
.expect("local network.allow entries are scrubbed on export");
|
||||
let body = serialize_bundle(&exported).expect("serialize scrubbed export");
|
||||
for forbidden in [
|
||||
".localdomain",
|
||||
"*.localdomain",
|
||||
".localhost",
|
||||
"*.localhost",
|
||||
"\"localhost\"",
|
||||
"10.0.0.5",
|
||||
] {
|
||||
assert!(
|
||||
!body.contains(forbidden),
|
||||
"export retained {forbidden}: {body}"
|
||||
);
|
||||
}
|
||||
assert!(body.contains("docs.example.com"), "{body}");
|
||||
assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn https_redirect_cannot_downgrade_to_loopback_http() {
|
||||
let secret = "downgrade-secret-must-not-leak";
|
||||
let target = reqwest::Url::parse(&format!("http://127.0.0.1/internal?token={secret}"))
|
||||
.expect("test target URL");
|
||||
|
||||
let error = validate_bundle_redirect("https", &target)
|
||||
let error = validate_bundle_redirect("https", "example.com", &target)
|
||||
.expect_err("HTTPS redirect must not downgrade to loopback HTTP");
|
||||
let rendered = format!("{error:#}");
|
||||
assert!(rendered.contains("scheme"), "{rendered}");
|
||||
@@ -4218,6 +4417,59 @@ label = "safe-nested-workspace-label"
|
||||
assert!(!rendered.contains("127.0.0.1"), "{rendered}");
|
||||
}
|
||||
|
||||
fn spawn_counting_server(
|
||||
response: Vec<u8>,
|
||||
) -> (u16, std::sync::Arc<std::sync::atomic::AtomicUsize>) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind counting fixture");
|
||||
let port = listener.local_addr().expect("fixture address").port();
|
||||
let hits = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let counter = std::sync::Arc::clone(&hits);
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(mut stream) = stream else { return };
|
||||
counter.fetch_add(1, Ordering::SeqCst);
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let _ = stream.read(&mut chunk);
|
||||
let _ = stream.write_all(&response);
|
||||
}
|
||||
});
|
||||
(port, hits)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redirect_to_another_local_host_is_refused() {
|
||||
use std::sync::atomic::Ordering;
|
||||
let (port, hits) = spawn_counting_server(http_response(
|
||||
"200 OK",
|
||||
&[("Content-Type", "text/plain")],
|
||||
VALID_TOML.as_bytes(),
|
||||
));
|
||||
let location = format!("http://localhost:{port}/bundle.toml");
|
||||
let redirect = http_response("302 Found", &[("Location", location.as_str())], b"");
|
||||
let (url, entry_server) = spawn_bundle_http_server(vec![redirect.clone()]);
|
||||
|
||||
let error = fetch_bundle(&url, &NetworkPolicy::default())
|
||||
.expect_err("a hop to another local host must be refused");
|
||||
assert!(!format!("{error:#}").contains("bundle.toml"), "{error:#}");
|
||||
assert_eq!(entry_server.join().expect("entry joins"), 1);
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 0);
|
||||
|
||||
let (listed_url, listed_entry_server) = spawn_bundle_http_server(vec![redirect]);
|
||||
let listed_policy = NetworkPolicy {
|
||||
allow: vec!["localhost".to_string()],
|
||||
..NetworkPolicy::default()
|
||||
};
|
||||
let listed_error = fetch_bundle(&listed_url, &listed_policy)
|
||||
.expect_err("even a listed local host must be refused on redirect from another host");
|
||||
assert!(
|
||||
!format!("{listed_error:#}").contains("bundle.toml"),
|
||||
"{listed_error:#}"
|
||||
);
|
||||
assert_eq!(listed_entry_server.join().expect("listed entry joins"), 1);
|
||||
assert_eq!(hits.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_loopback_redirect_fetches_bundle() {
|
||||
let redirect = http_response("302 Found", &[("Location", "/bundle.toml")], b"");
|
||||
@@ -4225,7 +4477,8 @@ label = "safe-nested-workspace-label"
|
||||
let success = http_response("200 OK", &[("Content-Type", "text/plain")], body);
|
||||
let (url, server) = spawn_bundle_http_server(vec![redirect, success]);
|
||||
|
||||
let fetched = fetch_bundle(&url).expect("relative redirect remains allowed");
|
||||
let fetched = fetch_bundle(&url, &NetworkPolicy::default())
|
||||
.expect("relative redirect remains allowed");
|
||||
assert_eq!(fetched, body);
|
||||
assert_eq!(server.join().expect("server joins"), 2);
|
||||
}
|
||||
@@ -4240,16 +4493,23 @@ label = "safe-nested-workspace-label"
|
||||
.collect();
|
||||
let (url, server) = spawn_bundle_http_server(responses);
|
||||
|
||||
let error = fetch_bundle(&url).expect_err("sixth redirect must be refused");
|
||||
assert!(error.to_string().contains("five-redirect"), "{error:#}");
|
||||
let error = fetch_bundle(&url, &NetworkPolicy::default())
|
||||
.expect_err("sixth redirect must be refused");
|
||||
assert!(
|
||||
error.to_string().contains("too many redirects"),
|
||||
"{error:#}"
|
||||
);
|
||||
assert_eq!(server.join().expect("server joins"), MAX_REDIRECTS + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_url_credentials_are_rejected_without_echoing_them() {
|
||||
let secret = "credential-secret-must-not-leak";
|
||||
let error = fetch_bundle(&format!("https://user:{secret}@example.com/bundle.toml"))
|
||||
.expect_err("URL userinfo must be refused");
|
||||
let error = fetch_bundle(
|
||||
&format!("https://user:{secret}@example.com/bundle.toml"),
|
||||
&NetworkPolicy::default(),
|
||||
)
|
||||
.expect_err("URL userinfo must be refused");
|
||||
let rendered = format!("{error:#}");
|
||||
assert!(rendered.contains("credentials"), "{rendered}");
|
||||
assert!(!rendered.contains(secret), "{rendered}");
|
||||
@@ -4258,7 +4518,8 @@ label = "safe-nested-workspace-label"
|
||||
|
||||
#[test]
|
||||
fn unsupported_schemes_are_refused() {
|
||||
let err = fetch_bundle("file:///etc/passwd").expect_err("file scheme refused");
|
||||
let err = fetch_bundle("file:///etc/passwd", &NetworkPolicy::default())
|
||||
.expect_err("file scheme refused");
|
||||
assert!(err.to_string().contains("scheme"), "{err:#}");
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "Install, inspect, review, trust, enable, disable, update, uninstall or revoke 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 install <SPEC> [--json] Install a plugin bundle without trusting or enabling it\n status [NAME] [--json] Inspect plugin status and diagnostics\n list [NAME] [--json] Alias of status\n show <NAME> [--json] Show the capability review without prompting or trusting\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 update <NAME> [--json] Update an installed plugin bundle from its recorded source\n uninstall <NAME> [--yes] [--json] Disable if needed and remove an installed plugin bundle\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."
|
||||
)]
|
||||
Plugins(TuiPassthroughArgs),
|
||||
/// Bootstrap MCP config and/or skills directories.
|
||||
|
||||
@@ -1592,6 +1592,7 @@ pub struct PlanSections {
|
||||
pub verification_plan: Option<String>,
|
||||
pub risks_and_unknowns: Option<String>,
|
||||
pub handoff_packet: Option<String>,
|
||||
pub replan_if: Vec<String>,
|
||||
pub items: Vec<PlanStep>,
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct StructcopyPlan {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handoff_packet: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replan_if: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub items: Vec<StructcopyPlanItem>,
|
||||
}
|
||||
|
||||
|
||||
@@ -605,7 +605,7 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
def(
|
||||
"show_thinking",
|
||||
SettingKind::Bool(SHOW_THINKING),
|
||||
"false",
|
||||
"true",
|
||||
ui(
|
||||
TAB_APPEARANCE,
|
||||
"display",
|
||||
@@ -1546,6 +1546,12 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
|
||||
"false",
|
||||
None,
|
||||
),
|
||||
def(
|
||||
"account_preferences_notice_shown",
|
||||
SettingKind::String,
|
||||
"",
|
||||
None,
|
||||
),
|
||||
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
|
||||
def(
|
||||
"work_surface_bottom_migrated",
|
||||
|
||||
@@ -171,6 +171,7 @@ sha2.workspace = true
|
||||
# Reuse the locked cryptographic implementation for exact-call user decisions.
|
||||
ring = "0.17"
|
||||
codewhale-localization = { path = "../localization", version = "0.10.3" }
|
||||
codewhale-ratatui = { path = "../../vendor/codewhale-ratatui", version = "0.2.0" }
|
||||
shell-words = "1.1.1"
|
||||
|
||||
wiremock = { version = "0.6", optional = true }
|
||||
|
||||
+2
-1
@@ -12821,7 +12821,8 @@ var init_compatibility = __esm({
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
row_bridges: {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
@@ -15173,7 +15173,8 @@ var init_compatibility = __esm({
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
row_bridges: {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
@@ -12799,7 +12799,8 @@ var init_compatibility = __esm({
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
row_bridges: {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ var ErrorCode = {
|
||||
Denied: -32003,
|
||||
Cancelled: -32800
|
||||
};
|
||||
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote"];
|
||||
var DSH_SERVICES = ["workspace", "llm", "network", "settings", "catalog", "terminal", "remote", "plugins"];
|
||||
var METHODS = [
|
||||
{ name: "host/initialize", direction: "core_to_host", request: true, params: "InitializeParams", tiers: ["plugin", "builtin"] },
|
||||
{ name: "host/ping", direction: "core_to_host", request: true, params: "EmptyParams", tiers: ["plugin", "builtin"] },
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { AdapterHost, DshAdapter } from './adapters.ts'
|
||||
|
||||
export interface ApprovedRoute {
|
||||
provider: string
|
||||
base_url: string
|
||||
default_model: string | null
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export interface ResolvedRoute extends ApprovedRoute {
|
||||
model: string | null
|
||||
}
|
||||
|
||||
export interface InstallPlan {
|
||||
status: 'review_required' | 'already_present'
|
||||
package: string
|
||||
version: string
|
||||
plugin: string
|
||||
spec?: string
|
||||
command: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type BalanceAnswer =
|
||||
| { status: 'known', currency: string, total: string, topped_up: string, granted: string }
|
||||
| { status: 'unknown' }
|
||||
| { status: 'absent' }
|
||||
|
||||
interface AccountWallet {
|
||||
currency: string
|
||||
balance: string
|
||||
}
|
||||
|
||||
type AccountBalance = { status: 'ready', value: AccountWallet[], bonusWallets: AccountWallet[] } | { status: 'failed' }
|
||||
|
||||
interface ChangeResult {
|
||||
changed: false
|
||||
application: 'failed'
|
||||
stage: 'install' | 'remove'
|
||||
target: string
|
||||
error: { code: string, diagnostic: string, message: string }
|
||||
}
|
||||
|
||||
const EXACT_VERSION_ONLY = 'Codewhale plans a plugin install only for one exact npm version, written [email protected]'
|
||||
const REMOVE_IN_CODEWHALE = 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall'
|
||||
const WITHHELD_ACCOUNT_METHODS = [
|
||||
'getState', 'getProfile', 'getUnnotifiedBonuses', 'ackBonusNotified', 'startSignIn', 'cancelSignIn', 'signOut', 'watch',
|
||||
'resolveToken', 'rejectToken', 'getPlatformSession', 'getDeviceIdentity',
|
||||
] as const
|
||||
|
||||
function invalid(what: string): RpcError {
|
||||
return new RpcError(ErrorCode.Internal, `invalid catalog broker ${what}`)
|
||||
}
|
||||
|
||||
function record(value: unknown, what: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) throw invalid(what)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function text(value: unknown, what: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw invalid(what)
|
||||
return value
|
||||
}
|
||||
|
||||
function named(value: unknown, what: string): string {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, `${what} is a non-empty string`)
|
||||
return value
|
||||
}
|
||||
|
||||
function approvedRoute(value: unknown): ApprovedRoute {
|
||||
const row = record(value, 'route')
|
||||
const models = row.models
|
||||
const defaultModel = row.default_model
|
||||
if (!Array.isArray(models) || models.some((model) => typeof model !== 'string') || (defaultModel !== null && typeof defaultModel !== 'string')) throw invalid('route')
|
||||
return { provider: text(row.provider, 'route'), base_url: text(row.base_url, 'route'), default_model: defaultModel, models: [...models] }
|
||||
}
|
||||
|
||||
export async function approvedRoutes(host: AdapterHost, ctx: unknown): Promise<ApprovedRoute[]> {
|
||||
const routes = record(await host.broker(ctx, 'catalog', 'routes.list', {}), 'routes').routes
|
||||
if (!Array.isArray(routes)) throw invalid('routes')
|
||||
return routes.map(approvedRoute)
|
||||
}
|
||||
|
||||
export async function resolveRoute(host: AdapterHost, ctx: unknown, provider: unknown, model?: unknown): Promise<ResolvedRoute> {
|
||||
const input = model === undefined || model === null
|
||||
? { provider: named(provider, 'a provider route') }
|
||||
: { provider: named(provider, 'a provider route'), model: named(model, 'a model') }
|
||||
const answer = record(await host.broker(ctx, 'catalog', 'routes.resolve', input), 'route')
|
||||
const resolved = answer.model
|
||||
if (resolved !== null && typeof resolved !== 'string') throw invalid('route')
|
||||
return { ...approvedRoute(answer.route), model: resolved }
|
||||
}
|
||||
|
||||
export async function accountBalance(host: AdapterHost, ctx: unknown): Promise<BalanceAnswer> {
|
||||
const answer = record(await host.broker(ctx, 'catalog', 'balance', {}), 'balance')
|
||||
const status = answer.status
|
||||
if (status === 'unknown' || status === 'absent') return { status }
|
||||
const toppedUp = answer.topped_up
|
||||
const granted = answer.granted
|
||||
if (status !== 'known' || typeof toppedUp !== 'string' || typeof granted !== 'string') throw invalid('balance')
|
||||
return { status, currency: text(answer.currency, 'balance'), total: text(answer.total, 'balance'), topped_up: toppedUp, granted }
|
||||
}
|
||||
|
||||
export async function planInstall(host: AdapterHost, ctx: unknown, spec: unknown, registry?: unknown): Promise<InstallPlan> {
|
||||
const exact = typeof spec === 'string' ? spec.lastIndexOf('@') : -1
|
||||
if (typeof spec !== 'string' || exact <= 0 || exact === spec.length - 1) throw new RpcError(ErrorCode.InvalidParams, EXACT_VERSION_ONLY)
|
||||
const request = { package: spec.slice(0, exact), version: spec.slice(exact + 1) }
|
||||
const input = registry === undefined || registry === null ? request : { ...request, registry: named(registry, 'a registry URL') }
|
||||
const plan = record(await host.broker(ctx, 'catalog', 'install.plan', input), 'install plan')
|
||||
const status = plan.status
|
||||
const planned = plan.spec
|
||||
if ((status !== 'review_required' && status !== 'already_present') || (planned !== undefined && typeof planned !== 'string')) throw invalid('install plan')
|
||||
return {
|
||||
status,
|
||||
package: text(plan.package, 'install plan'),
|
||||
version: text(plan.version, 'install plan'),
|
||||
plugin: text(plan.plugin, 'install plan'),
|
||||
...(planned === undefined ? {} : { spec: planned }),
|
||||
command: text(plan.command, 'install plan'),
|
||||
message: text(plan.message, 'install plan'),
|
||||
}
|
||||
}
|
||||
|
||||
function notChanged(stage: ChangeResult['stage'], target: unknown, code: string, diagnostic: string): ChangeResult {
|
||||
return { changed: false, application: 'failed', stage, target: typeof target === 'string' ? target : '', error: { code, diagnostic, message: diagnostic } }
|
||||
}
|
||||
|
||||
function declined(error: unknown): string | undefined {
|
||||
if (!(error instanceof Error)) return undefined
|
||||
if (error instanceof RpcError && error.code === ErrorCode.InvalidParams && error.data === undefined) return error.message
|
||||
const refusal = (error as { data?: { dsh_refusal?: unknown } }).data?.dsh_refusal
|
||||
return refusal === 'refused' || refusal === 'invalid_input' ? error.message : undefined
|
||||
}
|
||||
|
||||
export const pluginManagerAdapter: DshAdapter = {
|
||||
service: 'pluginManager',
|
||||
define(host) {
|
||||
class PluginManagerService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'pluginManager') }
|
||||
|
||||
async installBundle(spec: unknown, options?: { registry?: unknown } | null): Promise<ChangeResult> {
|
||||
try {
|
||||
const plan = await planInstall(host, this.ctx, spec, options?.registry)
|
||||
return notChanged('install', spec, plan.status === 'already_present' ? 'already-installed' : 'management-required', plan.message)
|
||||
} catch (error) {
|
||||
const diagnostic = declined(error)
|
||||
if (diagnostic === undefined) throw error
|
||||
return notChanged('install', spec, 'invalid-spec', diagnostic)
|
||||
}
|
||||
}
|
||||
|
||||
async removeBundle(target: unknown): Promise<ChangeResult> {
|
||||
return notChanged('remove', target, 'management-required', REMOVE_IN_CODEWHALE)
|
||||
}
|
||||
|
||||
async cancelInstall(): Promise<{ status: 'not-running' }> {
|
||||
return { status: 'not-running' }
|
||||
}
|
||||
}
|
||||
Object.freeze(PluginManagerService.prototype)
|
||||
return PluginManagerService
|
||||
},
|
||||
}
|
||||
|
||||
export const deepseekAccountAdapter: DshAdapter = {
|
||||
service: 'deepseekAccount',
|
||||
define(host) {
|
||||
class DeepSeekAccountService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'deepseekAccount') }
|
||||
|
||||
async getBalance(): Promise<AccountBalance | null> {
|
||||
const answer = await accountBalance(host, this.ctx)
|
||||
if (answer.status === 'absent') return null
|
||||
if (answer.status === 'unknown') return { status: 'failed' }
|
||||
const { currency, total, topped_up: toppedUp, granted } = answer
|
||||
const wallet = (balance: string): AccountWallet[] => balance.length > 0 ? [{ currency, balance }] : []
|
||||
return { status: 'ready', value: wallet(toppedUp.length > 0 ? toppedUp : total), bonusWallets: wallet(granted) }
|
||||
}
|
||||
}
|
||||
for (const method of WITHHELD_ACCOUNT_METHODS) {
|
||||
Object.defineProperty(DeepSeekAccountService.prototype, method, {
|
||||
value() {
|
||||
throw new RpcError(ErrorCode.NotAvailable, `deepseekAccount.${method} is not provided: Codewhale answers a read-only balance and never gives a plugin an account credential or a sign-in`)
|
||||
},
|
||||
})
|
||||
}
|
||||
Object.freeze(DeepSeekAccountService.prototype)
|
||||
return DeepSeekAccountService
|
||||
},
|
||||
}
|
||||
@@ -49,7 +49,8 @@
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
"row_bridges": {
|
||||
"@deepseek-ai/dsh-mcp-client": "mcp",
|
||||
|
||||
@@ -337,7 +337,8 @@
|
||||
"avatars",
|
||||
"mcp",
|
||||
"logger",
|
||||
"userQuestions"
|
||||
"userQuestions",
|
||||
"remote"
|
||||
],
|
||||
"events": [
|
||||
{
|
||||
|
||||
@@ -18,6 +18,19 @@ function credentialRef(ref: unknown): string {
|
||||
return ref
|
||||
}
|
||||
|
||||
function credentialSite(site: unknown): string {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(String(site))
|
||||
} catch {
|
||||
throw new RpcError(ErrorCode.InvalidParams, 'a credential is stored for one https site, such as https://api.example.com')
|
||||
}
|
||||
if (typeof site !== 'string' || url.protocol !== 'https:' || url.username !== '' || url.password !== '' || url.pathname !== '/' || url.search !== '' || url.hash !== '') {
|
||||
throw new RpcError(ErrorCode.InvalidParams, 'a credential is stored for one https site, such as https://api.example.com')
|
||||
}
|
||||
return url.origin
|
||||
}
|
||||
|
||||
function brokered(value: unknown): BrokeredResponse {
|
||||
const response = value as Partial<BrokeredResponse> | null
|
||||
if (response === null || typeof response !== 'object' || typeof response.status !== 'number' || typeof response.url !== 'string'
|
||||
@@ -71,15 +84,15 @@ export const credentialsAdapter: DshAdapter = {
|
||||
return { value }
|
||||
}
|
||||
|
||||
async describe(ref: unknown): Promise<{ ref: string, set: boolean }> {
|
||||
const result = await host.broker(this.ctx, 'network', 'credentials.describe', { ref: credentialRef(ref) }) as { ref?: unknown, set?: unknown } | null
|
||||
async describe(ref: unknown): Promise<{ ref: string, set: boolean, site?: string }> {
|
||||
const result = await host.broker(this.ctx, 'network', 'credentials.describe', { ref: credentialRef(ref) }) as { ref?: unknown, set?: unknown, site?: unknown } | null
|
||||
if (result === null || typeof result !== 'object' || typeof result.set !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid credential description')
|
||||
return { ref: credentialRef(ref), set: result.set }
|
||||
return typeof result.site === 'string' ? { ref: credentialRef(ref), set: result.set, site: result.site } : { ref: credentialRef(ref), set: result.set }
|
||||
}
|
||||
|
||||
async set(ref: unknown, value: unknown): Promise<void> {
|
||||
async set(ref: unknown, value: unknown, site: unknown): Promise<void> {
|
||||
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a credential value is a non-empty string')
|
||||
await host.broker(this.ctx, 'network', 'credentials.set', { ref: credentialRef(ref), value })
|
||||
await host.broker(this.ctx, 'network', 'credentials.set', { ref: credentialRef(ref), value, site: credentialSite(site) })
|
||||
}
|
||||
|
||||
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
import { ErrorCode } from '../protocol.ts'
|
||||
import { RpcError } from '../rpc.ts'
|
||||
import type { AdapterHost, DshAdapter } from './adapters.ts'
|
||||
|
||||
export interface SettingsDescriptor {
|
||||
ns: string
|
||||
writable: boolean
|
||||
roots: string[]
|
||||
value: Record<string, unknown>
|
||||
revision: string
|
||||
applied: boolean | null
|
||||
applies: 'reload'
|
||||
}
|
||||
|
||||
export type SettingsPathOp =
|
||||
| { op: 'set', path: readonly string[], value: unknown }
|
||||
| { op: 'unset', path: readonly string[] }
|
||||
|
||||
export interface SettingsWrite {
|
||||
written: boolean
|
||||
revision: string
|
||||
applies: 'reload'
|
||||
}
|
||||
|
||||
export class SettingsConflictError extends Error {
|
||||
readonly code = 'SETTINGS_CONFLICT'
|
||||
constructor(readonly ns: string, readonly expected: string | undefined, readonly actual: string) {
|
||||
super(`settings namespace "${ns}" changed since it was read`)
|
||||
this.name = 'SettingsConflictError'
|
||||
}
|
||||
}
|
||||
|
||||
function plain(value: unknown, what: string): Record<string, unknown> {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new RpcError(ErrorCode.InvalidParams, `${what} is a plain object`)
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function revision(value: unknown): string | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'an expected revision is the revision string describe returned')
|
||||
return value
|
||||
}
|
||||
|
||||
function descriptor(value: unknown): SettingsDescriptor {
|
||||
const row = value as Partial<SettingsDescriptor> | null
|
||||
if (row === null || typeof row !== 'object' || typeof row.ns !== 'string' || typeof row.revision !== 'string' || typeof row.writable !== 'boolean'
|
||||
|| !Array.isArray(row.roots) || typeof row.value !== 'object' || row.value === null || row.applies !== 'reload') {
|
||||
throw new RpcError(ErrorCode.Internal, 'invalid settings broker response')
|
||||
}
|
||||
return { ns: row.ns, writable: row.writable, roots: row.roots.map(String), value: row.value, revision: row.revision, applied: typeof row.applied === 'boolean' ? row.applied : null, applies: 'reload' }
|
||||
}
|
||||
|
||||
export const settingsAdapter: DshAdapter = {
|
||||
service: 'settings',
|
||||
define(host: AdapterHost) {
|
||||
class SettingsService extends Service {
|
||||
constructor(ctx: any) { super(ctx, 'settings') }
|
||||
|
||||
async describe(): Promise<SettingsDescriptor[]> {
|
||||
return [descriptor(await host.broker(this.ctx, 'settings', 'describe', {}))]
|
||||
}
|
||||
|
||||
async update(ns: unknown, patch: unknown, expectedRevision?: unknown): Promise<SettingsWrite> {
|
||||
return this.write(ns, 'update', { patch: plain(patch, 'a settings patch') }, expectedRevision)
|
||||
}
|
||||
|
||||
async replace(ns: unknown, section: unknown, expectedRevision?: unknown): Promise<SettingsWrite> {
|
||||
return this.write(ns, 'replace', { value: plain(section, 'a settings section') }, expectedRevision)
|
||||
}
|
||||
|
||||
async mutate(ns: unknown, ops: unknown, expectedRevision?: unknown): Promise<SettingsWrite> {
|
||||
if (!Array.isArray(ops)) throw new RpcError(ErrorCode.InvalidParams, 'settings edits are an array of path operations')
|
||||
return this.write(ns, 'mutate', { ops }, expectedRevision)
|
||||
}
|
||||
|
||||
private async write(ns: unknown, method: 'update' | 'replace' | 'mutate', input: Record<string, unknown>, expectedRevision: unknown): Promise<SettingsWrite> {
|
||||
if (typeof ns !== 'string' || ns.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a settings namespace is a non-empty string')
|
||||
const owner = host.ownerOf(this.ctx)
|
||||
if (owner === undefined || ns !== owner.pluginName) throw new RpcError(ErrorCode.Refused, 'a plugin may edit only its own settings namespace')
|
||||
const expected = revision(expectedRevision)
|
||||
const result = await host.broker(this.ctx, 'settings', method, expected === undefined ? input : { ...input, expected_revision: expected }) as { written?: unknown, conflict?: unknown, revision?: unknown } | null
|
||||
if (result === null || typeof result !== 'object' || typeof result.written !== 'boolean' || typeof result.revision !== 'string') throw new RpcError(ErrorCode.Internal, 'invalid settings broker response')
|
||||
if (result.conflict === true) throw new SettingsConflictError(ns, expected, result.revision)
|
||||
return { written: result.written, revision: result.revision, applies: 'reload' }
|
||||
}
|
||||
}
|
||||
Object.freeze(SettingsService.prototype)
|
||||
return SettingsService
|
||||
},
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const ErrorCode = {
|
||||
|
||||
export type Direction = 'core_to_host' | 'host_to_core'
|
||||
|
||||
export const DSH_SERVICES = ['workspace', 'llm', 'network', 'settings', 'catalog', 'terminal', 'remote'] as const
|
||||
export const DSH_SERVICES = ['workspace', 'llm', 'network', 'settings', 'catalog', 'terminal', 'remote', 'plugins'] as const
|
||||
export type DshService = typeof DSH_SERVICES[number]
|
||||
|
||||
/** Every method either side may send, and the trust tiers it is allowed on; nothing else is admitted. */
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let catalog
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-catalog-'))
|
||||
const outfile = join(dir, 'catalog.mjs')
|
||||
await build({ entryPoints: [join(here, '../src/dsh/catalog.ts')], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
catalog = await import(pathToFileURL(outfile).href)
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function brokerHost(answers) {
|
||||
const calls = []
|
||||
return {
|
||||
calls,
|
||||
host: {
|
||||
tier: 'plugin',
|
||||
broker: async (ctx, service, method, input) => {
|
||||
calls.push({ service, method, input })
|
||||
const answer = typeof answers[method] === 'function' ? answers[method](input) : answers[method]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function serviceOf(adapter, host) {
|
||||
const service = Object.create(adapter.define(host).prototype)
|
||||
service.ctx = {}
|
||||
return service
|
||||
}
|
||||
|
||||
const brokerRefusal = (code, refusal, message) => Object.assign(new Error(message), { code, data: { dsh_refusal: refusal, service: 'catalog' } })
|
||||
|
||||
test('a balance widget reads an absent account, an unknown balance and a known balance, and never a key', async () => {
|
||||
const answers = { balance: { status: 'absent' } }
|
||||
const { host, calls } = brokerHost(answers)
|
||||
const account = serviceOf(catalog.deepseekAccountAdapter, host)
|
||||
const client = { version: '0.4.2', locale: 'zh_CN', timezoneOffsetSeconds: 28800 }
|
||||
|
||||
assert.equal(await account.getBalance(client), null)
|
||||
answers.balance = { status: 'unknown' }
|
||||
assert.deepEqual(await account.getBalance(client), { status: 'failed' })
|
||||
answers.balance = { status: 'known', currency: 'CNY', total: '123.45', topped_up: '100.00', granted: '23.45' }
|
||||
const ready = await account.getBalance(client)
|
||||
assert.deepEqual(ready, { status: 'ready', value: [{ currency: 'CNY', balance: '100.00' }], bonusWallets: [{ currency: 'CNY', balance: '23.45' }] })
|
||||
const shown = [...ready.value, ...ready.bonusWallets].reduce((sum, wallet) => sum + Number(wallet.balance), 0)
|
||||
assert.equal(shown.toFixed(2), '123.45')
|
||||
answers.balance = { status: 'known', currency: 'USD', total: '12.50', topped_up: '', granted: '' }
|
||||
assert.deepEqual(await account.getBalance(client), { status: 'ready', value: [{ currency: 'USD', balance: '12.50' }], bonusWallets: [] })
|
||||
|
||||
assert.deepEqual(calls.map((call) => [call.service, call.method, call.input]), Array(4).fill(['catalog', 'balance', {}]))
|
||||
for (const method of ['resolveToken', 'getPlatformSession', 'getState', 'startSignIn', 'signOut', 'rejectToken']) {
|
||||
assert.throws(() => account[method]('https://api.deepseek.com'), (error) => error.code === -32001 && error.message.includes(`deepseekAccount.${method} is not provided`))
|
||||
}
|
||||
assert.equal(calls.length, 4)
|
||||
|
||||
answers.balance = { status: 'known', currency: 'CNY', total: '1', topped_up: '1', granted: '0', api_key: 'sk-raw' }
|
||||
assert.deepEqual(Object.keys(await catalog.accountBalance(host, {})).sort(), ['currency', 'granted', 'status', 'topped_up', 'total'])
|
||||
answers.balance = { status: 'ready', value: [] }
|
||||
await assert.rejects(account.getBalance(client), /invalid catalog broker balance/)
|
||||
answers.balance = brokerRefusal(-32001, 'not_available', 'the catalog broker is not available yet')
|
||||
await assert.rejects(account.getBalance(client), /not available yet/)
|
||||
})
|
||||
|
||||
test('a market plugin asking to install gets the reviewed-install answer, and nothing installs or removes', async () => {
|
||||
const review = {
|
||||
status: 'review_required', package: '@acme/review', version: '1.10.0', plugin: 'review', spec: 'npm:@acme/[email protected]',
|
||||
command: '/plugin install npm:@acme/[email protected]',
|
||||
message: 'Codewhale installs a plugin only through its reviewed install flow; nothing was installed. Review and install @acme/[email protected] with `/plugin install npm:@acme/[email protected]`.',
|
||||
}
|
||||
const answers = { 'install.plan': review }
|
||||
const { host, calls } = brokerHost(answers)
|
||||
const manager = serviceOf(catalog.pluginManagerAdapter, host)
|
||||
|
||||
const requested = await manager.installBundle('@acme/[email protected]', { requestId: 'r1', registry: 'https://registry.example.com' })
|
||||
assert.deepEqual(requested, {
|
||||
changed: false, application: 'failed', stage: 'install', target: '@acme/[email protected]',
|
||||
error: { code: 'management-required', diagnostic: review.message, message: review.message },
|
||||
})
|
||||
assert.deepEqual(calls[0], { service: 'catalog', method: 'install.plan', input: { package: '@acme/review', version: '1.10.0', registry: 'https://registry.example.com' } })
|
||||
await manager.installBundle('[email protected]')
|
||||
assert.deepEqual(calls[1].input, { package: 'dsh-demo', version: '1.2.3' })
|
||||
|
||||
answers['install.plan'] = { status: 'already_present', package: 'dsh-demo', version: '1.2.3', plugin: 'dsh-demo', command: '/plugin trust dsh-demo', message: 'A plugin named `dsh-demo` is already present; nothing was installed.' }
|
||||
assert.equal((await manager.installBundle('[email protected]')).error.code, 'already-installed')
|
||||
|
||||
answers['install.plan'] = brokerRefusal(-32002, 'refused', '`dsh-demo@^1.2.3` is not an exact npm package version Codewhale can fetch')
|
||||
const range = await manager.installBundle('dsh-demo@^1.2.3')
|
||||
assert.equal(range.error.code, 'invalid-spec')
|
||||
assert.match(range.error.diagnostic, /not an exact npm package version/)
|
||||
|
||||
const sent = calls.length
|
||||
for (const spec of ['dsh-demo', '@acme/review', 'git+https://github.com/acme/review.git#0123456789abcdef0123456789abcdef01234567', 'dsh-demo@', 7]) {
|
||||
const refused = await manager.installBundle(spec)
|
||||
assert.equal(refused.error.code, 'invalid-spec', String(spec))
|
||||
assert.equal(refused.application, 'failed')
|
||||
}
|
||||
assert.equal((await manager.installBundle('[email protected]', { registry: 7 })).error.code, 'invalid-spec')
|
||||
assert.deepEqual(await manager.removeBundle('dsh-demo'), {
|
||||
changed: false, application: 'failed', stage: 'remove', target: 'dsh-demo',
|
||||
error: { code: 'management-required', diagnostic: 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall', message: 'Codewhale removes a plugin only from its own plugin manager, after you disable it: /plugin uninstall' },
|
||||
})
|
||||
assert.deepEqual(await manager.cancelInstall('r1'), { status: 'not-running' })
|
||||
assert.equal(calls.length, sent, 'a spec that is not name@exact-version, a removal and a cancel never reach the broker')
|
||||
|
||||
answers['install.plan'] = brokerRefusal(-32002, 'ticket_refused', 'the invocation this dsh/broker:catalog belongs to has ended')
|
||||
await assert.rejects(manager.installBundle('[email protected]'), /has ended/)
|
||||
answers['install.plan'] = { status: 'installed', package: 'dsh-demo', version: '1.2.3', plugin: 'dsh-demo', command: 'x', message: 'x' }
|
||||
await assert.rejects(manager.installBundle('[email protected]'), /invalid catalog broker install plan/)
|
||||
})
|
||||
|
||||
test('a model-proxy directory is built from approved provider routes, and an unapproved route is the refusal', async () => {
|
||||
const gateway = { provider: 'gateway', base_url: 'https://gateway.example/api', default_model: 'tiny-model', models: ['tiny-model', 'large-model'] }
|
||||
const answers = {
|
||||
'routes.list': { routes: [gateway] },
|
||||
'routes.resolve': (input) => input.provider === 'gateway'
|
||||
? { route: gateway, model: input.model ?? gateway.default_model }
|
||||
: brokerRefusal(-32002, 'refused', `\`${input.provider}\` is not a provider route approved for this plugin`),
|
||||
}
|
||||
const { host, calls } = brokerHost(answers)
|
||||
|
||||
const routes = await catalog.approvedRoutes(host, {})
|
||||
const directory = {
|
||||
providers: routes.map((route) => ({ provider: route.provider, active: true })),
|
||||
models: routes.map((route) => ({ provider: route.provider, models: route.models.map((id) => ({ id })) })),
|
||||
}
|
||||
assert.deepEqual(directory, {
|
||||
providers: [{ provider: 'gateway', active: true }],
|
||||
models: [{ provider: 'gateway', models: [{ id: 'tiny-model' }, { id: 'large-model' }] }],
|
||||
})
|
||||
|
||||
assert.deepEqual(await catalog.resolveRoute(host, {}, 'gateway', 'large-model'), { ...gateway, model: 'large-model' })
|
||||
assert.deepEqual(await catalog.resolveRoute(host, {}, 'gateway'), { ...gateway, model: 'tiny-model' })
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, 'openai', 'gpt-x'), /not a provider route approved for this plugin/)
|
||||
assert.deepEqual(calls.map((call) => [call.service, call.method, call.input]), [
|
||||
['catalog', 'routes.list', {}],
|
||||
['catalog', 'routes.resolve', { provider: 'gateway', model: 'large-model' }],
|
||||
['catalog', 'routes.resolve', { provider: 'gateway' }],
|
||||
['catalog', 'routes.resolve', { provider: 'openai', model: 'gpt-x' }],
|
||||
])
|
||||
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, '', 'x'), (error) => error.code === -32602)
|
||||
await assert.rejects(catalog.resolveRoute(host, {}, 'gateway', 7), (error) => error.code === -32602)
|
||||
assert.equal(calls.length, 4)
|
||||
answers['routes.list'] = { routes: [{ provider: 'gateway', base_url: 'https://gateway.example/api', default_model: null, models: [7] }] }
|
||||
await assert.rejects(catalog.approvedRoutes(host, {}), /invalid catalog broker route/)
|
||||
answers['routes.list'] = [gateway]
|
||||
await assert.rejects(catalog.approvedRoutes(host, {}), /invalid catalog broker routes/)
|
||||
})
|
||||
@@ -36,15 +36,20 @@ function brokerHost(answers) {
|
||||
test('credentials resolve to an opaque handle and go only to the network broker', async () => {
|
||||
const { host, calls } = brokerHost({
|
||||
'credentials.resolve': { ref: 'KEY', value: 'codewhale-credential:KEY' },
|
||||
'credentials.describe': { ref: 'KEY', set: true },
|
||||
'credentials.describe': { ref: 'KEY', set: true, site: 'https://api.example.com' },
|
||||
'credentials.set': { ref: 'KEY', set: true },
|
||||
})
|
||||
const Credentials = credentialsAdapter.define(host)
|
||||
const service = Object.create(Credentials.prototype)
|
||||
service.ctx = {}
|
||||
assert.deepEqual(await service.resolve('KEY'), { value: 'codewhale-credential:KEY' })
|
||||
assert.deepEqual(await service.describe('KEY'), { ref: 'KEY', set: true })
|
||||
await service.set('KEY', 'sk-secret')
|
||||
assert.deepEqual(await service.describe('KEY'), { ref: 'KEY', set: true, site: 'https://api.example.com' })
|
||||
await service.set('KEY', 'sk-secret', 'https://API.example.com/')
|
||||
assert.deepEqual(calls[2].input, { ref: 'KEY', value: 'sk-secret', site: 'https://api.example.com' })
|
||||
for (const site of [undefined, 'http://api.example.com', 'https://user:[email protected]', 'https://api.example.com/v1', 'https://api.example.com/?q=1', 'api.example.com']) {
|
||||
await assert.rejects(service.set('KEY', 'sk-secret', site), /one https site/, String(site))
|
||||
}
|
||||
assert.equal(calls.length, 3, 'a site that is not an https origin never reaches the broker')
|
||||
assert.deepEqual(calls.map((c) => [c.service, c.method]), [
|
||||
['network', 'credentials.resolve'], ['network', 'credentials.describe'], ['network', 'credentials.set'],
|
||||
])
|
||||
@@ -83,6 +88,6 @@ test('brokeredFetch sends a typed fetch and rebuilds a Response from the broker
|
||||
})
|
||||
|
||||
test('a broker refusal reaches the plugin as the refusal', async () => {
|
||||
const { host } = brokerHost({ fetch: new Error('egress refused: `evil.example` is not one of this plugin\'s reviewed network hosts') })
|
||||
await assert.rejects(brokeredFetch(host, {}, 'https://evil.example/'), /egress refused/)
|
||||
const { host } = brokerHost({ fetch: new Error('this site is not one of the plugin\'s reviewed network sites') })
|
||||
await assert.rejects(brokeredFetch(host, {}, 'https://evil.example/'), /not one of the plugin's reviewed network sites/)
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
import { activate, startHost } from './harness.mjs'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let remoteAdapter
|
||||
@@ -66,3 +68,33 @@ test('remote refuses malformed arguments locally, before any broker call', async
|
||||
assert.equal(typeof ctx.remote.listen, 'undefined')
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('a plugin may inject `remote` and may never provide it, in the shared scope or an isolated one', async (t) => {
|
||||
const host = await startHost()
|
||||
t.after(() => host.stop())
|
||||
const plugin = (source) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cw-ext-host-remote-'))
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
||||
const entry = join(dir, 'index.mjs')
|
||||
writeFileSync(entry, source)
|
||||
return entry
|
||||
}
|
||||
const fake = `{ status() { return 'fake' }, pair() { return 'fake' }, clients() { return 'fake' }, revoke() { return 'fake' } }`
|
||||
const providers = {
|
||||
'remote-root-provider': `ctx.root.provide('remote', ${fake})`,
|
||||
'remote-own-provider': `ctx.provide('remote', ${fake})`,
|
||||
'remote-isolated-provider': `ctx.isolate('remote').provide('remote', ${fake})`,
|
||||
}
|
||||
for (const [name, body] of Object.entries(providers)) {
|
||||
const { result } = await activate(host, name, plugin(`export const inject = ['tools']\nexport function apply(ctx) { ${body} }\n`))
|
||||
assert.equal(result.status, 'failed', name)
|
||||
assert.match(result.diagnostic, /may not provide core service `remote`/, name)
|
||||
}
|
||||
const user = await activate(host, 'remote-user', plugin(`export const inject = ['remote']
|
||||
export function apply(ctx) {
|
||||
for (const method of ['status', 'pair', 'clients', 'revoke']) if (typeof ctx.remote[method] !== 'function') throw new Error('ctx.remote.' + method + ' is missing')
|
||||
if (ctx.remote.constructor.name !== 'RemoteService') throw new Error('ctx.remote is not the host service')
|
||||
}
|
||||
`))
|
||||
assert.equal(user.result.status, 'ok', user.result.diagnostic)
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { test, before } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { join, dirname } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
let settingsAdapter
|
||||
let SettingsConflictError
|
||||
|
||||
before(async () => {
|
||||
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-settings-'))
|
||||
const outfile = join(dir, 'settings.mjs')
|
||||
await build({ entryPoints: [join(here, '../src/dsh/settings.ts')], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
|
||||
;({ settingsAdapter, SettingsConflictError } = await import(pathToFileURL(outfile).href))
|
||||
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
|
||||
})
|
||||
|
||||
function service(answers, pluginName = 'fixture.plugin') {
|
||||
const calls = []
|
||||
const host = {
|
||||
tier: 'plugin',
|
||||
ownerOf: () => ({ pluginName }),
|
||||
broker: async (ctx, name, method, input) => {
|
||||
calls.push({ name, method, input })
|
||||
const answer = answers[method]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}
|
||||
const instance = Object.create(settingsAdapter.define(host).prototype)
|
||||
instance.ctx = {}
|
||||
return { settings: instance, calls }
|
||||
}
|
||||
|
||||
const row = { ns: 'fixture.plugin', writable: true, roots: ['model'], value: { model: 'small' }, revision: 'r1', applied: true, applies: 'reload' }
|
||||
|
||||
test('settings read and edit go to the settings broker as typed methods', async () => {
|
||||
const { settings, calls } = service({
|
||||
describe: row,
|
||||
update: { written: true, conflict: false, revision: 'r2', applies: 'reload' },
|
||||
replace: { written: true, conflict: false, revision: 'r3', applies: 'reload' },
|
||||
mutate: { written: false, conflict: false, revision: 'r3', applies: 'reload' },
|
||||
})
|
||||
assert.deepEqual(await settings.describe(), [row])
|
||||
assert.deepEqual(await settings.update('fixture.plugin', { model: 'large' }, 'r1'), { written: true, revision: 'r2', applies: 'reload' })
|
||||
await settings.replace('fixture.plugin', { model: 'tiny' })
|
||||
assert.equal((await settings.mutate('fixture.plugin', [{ op: 'unset', path: ['model'] }])).written, false)
|
||||
assert.deepEqual(calls, [
|
||||
{ name: 'settings', method: 'describe', input: {} },
|
||||
{ name: 'settings', method: 'update', input: { patch: { model: 'large' }, expected_revision: 'r1' } },
|
||||
{ name: 'settings', method: 'replace', input: { value: { model: 'tiny' } } },
|
||||
{ name: 'settings', method: 'mutate', input: { ops: [{ op: 'unset', path: ['model'] }] } },
|
||||
])
|
||||
})
|
||||
|
||||
test('another namespace, a malformed edit and a malformed answer never become a broker write', async () => {
|
||||
const { settings, calls } = service({ describe: { ns: 'fixture.plugin' }, update: { written: 'yes' } })
|
||||
await assert.rejects(settings.update('other.plugin', { model: 'x' }), /only its own settings namespace/)
|
||||
await assert.rejects(settings.update('', { model: 'x' }), /non-empty string/)
|
||||
await assert.rejects(settings.update('fixture.plugin', ['model']), /plain object/)
|
||||
await assert.rejects(settings.replace('fixture.plugin', null), /plain object/)
|
||||
await assert.rejects(settings.mutate('fixture.plugin', { op: 'set' }), /array of path operations/)
|
||||
await assert.rejects(settings.update('fixture.plugin', { model: 'x' }, 7), /expected revision/)
|
||||
assert.deepEqual(calls, [])
|
||||
await assert.rejects(settings.describe(), /invalid settings broker response/)
|
||||
await assert.rejects(settings.update('fixture.plugin', { model: 'x' }), /invalid settings broker response/)
|
||||
})
|
||||
|
||||
test('a stale edit is a conflict and a broker refusal reaches the plugin as the refusal', async () => {
|
||||
const stale = service({ update: { written: false, conflict: true, revision: 'r9', applies: 'reload' } })
|
||||
await assert.rejects(stale.settings.update('fixture.plugin', { model: 'x' }, 'r1'), (error) => {
|
||||
assert.ok(error instanceof SettingsConflictError)
|
||||
assert.equal(error.code, 'SETTINGS_CONFLICT')
|
||||
assert.equal(error.expected, 'r1')
|
||||
assert.equal(error.actual, 'r9')
|
||||
return true
|
||||
})
|
||||
const refused = service({ update: new Error('that setting is outside this plugin\'s reviewed settings'), describe: new Error('the settings broker is not available yet') })
|
||||
await assert.rejects(refused.settings.update('fixture.plugin', { internal: 1 }), /outside this plugin's reviewed settings/)
|
||||
await assert.rejects(refused.settings.describe(), /not available yet/)
|
||||
})
|
||||
@@ -22,7 +22,7 @@ use chrono::{
|
||||
DateTime, Datelike, Duration, Local, NaiveDateTime, TimeZone, Timelike, Utc, Weekday,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
use tokio::time::sleep;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use uuid::Uuid;
|
||||
@@ -44,8 +44,43 @@ impl std::fmt::Debug for ThreadEventSink {
|
||||
// v2 pins provider identity. Older runtimes must reject a pinned definition
|
||||
// instead of silently sending its model through their current provider.
|
||||
pub const CURRENT_AUTOMATION_SCHEMA_VERSION: u32 = 3;
|
||||
const DENY_RULES_AUTOMATION_SCHEMA_VERSION: u32 = 4;
|
||||
const MAX_SUPPORTED_AUTOMATION_SCHEMA_VERSION: u32 = DENY_RULES_AUTOMATION_SCHEMA_VERSION;
|
||||
const CURRENT_RUN_SCHEMA_VERSION: u32 = 3;
|
||||
const DENY_RULES_RUN_SCHEMA_VERSION: u32 = 4;
|
||||
const MAX_SUPPORTED_RUN_SCHEMA_VERSION: u32 = DENY_RULES_RUN_SCHEMA_VERSION;
|
||||
const CURRENT_TRIGGER_SCHEMA_VERSION: u32 = 3;
|
||||
const DENY_RULES_TRIGGER_SCHEMA_VERSION: u32 = 4;
|
||||
const MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION: u32 = DENY_RULES_TRIGGER_SCHEMA_VERSION;
|
||||
|
||||
pub const fn required_automation_schema_version(disallowed_tools: &[String]) -> u32 {
|
||||
if disallowed_tools.is_empty() {
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
const fn required_run_schema_version(dispatch: Option<&AutomationDispatch>) -> u32 {
|
||||
match dispatch {
|
||||
Some(dispatch) if !dispatch.request.disallowed_tools.is_empty() => {
|
||||
DENY_RULES_RUN_SCHEMA_VERSION
|
||||
}
|
||||
_ => CURRENT_RUN_SCHEMA_VERSION,
|
||||
}
|
||||
}
|
||||
|
||||
const fn required_trigger_schema_version(record: &DelayedTriggerRecord) -> u32 {
|
||||
let dispatch_denied = match &record.dispatch {
|
||||
Some(dispatch) => !dispatch.request.disallowed_tools.is_empty(),
|
||||
None => false,
|
||||
};
|
||||
if record.disallowed_tools.is_empty() && !dispatch_denied {
|
||||
CURRENT_TRIGGER_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_TRIGGER_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
const DEFAULT_AUTOMATION_MODE: &str = "agent";
|
||||
const DEFAULT_AUTOMATION_ALLOW_SHELL: bool = false;
|
||||
const DEFAULT_AUTOMATION_TRUST_MODE: bool = false;
|
||||
@@ -108,6 +143,8 @@ pub struct DelayedTriggerRecord {
|
||||
pub owner_session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination_thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub status: DelayedTriggerStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -143,6 +180,7 @@ pub struct CreateDelayedTriggerRequest {
|
||||
/// Optional parent trigger id for re-arm lineage tracking.
|
||||
pub parent_trigger_id: Option<String>,
|
||||
pub destination_thread_id: Option<String>,
|
||||
pub disallowed_tools: Vec<String>,
|
||||
}
|
||||
|
||||
// ── End delayed-trigger types ──────────────────────────────────────────────
|
||||
@@ -298,6 +336,8 @@ pub struct AutomationRecord {
|
||||
pub delivery_mode: Option<AutomationDeliveryMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination_thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub status: AutomationStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
@@ -415,6 +455,8 @@ pub struct CreateAutomationRequest {
|
||||
pub delivery_mode: Option<AutomationDeliveryMode>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination_thread_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub status: Option<AutomationStatus>,
|
||||
}
|
||||
@@ -1175,6 +1217,7 @@ pub struct AutomationManager {
|
||||
triggers_dir: PathBuf,
|
||||
archive_dir: PathBuf,
|
||||
missed_dir: PathBuf,
|
||||
trigger_wakeup: Arc<Notify>,
|
||||
}
|
||||
|
||||
impl AutomationManager {
|
||||
@@ -1276,6 +1319,7 @@ impl AutomationManager {
|
||||
triggers_dir,
|
||||
archive_dir,
|
||||
missed_dir,
|
||||
trigger_wakeup: Arc::new(Notify::new()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1361,7 +1405,12 @@ impl AutomationManager {
|
||||
}
|
||||
} else {
|
||||
automation.execution_scope = Some(scope.to_string());
|
||||
automation.schema_version = CURRENT_AUTOMATION_SCHEMA_VERSION;
|
||||
automation.schema_version =
|
||||
automation
|
||||
.schema_version
|
||||
.max(required_automation_schema_version(
|
||||
&automation.disallowed_tools,
|
||||
));
|
||||
automation.updated_at = Utc::now();
|
||||
if automation.status == AutomationStatus::Active {
|
||||
let schedule = AutomationSchedule::parse_rrule(&automation.rrule)?;
|
||||
@@ -1449,8 +1498,9 @@ impl AutomationManager {
|
||||
None
|
||||
};
|
||||
|
||||
let disallowed_tools = crate::task_manager::normalize_deny_rules(req.disallowed_tools);
|
||||
let record = AutomationRecord {
|
||||
schema_version: CURRENT_AUTOMATION_SCHEMA_VERSION,
|
||||
schema_version: required_automation_schema_version(&disallowed_tools),
|
||||
execution_scope: self.execution_scope.clone(),
|
||||
id: Uuid::new_v4().to_string(),
|
||||
name: req.name.trim().to_string(),
|
||||
@@ -1466,6 +1516,7 @@ impl AutomationManager {
|
||||
auto_approve: req.auto_approve,
|
||||
delivery_mode: req.delivery_mode,
|
||||
destination_thread_id: normalize_optional_string(req.destination_thread_id),
|
||||
disallowed_tools,
|
||||
status,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
@@ -1489,16 +1540,24 @@ impl AutomationManager {
|
||||
}
|
||||
|
||||
fn save_automation_unlocked(&self, record: &AutomationRecord) -> Result<()> {
|
||||
if record.model_provider.is_some() || record.model_provider_id.is_some() {
|
||||
if record
|
||||
if (record.model_provider.is_some() || record.model_provider_id.is_some())
|
||||
&& record
|
||||
.model
|
||||
.as_deref()
|
||||
.is_none_or(|model| model.trim().is_empty())
|
||||
{
|
||||
bail!("A pinned automation provider requires an explicit model");
|
||||
}
|
||||
{
|
||||
bail!("A pinned automation provider requires an explicit model");
|
||||
}
|
||||
let min_version = if !record.disallowed_tools.is_empty() {
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
} else if record.model_provider.is_some() || record.model_provider_id.is_some() {
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
} else {
|
||||
record.schema_version
|
||||
};
|
||||
if record.schema_version < min_version {
|
||||
let mut record = record.clone();
|
||||
record.schema_version = record.schema_version.max(CURRENT_AUTOMATION_SCHEMA_VERSION);
|
||||
record.schema_version = min_version;
|
||||
return write_json_atomic(&self.automation_path(&record.id)?, &record);
|
||||
}
|
||||
write_json_atomic(&self.automation_path(&record.id)?, record)
|
||||
@@ -1609,8 +1668,14 @@ impl AutomationManager {
|
||||
if existing.execution_scope.is_some()
|
||||
|| existing.model_provider.is_some()
|
||||
|| existing.model_provider_id.is_some()
|
||||
|| !existing.disallowed_tools.is_empty()
|
||||
{
|
||||
existing.schema_version = CURRENT_AUTOMATION_SCHEMA_VERSION;
|
||||
existing.schema_version =
|
||||
existing
|
||||
.schema_version
|
||||
.max(required_automation_schema_version(
|
||||
&existing.disallowed_tools,
|
||||
));
|
||||
}
|
||||
|
||||
existing.updated_at = Utc::now();
|
||||
@@ -1981,7 +2046,14 @@ impl AutomationManager {
|
||||
let dir = self.runs_dir_for(&run.automation_id)?;
|
||||
fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;
|
||||
let path = self.run_path(run)?;
|
||||
write_json_atomic(&path, run)?;
|
||||
let min_version = required_run_schema_version(run.dispatch.as_ref());
|
||||
if run.schema_version < min_version {
|
||||
let mut updated = run.clone();
|
||||
updated.schema_version = min_version;
|
||||
write_json_atomic(&path, &updated)?;
|
||||
} else {
|
||||
write_json_atomic(&path, run)?;
|
||||
}
|
||||
// Rewrites of a legacy-named run migrate it to the sortable name; drop
|
||||
// the old file so the run never exists twice.
|
||||
let legacy = self.legacy_run_path(&run.automation_id, &run.id)?;
|
||||
@@ -2551,8 +2623,13 @@ impl AutomationManager {
|
||||
if req.message.trim().is_empty() {
|
||||
bail!("Trigger message must not be empty");
|
||||
}
|
||||
let disallowed_tools = crate::task_manager::normalize_deny_rules(req.disallowed_tools);
|
||||
let record = DelayedTriggerRecord {
|
||||
schema_version: CURRENT_TRIGGER_SCHEMA_VERSION,
|
||||
schema_version: if disallowed_tools.is_empty() {
|
||||
CURRENT_TRIGGER_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_TRIGGER_SCHEMA_VERSION
|
||||
},
|
||||
execution_scope: self.execution_scope.clone(),
|
||||
trigger_id: format!("trig_{}", Uuid::new_v4().simple()),
|
||||
fire_at: req.fire_at,
|
||||
@@ -2560,6 +2637,7 @@ impl AutomationManager {
|
||||
workspace: req.workspace,
|
||||
owner_session_id: req.owner_session_id,
|
||||
destination_thread_id: normalize_optional_string(req.destination_thread_id),
|
||||
disallowed_tools,
|
||||
status: DelayedTriggerStatus::Pending,
|
||||
created_at: now,
|
||||
fired_at: None,
|
||||
@@ -2570,6 +2648,7 @@ impl AutomationManager {
|
||||
dispatch: None,
|
||||
};
|
||||
self.save_trigger(&record)?;
|
||||
self.trigger_wakeup.notify_one();
|
||||
self.emit_trigger_event("trigger.scheduled", &record);
|
||||
Ok(record)
|
||||
}
|
||||
@@ -2581,11 +2660,11 @@ impl AutomationManager {
|
||||
.with_context(|| format!("Trigger '{trigger_id}' not found"))?;
|
||||
let record: DelayedTriggerRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse trigger '{trigger_id}'"))?;
|
||||
if record.schema_version > CURRENT_TRIGGER_SCHEMA_VERSION {
|
||||
if record.schema_version > MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Trigger schema v{} is newer than supported v{}",
|
||||
record.schema_version,
|
||||
CURRENT_TRIGGER_SCHEMA_VERSION
|
||||
MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
Ok(record)
|
||||
@@ -2613,6 +2692,12 @@ impl AutomationManager {
|
||||
|
||||
fn save_trigger_unlocked(&self, record: &DelayedTriggerRecord) -> Result<()> {
|
||||
let path = self.trigger_path(&record.trigger_id)?;
|
||||
let min_version = required_trigger_schema_version(record);
|
||||
if record.schema_version < min_version {
|
||||
let mut updated = record.clone();
|
||||
updated.schema_version = min_version;
|
||||
return write_json_atomic(&path, &updated);
|
||||
}
|
||||
write_json_atomic(&path, record)
|
||||
}
|
||||
|
||||
@@ -2639,6 +2724,14 @@ impl AutomationManager {
|
||||
.and_then(|raw| serde_json::from_str::<DelayedTriggerRecord>(&raw).ok())
|
||||
{
|
||||
Some(record) => {
|
||||
if record.schema_version > MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION {
|
||||
tracing::warn!(
|
||||
"Skipping trigger file {} with unsupported schema v{}",
|
||||
path.display(),
|
||||
record.schema_version
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(filter) = status_filter
|
||||
&& record.status != filter
|
||||
{
|
||||
@@ -2706,10 +2799,40 @@ impl AutomationManager {
|
||||
self.save_trigger_unlocked(&record)?;
|
||||
Ok(record)
|
||||
})?;
|
||||
self.trigger_wakeup.notify_one();
|
||||
self.emit_trigger_event("trigger.canceled", &record);
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_wakeup(&self) -> Arc<Notify> {
|
||||
Arc::clone(&self.trigger_wakeup)
|
||||
}
|
||||
|
||||
pub(crate) fn trigger_wait_duration(
|
||||
&self,
|
||||
now: DateTime<Utc>,
|
||||
max_wait: std::time::Duration,
|
||||
) -> Result<std::time::Duration> {
|
||||
let next_fire_at = self
|
||||
.list_triggers(Some(DelayedTriggerStatus::Pending), None)?
|
||||
.into_iter()
|
||||
.filter(|trigger| {
|
||||
trigger.owner_session_id.is_some()
|
||||
&& self.eligible_scope(trigger.execution_scope.as_deref())
|
||||
})
|
||||
.map(|trigger| trigger.fire_at)
|
||||
.min();
|
||||
let wait = match next_fire_at {
|
||||
Some(fire_at) if fire_at <= now => std::time::Duration::ZERO,
|
||||
Some(fire_at) => (fire_at - now)
|
||||
.to_std()
|
||||
.unwrap_or(max_wait)
|
||||
.clamp(std::time::Duration::ZERO, max_wait),
|
||||
None => max_wait,
|
||||
};
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Return due proposals and unfinished durable trigger admissions.
|
||||
pub fn collect_due_triggers(&self, now: DateTime<Utc>) -> Result<Vec<DelayedTriggerRecord>> {
|
||||
Ok(self
|
||||
@@ -2859,6 +2982,7 @@ fn automation_task_request(automation: &AutomationRecord) -> NewTaskRequest {
|
||||
trust_mode: Some(automation.task_trust_mode()),
|
||||
auto_approve: Some(automation.task_auto_approve()),
|
||||
permission_posture: Some(automation_posture(automation.task_auto_approve())),
|
||||
disallowed_tools: automation.disallowed_tools.clone(),
|
||||
owner_session_id: None,
|
||||
destination_thread_id: automation.destination_thread_id.clone(),
|
||||
}
|
||||
@@ -2873,11 +2997,17 @@ fn bind_run_dispatch(
|
||||
if automation.execution_scope.is_none() {
|
||||
bail!("Automation execution ownership is unverified");
|
||||
}
|
||||
run.schema_version = CURRENT_RUN_SCHEMA_VERSION;
|
||||
let request = automation_task_request(automation);
|
||||
let min_version = if request.disallowed_tools.is_empty() {
|
||||
CURRENT_RUN_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_RUN_SCHEMA_VERSION
|
||||
};
|
||||
run.schema_version = run.schema_version.max(min_version);
|
||||
run.task_id = Some(crate::task_manager::TaskManager::new_task_id());
|
||||
run.dispatch = Some(AutomationDispatch {
|
||||
execution_scope: automation.execution_scope.clone(),
|
||||
request: automation_task_request(automation),
|
||||
request,
|
||||
task_data_dir: task_data_dir
|
||||
.canonicalize()
|
||||
.context("resolve task store before automation admission")?,
|
||||
@@ -3285,7 +3415,6 @@ where
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
current.schema_version = CURRENT_TRIGGER_SCHEMA_VERSION;
|
||||
current.status = DelayedTriggerStatus::Dispatching;
|
||||
current.task_id = Some(crate::task_manager::TaskManager::new_task_id());
|
||||
current.dispatch = Some(AutomationDispatch {
|
||||
@@ -3302,6 +3431,7 @@ where
|
||||
trust_mode: Some(false),
|
||||
auto_approve: Some(false),
|
||||
permission_posture: Some(automation_posture(false)),
|
||||
disallowed_tools: current.disallowed_tools.clone(),
|
||||
owner_session_id: current.owner_session_id.clone(),
|
||||
destination_thread_id: current.destination_thread_id.clone(),
|
||||
},
|
||||
@@ -3311,6 +3441,9 @@ where
|
||||
suppress_report: false,
|
||||
schedule: None,
|
||||
});
|
||||
current.schema_version = current
|
||||
.schema_version
|
||||
.max(required_trigger_schema_version(¤t));
|
||||
manager.save_trigger_unlocked(¤t)?;
|
||||
Ok(Some(current))
|
||||
})
|
||||
@@ -3581,7 +3714,9 @@ async fn reconcile_run_statuses_shared(
|
||||
suppress_report: false,
|
||||
schedule: None,
|
||||
});
|
||||
run.schema_version = CURRENT_RUN_SCHEMA_VERSION;
|
||||
run.schema_version = run
|
||||
.schema_version
|
||||
.max(required_run_schema_version(Some(&*dispatch)));
|
||||
dispatch.accepted = true;
|
||||
dispatch.suppress_report = watcher_noop;
|
||||
if let Err(error) = with_manager_blocking(automations, {
|
||||
@@ -3631,11 +3766,11 @@ fn read_automation_file(path: &Path) -> Result<AutomationRecord> {
|
||||
.with_context(|| format!("Failed to read automation {}", path.display()))?;
|
||||
let record: AutomationRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse automation {}", path.display()))?;
|
||||
if record.schema_version > CURRENT_AUTOMATION_SCHEMA_VERSION {
|
||||
if record.schema_version > MAX_SUPPORTED_AUTOMATION_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation schema v{} is newer than supported v{}",
|
||||
record.schema_version,
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
MAX_SUPPORTED_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
Ok(record)
|
||||
@@ -3646,11 +3781,11 @@ fn read_run_file(path: &Path) -> Result<AutomationRunRecord> {
|
||||
fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))?;
|
||||
let run: AutomationRunRecord = serde_json::from_str(&raw)
|
||||
.with_context(|| format!("Failed to parse {}", path.display()))?;
|
||||
if run.schema_version > CURRENT_RUN_SCHEMA_VERSION {
|
||||
if run.schema_version > MAX_SUPPORTED_RUN_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Automation run schema v{} is newer than supported v{}",
|
||||
run.schema_version,
|
||||
CURRENT_RUN_SCHEMA_VERSION
|
||||
MAX_SUPPORTED_RUN_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
Ok(run)
|
||||
@@ -3747,6 +3882,11 @@ pub fn spawn_scheduler(
|
||||
std::panic::Location::caller(),
|
||||
async move {
|
||||
let interval = config.tick_interval_secs.max(5);
|
||||
let max_wait = std::time::Duration::from_secs(interval);
|
||||
let wakeup =
|
||||
with_manager_blocking(&automations, |manager| Ok(manager.trigger_wakeup()))
|
||||
.await
|
||||
.unwrap_or_else(|_| Arc::new(Notify::new()));
|
||||
let grace = Duration::seconds(
|
||||
SCHEDULER_PRESENCE_MIN_GRACE_SECS.max(
|
||||
i64::try_from(interval)
|
||||
@@ -3780,9 +3920,16 @@ pub fn spawn_scheduler(
|
||||
tracing::warn!("delayed trigger tick failed: {err}");
|
||||
}
|
||||
|
||||
let wait_duration = with_manager_blocking(&automations, move |manager| {
|
||||
manager.trigger_wait_duration(Utc::now(), max_wait)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(max_wait);
|
||||
|
||||
tokio::select! {
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = sleep(std::time::Duration::from_secs(interval)) => {}
|
||||
_ = sleep(wait_duration) => {}
|
||||
_ = wakeup.notified() => {}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -4218,6 +4365,7 @@ mod tests {
|
||||
workspace: None,
|
||||
owner_session_id: Some("fixture-owner".into()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
trigger.fire_at = Utc::now() - Duration::minutes(1);
|
||||
trigger.created_at = Utc::now() - Duration::minutes(index);
|
||||
@@ -4636,6 +4784,7 @@ mod tests {
|
||||
next_run_at: None,
|
||||
last_run_at: None,
|
||||
revision: 0,
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5038,6 +5187,7 @@ mod tests {
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.expect("create");
|
||||
assert_eq!(created.model.as_deref(), Some("scheduled-model"));
|
||||
@@ -5084,6 +5234,7 @@ mod tests {
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.expect("create");
|
||||
|
||||
@@ -5610,6 +5761,7 @@ model = "private-model"
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
owned.fire_at = Utc::now() - Duration::minutes(1);
|
||||
manager.save_trigger(&owned)?;
|
||||
@@ -5621,6 +5773,7 @@ model = "private-model"
|
||||
workspace: None,
|
||||
owner_session_id: None,
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
legacy.fire_at = Utc::now() - Duration::minutes(1);
|
||||
manager.save_trigger(&legacy)?;
|
||||
@@ -5664,6 +5817,263 @@ model = "private-model"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denied_automation_and_run_use_v4_schema_while_open_stay_v3() -> Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let manager = AutomationManager::open_for_test(tempdir.path().join("automations"))?;
|
||||
|
||||
let open = manager.create_automation(CreateAutomationRequest {
|
||||
destination_thread_id: None,
|
||||
name: "Open".to_string(),
|
||||
prompt: "prompt".to_string(),
|
||||
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
|
||||
cwds: Vec::new(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
model_provider_id: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: vec![" ".to_string()],
|
||||
})?;
|
||||
assert_eq!(open.schema_version, CURRENT_AUTOMATION_SCHEMA_VERSION);
|
||||
assert!(open.disallowed_tools.is_empty());
|
||||
|
||||
let open_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(manager.automation_path(&open.id)?)?)?;
|
||||
assert_eq!(
|
||||
open_disk["schema_version"],
|
||||
CURRENT_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
assert!(open_disk.get("disallowed_tools").is_none());
|
||||
|
||||
let mut open_run = new_run_record(&open.id, Utc::now(), Utc::now());
|
||||
bind_run_dispatch(&mut open_run, &open, tempdir.path(), true)?;
|
||||
manager.save_run(&open_run)?;
|
||||
let open_run_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(manager.run_path(&open_run)?)?)?;
|
||||
assert_eq!(open_run.schema_version, CURRENT_RUN_SCHEMA_VERSION);
|
||||
assert_eq!(open_run_disk["schema_version"], CURRENT_RUN_SCHEMA_VERSION);
|
||||
|
||||
let restricted = manager.create_automation(CreateAutomationRequest {
|
||||
destination_thread_id: None,
|
||||
name: "Restricted".to_string(),
|
||||
prompt: "prompt".to_string(),
|
||||
rrule: "FREQ=HOURLY;INTERVAL=1".to_string(),
|
||||
cwds: Vec::new(),
|
||||
model: None,
|
||||
model_provider: None,
|
||||
model_provider_id: None,
|
||||
mode: None,
|
||||
allow_shell: None,
|
||||
trust_mode: None,
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: vec![
|
||||
"shell".to_string(),
|
||||
" shell ".to_string(),
|
||||
"mcp_secret_*".to_string(),
|
||||
],
|
||||
})?;
|
||||
assert_eq!(
|
||||
restricted.schema_version,
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(restricted.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
|
||||
let updated = manager.update_automation(
|
||||
&restricted.id,
|
||||
UpdateAutomationRequest {
|
||||
allow_shell: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
assert_eq!(updated.schema_version, DENY_RULES_AUTOMATION_SCHEMA_VERSION);
|
||||
assert_eq!(updated.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
|
||||
let auto_path = manager.automation_path(&restricted.id)?;
|
||||
let mut restricted_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&auto_path)?)?;
|
||||
assert_eq!(
|
||||
restricted_disk["schema_version"],
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(
|
||||
restricted_disk["disallowed_tools"],
|
||||
serde_json::json!(["shell", "mcp_secret_*"])
|
||||
);
|
||||
assert_eq!(
|
||||
manager.get_automation(&restricted.id)?.schema_version,
|
||||
DENY_RULES_AUTOMATION_SCHEMA_VERSION
|
||||
);
|
||||
|
||||
let mut restricted_run = new_run_record(&restricted.id, Utc::now(), Utc::now());
|
||||
bind_run_dispatch(&mut restricted_run, &restricted, tempdir.path(), true)?;
|
||||
assert_eq!(restricted_run.schema_version, DENY_RULES_RUN_SCHEMA_VERSION);
|
||||
manager.save_run(&restricted_run)?;
|
||||
let run_path = manager.run_path(&restricted_run)?;
|
||||
let mut run_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&run_path)?)?;
|
||||
assert_eq!(run_disk["schema_version"], DENY_RULES_RUN_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
run_disk["dispatch"]["request"]["disallowed_tools"],
|
||||
serde_json::json!(["shell", "mcp_secret_*"])
|
||||
);
|
||||
assert_eq!(manager.list_runs(&restricted.id, None)?.len(), 1);
|
||||
|
||||
run_disk["schema_version"] = serde_json::json!(MAX_SUPPORTED_RUN_SCHEMA_VERSION + 1);
|
||||
fs::write(&run_path, serde_json::to_string_pretty(&run_disk)?)?;
|
||||
assert!(
|
||||
manager
|
||||
.list_runs(&restricted.id, None)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("newer than supported")
|
||||
);
|
||||
|
||||
restricted_disk["schema_version"] =
|
||||
serde_json::json!(MAX_SUPPORTED_AUTOMATION_SCHEMA_VERSION + 1);
|
||||
fs::write(&auto_path, serde_json::to_string_pretty(&restricted_disk)?)?;
|
||||
assert!(
|
||||
manager
|
||||
.get_automation(&restricted.id)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("newer than supported")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn denied_delayed_trigger_uses_v4_and_preserves_deny_rules_when_fired() -> Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let task_dir = tempdir.path().join("tasks");
|
||||
let task_manager = TaskManager::start_with_executor(
|
||||
automation_task_config(task_dir.clone()),
|
||||
std::sync::Arc::new(AutomationNoopExecutor),
|
||||
)
|
||||
.await?;
|
||||
let manager = AutomationManager::open_for_test(tempdir.path().join("automations"))?;
|
||||
|
||||
let open = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
fire_at: Utc::now() + Duration::hours(1),
|
||||
message: "open later".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-open".to_string()),
|
||||
parent_trigger_id: None,
|
||||
destination_thread_id: None,
|
||||
disallowed_tools: vec![" ".to_string()],
|
||||
})?;
|
||||
assert_eq!(open.schema_version, CURRENT_TRIGGER_SCHEMA_VERSION);
|
||||
assert!(open.disallowed_tools.is_empty());
|
||||
let open_disk: serde_json::Value = serde_json::from_str(&fs::read_to_string(
|
||||
manager.trigger_path(&open.trigger_id)?,
|
||||
)?)?;
|
||||
assert_eq!(open_disk["schema_version"], CURRENT_TRIGGER_SCHEMA_VERSION);
|
||||
assert!(open_disk.get("disallowed_tools").is_none());
|
||||
|
||||
let mut restricted = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
fire_at: Utc::now() + Duration::hours(1),
|
||||
message: "restricted later".to_string(),
|
||||
workspace: Some(tempdir.path().to_path_buf()),
|
||||
owner_session_id: Some("session-deny".to_string()),
|
||||
parent_trigger_id: None,
|
||||
destination_thread_id: None,
|
||||
disallowed_tools: vec!["shell".to_string(), " mcp_secret_* ".to_string()],
|
||||
})?;
|
||||
assert_eq!(restricted.schema_version, DENY_RULES_TRIGGER_SCHEMA_VERSION);
|
||||
assert_eq!(restricted.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
restricted.fire_at = Utc::now() - Duration::minutes(1);
|
||||
manager.save_trigger(&restricted)?;
|
||||
|
||||
let trigger_path = manager.trigger_path(&restricted.trigger_id)?;
|
||||
let initial_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&trigger_path)?)?;
|
||||
assert_eq!(
|
||||
initial_disk["schema_version"],
|
||||
DENY_RULES_TRIGGER_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(
|
||||
initial_disk["disallowed_tools"],
|
||||
serde_json::json!(["shell", "mcp_secret_*"])
|
||||
);
|
||||
|
||||
let shared = Arc::new(Mutex::new(manager));
|
||||
fire_due_triggers_with(&shared, &task_dir, |claimed| async move {
|
||||
assert_eq!(claimed.status, DelayedTriggerStatus::Dispatching);
|
||||
assert_eq!(claimed.schema_version, DENY_RULES_TRIGGER_SCHEMA_VERSION);
|
||||
assert_eq!(
|
||||
claimed
|
||||
.dispatch
|
||||
.as_ref()
|
||||
.expect("dispatch")
|
||||
.request
|
||||
.disallowed_tools,
|
||||
vec!["shell", "mcp_secret_*"]
|
||||
);
|
||||
anyhow::bail!("pause at dispatching boundary")
|
||||
})
|
||||
.await?;
|
||||
|
||||
let dispatching_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&trigger_path)?)?;
|
||||
assert_eq!(dispatching_disk["status"], "dispatching");
|
||||
assert_eq!(
|
||||
dispatching_disk["schema_version"],
|
||||
DENY_RULES_TRIGGER_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(
|
||||
dispatching_disk["dispatch"]["request"]["disallowed_tools"],
|
||||
serde_json::json!(["shell", "mcp_secret_*"])
|
||||
);
|
||||
|
||||
fire_due_triggers_shared(&shared, &task_manager).await?;
|
||||
|
||||
let manager = shared.lock().await;
|
||||
let fired = manager.get_trigger(&restricted.trigger_id)?;
|
||||
assert_eq!(fired.status, DelayedTriggerStatus::Fired);
|
||||
assert_eq!(fired.schema_version, DENY_RULES_TRIGGER_SCHEMA_VERSION);
|
||||
assert_eq!(fired.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
assert_eq!(
|
||||
fired
|
||||
.dispatch
|
||||
.as_ref()
|
||||
.expect("dispatch")
|
||||
.request
|
||||
.disallowed_tools,
|
||||
vec!["shell", "mcp_secret_*"]
|
||||
);
|
||||
|
||||
let task = task_manager
|
||||
.get_task(fired.task_id.as_deref().expect("fired task id"))
|
||||
.await?;
|
||||
assert_eq!(task.schema_version, 5);
|
||||
assert_eq!(task.disallowed_tools, vec!["shell", "mcp_secret_*"]);
|
||||
|
||||
let mut future_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&trigger_path)?)?;
|
||||
future_disk["schema_version"] = serde_json::json!(MAX_SUPPORTED_TRIGGER_SCHEMA_VERSION + 1);
|
||||
fs::write(&trigger_path, serde_json::to_string_pretty(&future_disk)?)?;
|
||||
assert!(
|
||||
manager
|
||||
.get_trigger(&restricted.trigger_id)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("newer than supported")
|
||||
);
|
||||
let listed = manager.list_triggers(None, None)?;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].trigger_id, open.trigger_id);
|
||||
|
||||
drop(manager);
|
||||
task_manager.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_legacy_run_file(manager: &AutomationManager, run: &AutomationRunRecord) {
|
||||
let dir = manager.runs_dir_for(&run.automation_id).expect("runs dir");
|
||||
fs::create_dir_all(&dir).expect("create runs dir");
|
||||
@@ -5992,6 +6402,7 @@ model = "private-model"
|
||||
auto_approve: None,
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Active),
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.expect("create");
|
||||
let shared: SharedAutomationManager = Arc::new(Mutex::new(manager));
|
||||
@@ -6226,4 +6637,176 @@ model = "private-model"
|
||||
assert_eq!(run.status, AutomationRunStatus::Canceled);
|
||||
assert_eq!(run.error, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trigger_wait_duration_clamps_and_cancel_wakes_notifier() -> Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let mut manager = AutomationManager::open_for_test(tempdir.path().to_path_buf())?;
|
||||
let max_wait = std::time::Duration::from_secs(60);
|
||||
let now = Utc::now();
|
||||
assert_eq!(manager.trigger_wait_duration(now, max_wait)?, max_wait);
|
||||
|
||||
let far = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(120),
|
||||
message: "far".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
assert_eq!(manager.trigger_wait_duration(now, max_wait)?, max_wait);
|
||||
|
||||
let wakeup = manager.trigger_wakeup();
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), wakeup.notified())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let subminute = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(2),
|
||||
message: "subminute".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), wakeup.notified())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
let sub_wait = manager.trigger_wait_duration(now, max_wait)?;
|
||||
assert!(sub_wait <= std::time::Duration::from_secs(2));
|
||||
assert!(sub_wait > std::time::Duration::ZERO);
|
||||
|
||||
let mut past = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(30),
|
||||
message: "past".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), wakeup.notified())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
past.fire_at = now - Duration::seconds(5);
|
||||
manager.save_trigger(&past)?;
|
||||
assert_eq!(
|
||||
manager.trigger_wait_duration(now, max_wait)?,
|
||||
std::time::Duration::ZERO
|
||||
);
|
||||
manager.cancel_trigger(&past.trigger_id)?;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), wakeup.notified())
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
||||
let notified = tokio::spawn({
|
||||
let wakeup = Arc::clone(&wakeup);
|
||||
async move { wakeup.notified().await }
|
||||
});
|
||||
tokio::task::yield_now().await;
|
||||
manager.cancel_trigger(&subminute.trigger_id)?;
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(100), notified)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
assert_eq!(manager.trigger_wait_duration(now, max_wait)?, max_wait);
|
||||
|
||||
let mut ownerless = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(1),
|
||||
message: "ownerless".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: None,
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
ownerless.fire_at = now - Duration::seconds(1);
|
||||
manager.save_trigger(&ownerless)?;
|
||||
manager.execution_scope = Some("other-scope".to_string());
|
||||
let mut foreign = manager.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: now + Duration::seconds(1),
|
||||
message: "foreign".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-b".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
foreign.fire_at = now - Duration::seconds(1);
|
||||
manager.save_trigger(&foreign)?;
|
||||
manager.execution_scope = far.execution_scope.clone();
|
||||
assert_eq!(manager.trigger_wait_duration(now, max_wait)?, max_wait);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn scheduler_wakes_within_one_second_for_subminute_trigger() -> Result<()> {
|
||||
let tempdir = tempfile::tempdir()?;
|
||||
let task_manager = TaskManager::start_with_executor(
|
||||
automation_task_config(tempdir.path().join("tasks")),
|
||||
std::sync::Arc::new(AutomationNoopExecutor),
|
||||
)
|
||||
.await?;
|
||||
let mut manager = AutomationManager::open(tempdir.path().join("automations"))?;
|
||||
manager.bind_task_manager(&task_manager)?;
|
||||
let shared = Arc::new(Mutex::new(manager));
|
||||
let cancel = CancellationToken::new();
|
||||
let handle = spawn_scheduler(
|
||||
Arc::clone(&shared),
|
||||
Arc::clone(&task_manager),
|
||||
cancel.clone(),
|
||||
AutomationSchedulerConfig {
|
||||
tick_interval_secs: 60,
|
||||
},
|
||||
);
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let start = std::time::Instant::now();
|
||||
let trigger = {
|
||||
let guard = shared.lock().await;
|
||||
guard.create_trigger(CreateDelayedTriggerRequest {
|
||||
destination_thread_id: None,
|
||||
fire_at: Utc::now() + Duration::milliseconds(60),
|
||||
message: "sub-second notify wakeup".to_string(),
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-notify".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?
|
||||
};
|
||||
|
||||
let mut fired = None;
|
||||
for _ in 0..95 {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
let current = shared.lock().await.get_trigger(&trigger.trigger_id)?;
|
||||
if current.status == DelayedTriggerStatus::Fired {
|
||||
fired = Some(current);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
cancel.cancel();
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), handle).await;
|
||||
task_manager.shutdown();
|
||||
|
||||
let fired = fired.expect("sub-minute trigger must fire before 1s via Notify");
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_secs(1),
|
||||
"expected wakeup and fire under 1s, took {elapsed:?}"
|
||||
);
|
||||
assert!(fired.task_id.is_some());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ async fn foreign_scoped_automation_and_unbound_trigger_never_dispatch_through_cu
|
||||
workspace: Some(root.path().to_path_buf()),
|
||||
owner_session_id: None,
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
trigger.execution_scope = None;
|
||||
trigger.fire_at = Utc::now() - Duration::seconds(1);
|
||||
|
||||
@@ -2889,6 +2889,17 @@ mod tests {
|
||||
"cat junk | dd of=/dev/sda",
|
||||
"timeout 30 mkfs /dev/sda1",
|
||||
r#"echo \";mkfs.ext4 /dev/sda1"#,
|
||||
r#"mk""fs.ext4 -L \; /dev/sda1"#,
|
||||
r#"shr""ed /dev/sda \;"#,
|
||||
r#"d""d if=/dev/zero of=/dev/sda 2>\;"#,
|
||||
r#"wip""efs -a /dev/sda \"#,
|
||||
r#"m\kfs.ext4 /dev/sda1 \;"#,
|
||||
"mk\"\"fs.ext4 /dev/sda1\necho \"",
|
||||
r#"sh -c $'echo x\nwipefs -a /dev/sda'"#,
|
||||
r#"sh -c $'echo x\nmkfs.ext4 /dev/sda1'"#,
|
||||
r#"sh -c $'echo x\nshred /dev/sda'"#,
|
||||
r#"sh -c $'echo x\ndd if=/dev/zero of=/dev/sda'"#,
|
||||
r#"sh -c $'echo x\rwipefs -a /dev/sda'"#,
|
||||
] {
|
||||
let ctx = ctx_for(
|
||||
"exec_shell",
|
||||
@@ -2900,6 +2911,41 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_review_asks_for_prefixed_writable_and_windows_backslash_commands() {
|
||||
let policy = AutoReviewPolicy::default();
|
||||
for command in [
|
||||
"FOO=1>/tmp/out codewhale --version",
|
||||
"PATH=/tmp/bin codewhale --version",
|
||||
"LD_PRELOAD=/tmp/lib.so codewhale --version",
|
||||
"git stash list --output=/tmp/out",
|
||||
"rg --hostname-bin=/tmp/tool foo",
|
||||
"less -o /tmp/out file",
|
||||
"less -O /tmp/out file",
|
||||
"less --log-file=/tmp/out file",
|
||||
"less --LOG-FILE=/tmp/out file",
|
||||
"ag --pager=\"sh -c id\" foo",
|
||||
"info -o /tmp/out",
|
||||
r"find C:\ -delete",
|
||||
r"rg C:\ --pre=id foo",
|
||||
r"find \\?\ -delete",
|
||||
r"rg \\?\ --pre=id foo",
|
||||
] {
|
||||
let ctx = ctx_for(
|
||||
"exec_shell",
|
||||
json!({ "command": command }),
|
||||
RunOrigin::Interactive,
|
||||
ApprovalMode::Auto,
|
||||
);
|
||||
let decision = policy.evaluate(&ctx);
|
||||
assert_eq!(
|
||||
decision.action,
|
||||
AutoReviewAction::AskUser,
|
||||
"{command:?} should require review in Auto mode: {decision:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn destroyer_held(workspace: &std::path::Path, command: &str) -> bool {
|
||||
let ctx = AutoReviewContext::from_tool_call(
|
||||
"exec_shell",
|
||||
|
||||
@@ -314,8 +314,8 @@ pub struct EngineConfig {
|
||||
pub session_id: Option<String>,
|
||||
/// Optional host-owned root for delegated-agent runtime state.
|
||||
///
|
||||
/// When unset, the worker ledger, complete transcript artifacts and
|
||||
/// coordination lock retain their historical location under
|
||||
/// When unset, the worker ledger and complete transcript artifacts
|
||||
/// retain their historical location under
|
||||
/// `workspace/.codewhale/state`. Embedders may set a session-scoped root
|
||||
/// to separate that control-plane state from the execution workspace.
|
||||
/// Child cwd and file authority still derive from `workspace`; hosts using
|
||||
|
||||
@@ -2640,6 +2640,8 @@ mod tests {
|
||||
enum Posture {
|
||||
Ask,
|
||||
FullAccess,
|
||||
AutoReview,
|
||||
Plan,
|
||||
}
|
||||
|
||||
struct ExtensionTurn {
|
||||
@@ -2655,18 +2657,44 @@ mod tests {
|
||||
/// A turn whose model calls the extension tool (id `ext-1`) with `input`,
|
||||
/// over the file, shell and web tools.
|
||||
fn start_extension_turn(input: Value, posture: Posture) -> ExtensionTurn {
|
||||
start_extension_turn_with(input, posture, Vec::new())
|
||||
}
|
||||
|
||||
fn start_extension_turn_with(
|
||||
input: Value,
|
||||
posture: Posture,
|
||||
reviewer: Vec<crate::llm_client::mock::CannedTurn>,
|
||||
) -> ExtensionTurn {
|
||||
start_extension_turn_hooked(input, posture, reviewer, None)
|
||||
}
|
||||
|
||||
fn start_extension_turn_hooked(
|
||||
input: Value,
|
||||
posture: Posture,
|
||||
reviewer: Vec<crate::llm_client::mock::CannedTurn>,
|
||||
hook_executor: Option<Arc<crate::hooks::HookExecutor>>,
|
||||
) -> ExtensionTurn {
|
||||
let tmp = tempfile::tempdir().expect("fixture directory");
|
||||
std::fs::write(tmp.path().join("a.txt"), "alpha").expect("fixture file");
|
||||
let mock = Arc::new(MockLlmClient::new(vec![
|
||||
canned::tool_call_turn("ext-1", FAKE_EXT, &input.to_string()),
|
||||
canned::simple_text_turn("Extension finished."),
|
||||
]));
|
||||
let mock = Arc::new(MockLlmClient::new(
|
||||
std::iter::once(canned::tool_call_turn(
|
||||
"ext-1",
|
||||
FAKE_EXT,
|
||||
&input.to_string(),
|
||||
))
|
||||
.chain(reviewer)
|
||||
.chain(std::iter::once(canned::simple_text_turn(
|
||||
"Extension finished.",
|
||||
)))
|
||||
.collect(),
|
||||
));
|
||||
let (mut engine, handle) = Engine::new_with_model_client(
|
||||
EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
snapshots_enabled: false,
|
||||
subagents_enabled: false,
|
||||
terminal_chrome_enabled: false,
|
||||
hook_executor,
|
||||
..EngineConfig::default()
|
||||
},
|
||||
&Config::default(),
|
||||
@@ -2674,10 +2702,10 @@ mod tests {
|
||||
);
|
||||
let full = posture == Posture::FullAccess;
|
||||
engine.session.auto_approve = full;
|
||||
engine.session.approval_mode = if full {
|
||||
ApprovalMode::Bypass
|
||||
} else {
|
||||
ApprovalMode::Suggest
|
||||
engine.session.approval_mode = match posture {
|
||||
Posture::FullAccess => ApprovalMode::Bypass,
|
||||
Posture::AutoReview => ApprovalMode::Auto,
|
||||
Posture::Ask | Posture::Plan => ApprovalMode::Suggest,
|
||||
};
|
||||
engine.session.mcp_config_path = tmp.path().join("mcp.json");
|
||||
engine.session.add_message(Message {
|
||||
@@ -2701,11 +2729,18 @@ mod tests {
|
||||
registry.register(Arc::new(FakeExtensionTool {
|
||||
withdraw: withdraw.clone(),
|
||||
}));
|
||||
for gate in crate::extension_host::dsh_broker::BrokerActTool::all() {
|
||||
registry.register(gate);
|
||||
}
|
||||
let catalog = registry.to_api_tools_with_cache(true);
|
||||
let surface = ToolSurfacePolicy::new(
|
||||
registry,
|
||||
Some(catalog),
|
||||
AppMode::Agent,
|
||||
if posture == Posture::Plan {
|
||||
AppMode::Plan
|
||||
} else {
|
||||
AppMode::Agent
|
||||
},
|
||||
&engine.config.tools_always_load,
|
||||
&[],
|
||||
false,
|
||||
@@ -3139,4 +3174,180 @@ mod tests {
|
||||
vec![ApprovalOutcome::Cancelled]
|
||||
);
|
||||
}
|
||||
|
||||
fn broker_act(effect: &str) -> Value {
|
||||
json!({
|
||||
"plugin": "fake",
|
||||
"service": "remote",
|
||||
"method": "pair",
|
||||
"effect": effect,
|
||||
})
|
||||
}
|
||||
|
||||
fn broker_calls(acts: &[&Value]) -> Value {
|
||||
let calls: Vec<Value> = acts
|
||||
.iter()
|
||||
.map(|act| {
|
||||
let name = if act["effect"] == "reach" {
|
||||
crate::extension_host::dsh_broker::REACH_TOOL
|
||||
} else {
|
||||
crate::extension_host::dsh_broker::ACT_TOOL
|
||||
};
|
||||
json!({"name": name, "input": act})
|
||||
})
|
||||
.collect();
|
||||
json!({ "calls": calls })
|
||||
}
|
||||
|
||||
fn approvals(seen: &[Event]) -> usize {
|
||||
seen.iter()
|
||||
.filter(|event| matches!(event, Event::ApprovalRequired { .. }))
|
||||
.count()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_broker_act_asks_in_ask_and_proceeds_in_full_access() {
|
||||
let act = broker_act("reach");
|
||||
let _open = crate::extension_host::dsh_broker::OpenAct::open(&act, 0).expect("open act");
|
||||
|
||||
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::Ask);
|
||||
let events = turn.events.clone();
|
||||
let mut seen = Vec::new();
|
||||
let event = next_approval_event(&events, &mut seen).await;
|
||||
let (id, tool, description, _, _, force) = approval_fields(&event);
|
||||
assert_eq!(tool, crate::extension_host::dsh_broker::REACH_TOOL);
|
||||
assert!(!force, "the mode decides; no extension force-prompt");
|
||||
assert!(
|
||||
description.contains("plugin `fake`") && description.contains("remote"),
|
||||
"{description}"
|
||||
);
|
||||
turn.handle.deny_tool_call(id).await.expect("deny");
|
||||
let answer = finish_extension_turn(&mut turn, &mut seen).await;
|
||||
assert_eq!(answer["results"][0]["rejected"], "Denied", "{answer}");
|
||||
|
||||
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::FullAccess);
|
||||
let mut seen = Vec::new();
|
||||
let answer = finish_extension_turn(&mut turn, &mut seen).await;
|
||||
assert_eq!(answer["results"][0]["ok"], true, "{answer}");
|
||||
assert_eq!(approvals(&seen), 0, "Full Access opens no card");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_review_reviewer_no_refuses_a_broker_act() {
|
||||
let act = broker_act("write");
|
||||
let _open = crate::extension_host::dsh_broker::OpenAct::open(&act, 0).expect("open act");
|
||||
let mut turn = start_extension_turn_with(
|
||||
broker_calls(&[&act]),
|
||||
Posture::AutoReview,
|
||||
vec![
|
||||
canned::simple_text_turn(
|
||||
r#"{"risk_level":"low","decision":"allow","reason":"the person asked to run the extension"}"#,
|
||||
),
|
||||
canned::simple_text_turn(
|
||||
r#"{"risk_level":"high","decision":"deny","reason":"not what the person asked for"}"#,
|
||||
),
|
||||
],
|
||||
);
|
||||
let mut seen = Vec::new();
|
||||
let answer = finish_extension_turn(&mut turn, &mut seen).await;
|
||||
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
|
||||
assert_eq!(approvals(&seen), 0, "the reviewer, not a person, decided");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plan_refuses_a_broker_act_that_writes_or_reaches_out() {
|
||||
let reach = broker_act("reach");
|
||||
let write = broker_act("write");
|
||||
let read = broker_act("read");
|
||||
let _open: Vec<_> = [&reach, &write, &read]
|
||||
.into_iter()
|
||||
.map(|act| crate::extension_host::dsh_broker::OpenAct::open(act, 0).expect("open act"))
|
||||
.collect();
|
||||
let mut turn = start_extension_turn(broker_calls(&[&reach, &write, &read]), Posture::Plan);
|
||||
let mut seen = Vec::new();
|
||||
let answer = finish_extension_turn(&mut turn, &mut seen).await;
|
||||
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
|
||||
assert_eq!(answer["results"][1]["rejected"], "Refused", "{answer}");
|
||||
assert_eq!(answer["results"][2]["ok"], true, "{answer}");
|
||||
assert_eq!(approvals(&seen), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_broker_act_nobody_opened_is_refused_before_any_card() {
|
||||
let act =
|
||||
json!({"plugin": "fake", "service": "network", "method": "fetch", "effect": "reach"});
|
||||
let mut turn = start_extension_turn(broker_calls(&[&act]), Posture::Ask);
|
||||
let mut seen = Vec::new();
|
||||
let answer = finish_extension_turn(&mut turn, &mut seen).await;
|
||||
assert_eq!(answer["results"][0]["rejected"], "Refused", "{answer}");
|
||||
assert_eq!(approvals(&seen), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn a_hook_rewrite_cannot_make_the_gate_answer_for_another_act() {
|
||||
use crate::extension_host::dsh_broker::{OpenAct, REACH_TOOL, answered_for};
|
||||
let act = |target: &str| {
|
||||
json!({
|
||||
"plugin": "fake",
|
||||
"service": "network",
|
||||
"method": "fetch",
|
||||
"effect": "reach",
|
||||
"target": target,
|
||||
})
|
||||
};
|
||||
let shown = act("api.github.com");
|
||||
let asked = act("evil.example");
|
||||
let hooks = tempfile::tempdir().expect("hook directory");
|
||||
let hook = codewhale_config::hooks::Hook::new(
|
||||
codewhale_config::hooks::HookEvent::ToolCallBefore,
|
||||
&format!("printf '%s' '{}'", json!({ "updatedInput": shown })),
|
||||
)
|
||||
.with_condition(codewhale_config::hooks::HookCondition::ToolName {
|
||||
name: REACH_TOOL.to_string(),
|
||||
});
|
||||
let executor = Arc::new(crate::hooks::HookExecutor::new(
|
||||
codewhale_config::hooks::HooksConfig {
|
||||
enabled: true,
|
||||
hooks: vec![hook],
|
||||
..codewhale_config::hooks::HooksConfig::default()
|
||||
},
|
||||
hooks.path().to_path_buf(),
|
||||
));
|
||||
|
||||
let _asked_open = OpenAct::open(&asked, 0).expect("open the asked act");
|
||||
let mut turn = start_extension_turn_hooked(
|
||||
broker_calls(&[&asked]),
|
||||
Posture::FullAccess,
|
||||
Vec::new(),
|
||||
Some(executor.clone()),
|
||||
);
|
||||
let mut seen = Vec::new();
|
||||
let control = finish_extension_turn(&mut turn, &mut seen).await;
|
||||
assert_eq!(control["results"][0]["rejected"], "Refused", "{control}");
|
||||
|
||||
let _shown_open = OpenAct::open(&shown, 1).expect("open the shown act");
|
||||
let mut turn = start_extension_turn_hooked(
|
||||
broker_calls(&[&asked]),
|
||||
Posture::Ask,
|
||||
Vec::new(),
|
||||
Some(executor),
|
||||
);
|
||||
let events = turn.events.clone();
|
||||
let mut seen = Vec::new();
|
||||
let event = next_approval_event(&events, &mut seen).await;
|
||||
let (id, _tool, description, _key, _, _force) = approval_fields(&event);
|
||||
assert!(description.contains("api.github.com"), "{description}");
|
||||
turn.handle.approve_tool_call(id).await.expect("approve");
|
||||
let answer = finish_extension_turn(&mut turn, &mut seen).await;
|
||||
let result = &answer["results"][0]["result"];
|
||||
assert!(
|
||||
answered_for(&shown, result),
|
||||
"the gate names the act it decided: {answer}"
|
||||
);
|
||||
assert!(
|
||||
!answered_for(&asked, result),
|
||||
"the yes is not for the act that asked: {answer}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -710,7 +710,7 @@ fn shell_denial_filters_search_catalog_without_expanding_allow_grants() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_preserves_task_reads_and_bounded_verification_actions() {
|
||||
fn shell_denial_preserves_task_reads_and_denies_verification_actions() {
|
||||
let mut tasks = catalog_tool("tasks");
|
||||
tasks.input_schema =
|
||||
json!({"type":"object", "properties":{"action":{"enum":["list", "read", "gate_run"]}}});
|
||||
@@ -737,21 +737,293 @@ fn shell_denial_preserves_task_reads_and_bounded_verification_actions() {
|
||||
assert!(surface.denies_call("tasks", &json!({"action":"gate_run"})));
|
||||
assert!(!surface.denies_call("task_shell_wait", &json!({})));
|
||||
assert!(!surface.denies_call("terminal/cancel", &json!({})));
|
||||
assert!(!surface.denies_call(
|
||||
"Run",
|
||||
&json!({"action":"tests", "args":"-p fixture selected_test"})
|
||||
));
|
||||
assert!(!surface.denies_call("Run", &json!({"action":"verifiers", "commands":[]})));
|
||||
assert!(surface.denies_call(
|
||||
"Run",
|
||||
&json!({"action":"tests", "args":"--config build.rustc=malicious"})
|
||||
));
|
||||
for input in [
|
||||
json!({"action":"tests"}),
|
||||
json!({"action":"tests", "args":""}),
|
||||
json!({"action":"tests", "args":"-p fixture selected_test"}),
|
||||
json!({"action":"tests", "args":"--config build.rustc=malicious"}),
|
||||
json!({"action":"verifiers"}),
|
||||
json!({"action":"verifiers", "profile":"node"}),
|
||||
json!({"action":"verifiers", "commands":[]}),
|
||||
json!({"action":"verifiers", "commands":[{"program":"sh"}]}),
|
||||
] {
|
||||
assert!(surface.denies_call("Run", &input), "{input}");
|
||||
}
|
||||
for name in ["run_tests", "run_verifiers"] {
|
||||
assert!(surface.denies_call(name, &json!({})), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_covers_every_code_executing_action_or_names_an_exemption() {
|
||||
use crate::tools::execution_envelope::{CallClass, classify_call, tool_call_denied};
|
||||
use crate::tools::spec::ToolCapability;
|
||||
use codewhale_agent_contract::worker_profile::ShellPolicy;
|
||||
|
||||
const EXEMPT: &[(&str, &str)] = &[
|
||||
("automation/pause", "stops a stored automation from firing"),
|
||||
("automation/delete", "removes a stored automation"),
|
||||
("send_later/cancel", "cancels a pending delayed message"),
|
||||
("tasks/cancel", "stops a durable task"),
|
||||
("tasks/suggest", "offers a task card and starts nothing"),
|
||||
("tasks/pr_attempt_record", "records a stored patch artifact"),
|
||||
(
|
||||
"tasks/pr_attempt_preflight",
|
||||
"git apply --check of a stored patch",
|
||||
),
|
||||
("terminal/cancel", "stops an existing terminal session"),
|
||||
("terminal/wait", "reads an existing terminal session"),
|
||||
(
|
||||
"rlm/open",
|
||||
"loads content into a context; running code in it is rlm/eval, and a url is gated by fetch_url",
|
||||
),
|
||||
(
|
||||
"workflow/start",
|
||||
"runs sub-agents that inherit this deny list; the verify input is denied by the flag list below",
|
||||
),
|
||||
(
|
||||
"workflow/run",
|
||||
"runs sub-agents that inherit this deny list; the verify input is denied by the flag list below",
|
||||
),
|
||||
(
|
||||
"workflow/cancel",
|
||||
"stops a workflow run and starts nothing",
|
||||
),
|
||||
];
|
||||
const EXECUTING_FLAGS: &[(&str, &str)] = &[("workflow", "verify")];
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let mut options = crate::tools::AgentToolSurfaceOptions::new(ShellPolicy::Full);
|
||||
options.apply_patch_enabled = true;
|
||||
options.web_search_enabled = true;
|
||||
options.memory_tool_enabled = true;
|
||||
let pool = std::sync::Arc::new(tokio::sync::Mutex::new(crate::mcp::McpPool::new(
|
||||
codewhale_mcp::config::McpConfig::default(),
|
||||
)));
|
||||
let client = CodewhaleClient::new(
|
||||
&Config::default().with_legacy_root(Some("test-key".to_string()), None),
|
||||
)
|
||||
.expect("stub client");
|
||||
let manager = crate::tools::subagent::new_shared_subagent_manager(tmp.path().to_path_buf(), 4);
|
||||
let runtime = SubAgentRuntime::new(
|
||||
client.clone(),
|
||||
"sweep-model".to_string(),
|
||||
crate::tools::ToolContext::new(tmp.path().to_path_buf()),
|
||||
true,
|
||||
None,
|
||||
manager.clone(),
|
||||
);
|
||||
let registry = crate::tools::ToolRegistryBuilder::new()
|
||||
.with_full_agent_surface_options(
|
||||
Some(client),
|
||||
"sweep-model".to_string(),
|
||||
manager,
|
||||
runtime,
|
||||
options,
|
||||
crate::tools::todo::new_shared_todo_list(),
|
||||
crate::tools::plan::new_shared_plan_state(),
|
||||
)
|
||||
.with_registry_mcp_sync_tool()
|
||||
.with_runtime_mcp_tool(pool.clone())
|
||||
.with_registry_mcp_start_tool(pool)
|
||||
.build(crate::tools::ToolContext::new(tmp.path().to_path_buf()));
|
||||
|
||||
let rules = vec!["Bash".to_string()];
|
||||
for name in ["workflow", "agent", "Run"] {
|
||||
assert!(
|
||||
registry.all().iter().any(|tool| tool.name() == name),
|
||||
"the sweep surface lacks {name}"
|
||||
);
|
||||
}
|
||||
for (name, flag) in EXECUTING_FLAGS {
|
||||
let tool = registry
|
||||
.all()
|
||||
.into_iter()
|
||||
.find(|tool| tool.name() == *name)
|
||||
.unwrap_or_else(|| panic!("{name} is not registered"));
|
||||
assert_eq!(
|
||||
tool.input_schema()
|
||||
.pointer(&format!("/properties/{flag}/type"))
|
||||
.and_then(Value::as_str),
|
||||
Some("boolean"),
|
||||
"{name} no longer declares a boolean {flag}"
|
||||
);
|
||||
assert!(
|
||||
tool_call_denied(Some(&rules), name, &json!({"action":"run", (*flag): true})),
|
||||
"{name} with {flag} starts workspace code and a Bash rule does not deny it"
|
||||
);
|
||||
}
|
||||
let mut swept = 0usize;
|
||||
let mut unaccounted = Vec::new();
|
||||
let mut stale = Vec::new();
|
||||
for tool in registry.all() {
|
||||
let schema = tool.input_schema();
|
||||
let actions: Vec<Option<String>> = schema
|
||||
.pointer("/properties/action/enum")
|
||||
.and_then(Value::as_array)
|
||||
.map(|actions| {
|
||||
actions
|
||||
.iter()
|
||||
.filter_map(|action| action.as_str().map(|action| Some(action.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_else(|| vec![None]);
|
||||
for action in actions {
|
||||
let input = action
|
||||
.as_ref()
|
||||
.map_or_else(|| json!({}), |action| json!({"action": action}));
|
||||
let label = match &action {
|
||||
Some(action) => format!("{}/{action}", tool.name()),
|
||||
None => tool.name().to_string(),
|
||||
};
|
||||
let denied = tool_call_denied(Some(&rules), tool.name(), &input);
|
||||
let executes = tool.capabilities().contains(&ToolCapability::ExecutesCode)
|
||||
&& classify_call(tool.name(), &input, &*tool) != CallClass::Bounded;
|
||||
let exempt = EXEMPT.iter().any(|(name, _)| *name == label);
|
||||
if executes {
|
||||
swept += 1;
|
||||
}
|
||||
if executes && !denied && !exempt {
|
||||
unaccounted.push(label.clone());
|
||||
}
|
||||
if exempt && denied {
|
||||
stale.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(swept > 10, "the sweep found only {swept} executing actions");
|
||||
assert!(
|
||||
unaccounted.is_empty(),
|
||||
"executing actions neither denied by a Bash rule nor exempt: {unaccounted:?}"
|
||||
);
|
||||
assert!(
|
||||
stale.is_empty(),
|
||||
"exempt actions a Bash rule already denies: {stale:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_keeps_only_the_bounded_verifier_its_default_gate() {
|
||||
use crate::tools::execution_envelope::{enforce_tool_denial, holds_bounded_verification};
|
||||
use crate::tools::spec::{
|
||||
ToolAuthorityEnvelope, ToolMutationAuthority, ToolShellAuthority,
|
||||
ToolVerificationAuthority,
|
||||
};
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let rules: Vec<String> = codewhale_agent_contract::role::RAW_SHELL_DENYLIST
|
||||
.iter()
|
||||
.map(|rule| (*rule).to_string())
|
||||
.collect();
|
||||
|
||||
let mut operator = crate::tools::ToolContext::new(tmp.path().to_path_buf());
|
||||
operator.disallowed_tools = rules.clone();
|
||||
assert!(!holds_bounded_verification(&operator));
|
||||
|
||||
let mut verifier = crate::tools::ToolContext::new(tmp.path().to_path_buf())
|
||||
.with_tool_authority(ToolAuthorityEnvelope {
|
||||
schema_version: 1,
|
||||
owner: "verifier-1".to_string(),
|
||||
authority: ToolMutationAuthority::ReadOnly,
|
||||
network_access: Some(false),
|
||||
shell: ToolShellAuthority::None,
|
||||
verification: ToolVerificationAuthority::Bounded,
|
||||
writable_roots: Vec::new(),
|
||||
writable_files: Vec::new(),
|
||||
coordination_contracts: Vec::new(),
|
||||
})
|
||||
.expect("bounded verifier authority");
|
||||
verifier.disallowed_tools = rules;
|
||||
assert!(holds_bounded_verification(&verifier));
|
||||
|
||||
for input in [
|
||||
json!({"action":"tests"}),
|
||||
json!({"action":"tests", "args":"-p fixture selected_test"}),
|
||||
json!({"action":"verifiers"}),
|
||||
] {
|
||||
assert!(enforce_tool_denial(&operator, "Run", &input).is_err(), "{input}");
|
||||
assert!(enforce_tool_denial(&verifier, "Run", &input).is_ok(), "{input}");
|
||||
}
|
||||
for input in [
|
||||
json!({"action":"tests", "args":"--config build.rustc=malicious"}),
|
||||
json!({"action":"verifiers", "commands":[{"program":"sh"}]}),
|
||||
] {
|
||||
assert!(enforce_tool_denial(&operator, "Run", &input).is_err(), "{input}");
|
||||
assert!(enforce_tool_denial(&verifier, "Run", &input).is_err(), "{input}");
|
||||
}
|
||||
let workflow_verify = json!({"action":"run", "script":"return 1;", "verify":true});
|
||||
assert!(enforce_tool_denial(&operator, "workflow", &workflow_verify).is_err());
|
||||
assert!(enforce_tool_denial(&verifier, "workflow", &workflow_verify).is_ok());
|
||||
|
||||
let surface = ToolSurfacePolicy::new(
|
||||
crate::tools::ToolRegistry::new(verifier.clone()),
|
||||
Some(vec![catalog_tool("Run")]),
|
||||
AppMode::Agent,
|
||||
&HashSet::new(),
|
||||
&[],
|
||||
false,
|
||||
None,
|
||||
verifier.disallowed_tools.clone().into(),
|
||||
None,
|
||||
crate::core::engine::tool_catalog::ToolMode::Direct,
|
||||
);
|
||||
assert!(!surface.denies_call("Run", &json!({"action":"verifiers"})));
|
||||
assert!(surface.denies_call(
|
||||
"Run",
|
||||
&json!({"action":"verifiers", "commands":[{"program":"sh"}]})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_covers_workflow_completion_verification_but_not_plain_runs() {
|
||||
use crate::tools::execution_envelope::tool_call_denied;
|
||||
let rules = vec!["Bash".to_string()];
|
||||
for input in [
|
||||
json!({"verify":true}),
|
||||
json!({"action":"start", "verify":true}),
|
||||
json!({"action":"spawn", "verify":true, "script":"return 1;"}),
|
||||
json!({"action":"run", "verify":true, "script":"return 1;"}),
|
||||
json!({"action":"wait", "verify":true}),
|
||||
json!({"action":" RUN ", "verify":true}),
|
||||
] {
|
||||
assert!(tool_call_denied(Some(&rules), "workflow", &input), "{input}");
|
||||
}
|
||||
for input in [
|
||||
json!({"action":"run", "script":"return 1;"}),
|
||||
json!({"action":"run", "verify":false}),
|
||||
json!({"action":"status", "run_id":"workflow_1"}),
|
||||
json!({"action":"cancel", "run_id":"workflow_1"}),
|
||||
] {
|
||||
assert!(!tool_call_denied(Some(&rules), "workflow", &input), "{input}");
|
||||
}
|
||||
assert!(!tool_call_denied(
|
||||
None,
|
||||
"workflow",
|
||||
&json!({"action":"run", "verify":true})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_covers_delayed_message_scheduling_but_not_its_management() {
|
||||
use crate::tools::execution_envelope::tool_call_denied;
|
||||
let rules = vec!["Bash".to_string()];
|
||||
assert!(tool_call_denied(
|
||||
Some(&rules),
|
||||
"send_later",
|
||||
&json!({"action":"schedule", "delay_minutes":5, "message":"go", "thread_id":""})
|
||||
));
|
||||
for action in ["list", "read", "cancel"] {
|
||||
assert!(
|
||||
!tool_call_denied(Some(&rules), "send_later", &json!({"action": action})),
|
||||
"{action}"
|
||||
);
|
||||
}
|
||||
assert!(!tool_call_denied(
|
||||
None,
|
||||
"send_later",
|
||||
&json!({"action":"schedule"})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_denial_applies_to_new_durable_execution_without_hiding_management() {
|
||||
use crate::tools::execution_envelope::tool_call_denied;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! tool suggestions, and the small set of built-in advanced tools that are not
|
||||
//! registered by the normal runtime tool registry.
|
||||
|
||||
use crate::tools::execution_envelope::tool_call_denied;
|
||||
use crate::tools::execution_envelope::{holds_bounded_verification, tool_call_denied_for};
|
||||
use codewhale_tools::names::{
|
||||
LEGACY_TOOL_SEARCH_BM25_NAME, LEGACY_TOOL_SEARCH_REGEX_NAME, TOOL_SEARCH_NAME,
|
||||
is_tool_search_tool,
|
||||
@@ -625,6 +625,7 @@ pub(super) struct ToolSurfacePolicy {
|
||||
pub(super) strict_tool_mode: bool,
|
||||
allowed_tools: Option<Vec<String>>,
|
||||
disallowed_tools: Option<Vec<String>>,
|
||||
holds_bounded_verification: bool,
|
||||
/// Hard per-turn cap on admitted tool calls (#4415). The turn loop copies
|
||||
/// this limit into its own admission counter at turn start; `None` means
|
||||
/// unlimited (the default), which keeps the admission gate inert.
|
||||
@@ -645,6 +646,7 @@ impl ToolSurfacePolicy {
|
||||
max_tool_calls: Option<u32>,
|
||||
tool_mode: ToolMode,
|
||||
) -> Self {
|
||||
let holds_bounded_verification = holds_bounded_verification(registry.context());
|
||||
let mut catalog = tools.unwrap_or_default();
|
||||
if !catalog.is_empty() {
|
||||
ensure_advanced_tooling(&mut catalog, mode, always_load, tool_mode);
|
||||
@@ -669,10 +671,11 @@ impl ToolSurfacePolicy {
|
||||
.and_then(Value::as_array_mut)
|
||||
{
|
||||
actions.retain(|action| {
|
||||
!tool_call_denied(
|
||||
!tool_call_denied_for(
|
||||
disallowed_tools.as_deref(),
|
||||
&tool.name,
|
||||
&json!({"action": action}),
|
||||
holds_bounded_verification,
|
||||
)
|
||||
});
|
||||
}
|
||||
@@ -697,6 +700,7 @@ impl ToolSurfacePolicy {
|
||||
strict_tool_mode,
|
||||
allowed_tools,
|
||||
disallowed_tools,
|
||||
holds_bounded_verification,
|
||||
max_tool_calls,
|
||||
}
|
||||
}
|
||||
@@ -738,7 +742,12 @@ impl ToolSurfacePolicy {
|
||||
}
|
||||
|
||||
pub(super) fn denies_call(&self, name: &str, input: &Value) -> bool {
|
||||
tool_call_denied(self.disallowed_tools.as_deref(), name, input)
|
||||
tool_call_denied_for(
|
||||
self.disallowed_tools.as_deref(),
|
||||
name,
|
||||
input,
|
||||
self.holds_bounded_verification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -110,6 +110,7 @@ impl Engine {
|
||||
// the ToolContext carries ShellPolicy::None.
|
||||
.with_file_tools()
|
||||
.with_present_tool()
|
||||
.with_show_data_tool()
|
||||
// Foreground-only shell registration: never add terminal/*
|
||||
// lifecycle tools to Plan merely to keep the `bash` identity
|
||||
// stable across modes.
|
||||
|
||||
@@ -5968,7 +5968,9 @@ fn mode_blocks_write_capable_tool(
|
||||
&& (matches!(
|
||||
canonical_action_alias(tool_name, input),
|
||||
"write_file" | "edit_file" | "apply_patch"
|
||||
) || (McpPool::is_mcp_tool(tool_name) && !read_only))
|
||||
) || ((McpPool::is_mcp_tool(tool_name)
|
||||
|| crate::extension_host::dsh_broker::is_gate_tool(tool_name))
|
||||
&& !read_only))
|
||||
}
|
||||
|
||||
/// Synthesize the tool result recorded for a tool call that never executed
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
//! network calls force a prompt: a session grant is not consulted, and
|
||||
//! Full Access still opens their card. Auto-Review and Never refuse them
|
||||
//! (`resolve_approval_request_disposition`); explicit denials always win.
|
||||
//! The broker's own gate tools ([`dsh_broker::GATE_TOOLS`]) are the exception:
|
||||
//! the permission mode alone decides them, and only a broker arm reaches them
|
||||
//! (a plugin's own `core/call` of one is refused by name).
|
||||
//!
|
||||
//! **Caps.** Per invocation: 50 core tool calls in all, counted together
|
||||
//! across `core/call` and the nested calls of broker arms, 4 at
|
||||
@@ -77,7 +80,7 @@ use serde_json::{Value, json};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use super::ManagerShared;
|
||||
use super::dsh_broker::{self, Arm, BrokerCall, BrokerRefusal};
|
||||
use super::dsh_broker::{self, Arm, BrokerCall, BrokerEffect, BrokerRefusal, OpenAct};
|
||||
use super::protocol::{
|
||||
ContentBlockWire, CoreCallParams, DshBrokerParams, DshBrokerTicketWire, DshService,
|
||||
RpcErrorWire, SessionCheckpointParams, ToolResultWire, UserQuestionParams, error_code,
|
||||
@@ -106,6 +109,7 @@ const TICKET_TTL: Duration = Duration::from_secs(24 * 60 * 60);
|
||||
const GRANT_PROBE: Duration = Duration::from_millis(50);
|
||||
/// Receipts attached to an extension tool's result metadata.
|
||||
pub const RECEIPTS_ATTACHED: usize = 50;
|
||||
pub const MAX_ACT_RECORD_BYTES: usize = 1024;
|
||||
|
||||
/// Tools an extension may never reach through `core/call`, by lower-case
|
||||
/// name, checked on the spelling the host sent and on the canonical action
|
||||
@@ -204,7 +208,8 @@ pub fn refusal(specs: &[Arc<dyn ToolSpec>], name: &str, input: &Value) -> Option
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OriginApproval {
|
||||
/// Planning's decision stands (the tool is in [`EXT_AUTO_ELIGIBLE`] and
|
||||
/// nothing asked for approval).
|
||||
/// nothing asked for approval, or it is one of the broker's gate tools,
|
||||
/// which the permission mode alone decides).
|
||||
Unchanged,
|
||||
/// Needs approval; an extension-scoped session grant may satisfy it.
|
||||
Prompt,
|
||||
@@ -226,6 +231,9 @@ pub fn origin_approval(
|
||||
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let resolved = canonical(name, input);
|
||||
if dsh_broker::is_gate_tool(&lower) && spec.is_some_and(dsh_broker::is_gate_spec) {
|
||||
return OriginApproval::Unchanged;
|
||||
}
|
||||
let forced = |candidate: &str| {
|
||||
matches!(
|
||||
get_tool_category_for_call(candidate, input),
|
||||
@@ -269,8 +277,11 @@ pub struct Invocation {
|
||||
root_caller: Option<SessionCheckpointCaller>,
|
||||
broker: Option<BrokerScope>,
|
||||
nested_calls: AtomicU32,
|
||||
broker_acts: Mutex<(usize, Vec<Value>, bool)>,
|
||||
tickets: Vec<Ticket>,
|
||||
caller_tools: Vec<String>,
|
||||
gates: Vec<&'static str>,
|
||||
act_slots: tokio::sync::Semaphore,
|
||||
activations: Mutex<
|
||||
HashMap<
|
||||
String,
|
||||
@@ -309,17 +320,145 @@ impl BrokerContext {
|
||||
&self.cancel
|
||||
}
|
||||
|
||||
pub async fn core_call(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
) -> Result<ToolCallResponse, BrokerRefusal> {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(BrokerRefusal::nested(
|
||||
self.service,
|
||||
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
|
||||
));
|
||||
pub fn record(&self, entry: Value) -> bool {
|
||||
let mut entry = match entry {
|
||||
Value::Object(fields) => fields,
|
||||
other => {
|
||||
let mut fields = serde_json::Map::new();
|
||||
fields.insert("detail".to_string(), other);
|
||||
fields
|
||||
}
|
||||
};
|
||||
entry.insert(
|
||||
"service".to_string(),
|
||||
Value::String(self.service.name().to_string()),
|
||||
);
|
||||
let mut entry = Value::Object(entry);
|
||||
if entry.to_string().len() > MAX_ACT_RECORD_BYTES {
|
||||
let cut = |key: &str| {
|
||||
entry
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.map_or(Value::Null, |text| {
|
||||
Value::String(text.chars().take(dsh_broker::MAX_ACT_TARGET).collect())
|
||||
})
|
||||
};
|
||||
entry = json!({
|
||||
"service": self.service.name(),
|
||||
"method": cut("method"),
|
||||
"outcome": cut("outcome"),
|
||||
"cut": true,
|
||||
});
|
||||
}
|
||||
let mut acts = self
|
||||
.invocation
|
||||
.broker_acts
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if acts.2 {
|
||||
return false;
|
||||
}
|
||||
acts.0 += 1;
|
||||
if acts.1.len() < RECEIPTS_ATTACHED {
|
||||
acts.1.push(entry);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn act(
|
||||
&self,
|
||||
method: &str,
|
||||
effect: BrokerEffect,
|
||||
target: Option<&str>,
|
||||
) -> Result<(), BrokerRefusal> {
|
||||
let target: Option<String> =
|
||||
target.map(|target| target.chars().take(dsh_broker::MAX_ACT_TARGET).collect());
|
||||
let record = |outcome: &str| -> bool {
|
||||
let mut entry =
|
||||
json!({ "method": method, "effect": effect.name(), "outcome": outcome });
|
||||
if let Some(target) = &target {
|
||||
entry["target"] = json!(target);
|
||||
}
|
||||
self.record(entry)
|
||||
};
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(self.withdrawn());
|
||||
}
|
||||
if effect == BrokerEffect::Read {
|
||||
if let Err(refusal) = self.count() {
|
||||
record(refusal.code());
|
||||
return Err(refusal);
|
||||
}
|
||||
if !record("read") {
|
||||
return Err(self.withdrawn());
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let gate_tool = effect.gate_tool();
|
||||
if !self.invocation.gates.contains(&gate_tool) {
|
||||
record("refused");
|
||||
return Err(BrokerRefusal::Refused {
|
||||
service: self.service,
|
||||
message: "the permission gate is not available in this turn".to_string(),
|
||||
});
|
||||
}
|
||||
let slot = tokio::select! {
|
||||
slot = self.invocation.act_slots.acquire() => slot.ok(),
|
||||
() = self.cancel.cancelled() => None,
|
||||
() = self.invocation.cancel.cancelled() => None,
|
||||
};
|
||||
if slot.is_none() {
|
||||
return Err(self.withdrawn());
|
||||
}
|
||||
if let Err(refusal) = self.count() {
|
||||
record(refusal.code());
|
||||
return Err(refusal);
|
||||
}
|
||||
let mut input = json!({
|
||||
"plugin": self.invocation.owner.plugin_id,
|
||||
"service": self.service.name(),
|
||||
"method": method,
|
||||
"effect": effect.name(),
|
||||
});
|
||||
if let Some(target) = &target {
|
||||
input["target"] = json!(target);
|
||||
}
|
||||
let Some(_open) = OpenAct::open(&input, Arc::as_ptr(&self.invocation) as usize) else {
|
||||
record("refused");
|
||||
return Err(BrokerRefusal::Refused {
|
||||
service: self.service,
|
||||
message: "too many broker acts are waiting on the permission gate".to_string(),
|
||||
});
|
||||
};
|
||||
match self.call_counted(gate_tool, input.clone()).await {
|
||||
Ok(response) if response.ok && dsh_broker::answered_for(&input, &response.result) => {
|
||||
if !record("allowed") {
|
||||
return Err(self.withdrawn());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Ok(_) => {
|
||||
record("refused");
|
||||
Err(BrokerRefusal::Refused {
|
||||
service: self.service,
|
||||
message: "the permission gate refused this act".to_string(),
|
||||
})
|
||||
}
|
||||
Err(refusal) => {
|
||||
record(refusal.code());
|
||||
Err(refusal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn withdrawn(&self) -> BrokerRefusal {
|
||||
BrokerRefusal::nested(
|
||||
self.service,
|
||||
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
fn count(&self) -> Result<(), BrokerRefusal> {
|
||||
if self.invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
|
||||
return Err(BrokerRefusal::nested(
|
||||
self.service,
|
||||
@@ -331,6 +470,26 @@ impl BrokerContext {
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn core_call(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
) -> Result<ToolCallResponse, BrokerRefusal> {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(self.withdrawn());
|
||||
}
|
||||
self.count()?;
|
||||
self.call_counted(name, input).await
|
||||
}
|
||||
|
||||
async fn call_counted(
|
||||
&self,
|
||||
name: &str,
|
||||
input: Value,
|
||||
) -> Result<ToolCallResponse, BrokerRefusal> {
|
||||
self.invocation
|
||||
.calls
|
||||
.call(name.to_string(), input, Some(&self.cancel))
|
||||
@@ -345,7 +504,7 @@ impl BrokerContext {
|
||||
let tool = ToolContext::new(std::path::PathBuf::from("."));
|
||||
let gate = NestedCallGate::admitting_for_test();
|
||||
let calls = CodemodeInvoker::for_extension(
|
||||
Vec::new(),
|
||||
dsh_broker::BrokerActTool::all().to_vec(),
|
||||
tool.clone(),
|
||||
gate.clone(),
|
||||
String::new(),
|
||||
@@ -363,8 +522,11 @@ impl BrokerContext {
|
||||
root_caller: None,
|
||||
broker: None,
|
||||
nested_calls: AtomicU32::new(0),
|
||||
broker_acts: Mutex::new((0, Vec::new(), false)),
|
||||
tickets: Vec::new(),
|
||||
caller_tools: Vec::new(),
|
||||
gates: dsh_broker::GATE_TOOLS.to_vec(),
|
||||
act_slots: tokio::sync::Semaphore::new(dsh_broker::MAX_OPEN_ACTS_PER_INVOCATION),
|
||||
activations: Mutex::new(HashMap::new()),
|
||||
session_call_lock: tokio::sync::Mutex::new(()),
|
||||
cancel: CancellationToken::new(),
|
||||
@@ -511,12 +673,16 @@ impl CoreCalls {
|
||||
.as_ref()
|
||||
.map(|session| session.session_id.clone())
|
||||
.filter(|id| !id.is_empty());
|
||||
let root_agent = context.execution.owner_agent_id.is_none();
|
||||
let broker_tickets: Vec<(DshService, Ticket)> = broker_session
|
||||
.iter()
|
||||
.flat_map(|session| {
|
||||
DshService::ALL
|
||||
DshService::EVERY
|
||||
.into_iter()
|
||||
.filter(|service| tier == HostTier::Plugin && broker_services.contains(service))
|
||||
.filter(|service| {
|
||||
broker_services.contains(service)
|
||||
&& dsh_broker::mintable(tier, owner, *service, root_agent)
|
||||
})
|
||||
.map(move |service| {
|
||||
(
|
||||
service,
|
||||
@@ -544,9 +710,19 @@ impl CoreCalls {
|
||||
context: Arc::new(context),
|
||||
}
|
||||
});
|
||||
let gates: Vec<&'static str> = dsh_broker::GATE_TOOLS
|
||||
.into_iter()
|
||||
.filter(|gate| {
|
||||
specs.iter().any(|spec| {
|
||||
spec.name().eq_ignore_ascii_case(gate)
|
||||
&& dsh_broker::is_gate_spec(spec.as_ref())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let caller_tools = specs
|
||||
.iter()
|
||||
.map(|spec| spec.name().to_string())
|
||||
.filter(|name| !dsh_broker::is_gate_tool(name))
|
||||
.filter(|name| {
|
||||
!context
|
||||
.disallowed_tools
|
||||
@@ -566,6 +742,7 @@ impl CoreCalls {
|
||||
root_caller,
|
||||
broker,
|
||||
nested_calls: AtomicU32::new(0),
|
||||
broker_acts: Mutex::new((0, Vec::new(), false)),
|
||||
tickets: std::iter::once(ticket.clone())
|
||||
.chain(checkpoint_ticket.iter().cloned())
|
||||
.chain(question_ticket.iter().cloned())
|
||||
@@ -573,6 +750,8 @@ impl CoreCalls {
|
||||
.chain(broker_tickets.iter().map(|(_, ticket)| ticket.clone()))
|
||||
.collect(),
|
||||
caller_tools,
|
||||
gates,
|
||||
act_slots: tokio::sync::Semaphore::new(dsh_broker::MAX_OPEN_ACTS_PER_INVOCATION),
|
||||
activations: Mutex::new(HashMap::new()),
|
||||
session_call_lock: tokio::sync::Mutex::new(()),
|
||||
cancel: CancellationToken::new(),
|
||||
@@ -1299,6 +1478,9 @@ impl CoreCalls {
|
||||
.as_ref()
|
||||
.map(|scope| (scope.session_id.clone(), Arc::clone(&scope.context)))
|
||||
.ok_or_else(|| refused("dsh/broker requires an invocation in a session".into()))?;
|
||||
if service.builtin_only() && tool.execution.owner_agent_id.is_some() {
|
||||
return Err(BrokerRefusal::NotGranted(service).wire());
|
||||
}
|
||||
if !shared.broker_granted(¶ms.owner, service).await {
|
||||
return Err(BrokerRefusal::NotGranted(service).wire());
|
||||
}
|
||||
@@ -1386,6 +1568,15 @@ impl CoreCalls {
|
||||
METHOD,
|
||||
&cx,
|
||||
)?;
|
||||
if dsh_broker::is_gate_tool(¶ms.name) {
|
||||
return Err(refuse(
|
||||
error_code::REFUSED,
|
||||
format!(
|
||||
"`{}` is the host broker's permission gate; only the broker reaches it",
|
||||
params.name
|
||||
),
|
||||
));
|
||||
}
|
||||
if invocation.nested_calls.fetch_add(1, Ordering::SeqCst) >= MAX_CALLS_PER_INVOCATION {
|
||||
return Err(refuse(
|
||||
error_code::REFUSED,
|
||||
@@ -1544,8 +1735,33 @@ impl InvocationGuard {
|
||||
/// The receipts of the core calls made so far, bounded, for the tool's
|
||||
/// result metadata; `None` when it made none.
|
||||
pub fn receipts(&self) -> Option<Value> {
|
||||
let receipts = self.invocation.calls.receipts_json(RECEIPTS_ATTACHED);
|
||||
(receipts["total"].as_u64().unwrap_or(0) > 0).then_some(receipts)
|
||||
self.snapshot(false)
|
||||
}
|
||||
|
||||
pub fn seal_receipts(&self) -> Option<Value> {
|
||||
self.snapshot(true)
|
||||
}
|
||||
|
||||
fn snapshot(&self, seal: bool) -> Option<Value> {
|
||||
let (total, acts) = {
|
||||
let mut guard = self
|
||||
.invocation
|
||||
.broker_acts
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if seal {
|
||||
guard.2 = true;
|
||||
}
|
||||
(guard.0, guard.1.clone())
|
||||
};
|
||||
if seal {
|
||||
self.invocation.cancel.cancel();
|
||||
}
|
||||
let mut receipts = self.invocation.calls.receipts_json(RECEIPTS_ATTACHED);
|
||||
if total > 0 {
|
||||
receipts["broker_acts"] = json!({ "total": total, "acts": acts });
|
||||
}
|
||||
(receipts["total"].as_u64().unwrap_or(0) > 0 || total > 0).then_some(receipts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1554,3 +1770,86 @@ impl Drop for InvocationGuard {
|
||||
self.core_calls.end(&self.ticket);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod seal_tests {
|
||||
use super::*;
|
||||
use crate::tools::codemode::{NestedCallRequest, NestedCallVerdict};
|
||||
use crate::tools::spec::ToolResult;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_successful_gate_answer_after_sealing_cannot_record_or_perform_an_act() {
|
||||
let owner = OwnerRef {
|
||||
plugin_id: "sealed-gate".into(),
|
||||
generation: 1,
|
||||
owner_token: "fixture".into(),
|
||||
};
|
||||
let caller = ExtensionCaller {
|
||||
origin: "extension:sealed-gate".into(),
|
||||
tool: "sealed_tool".into(),
|
||||
scope: "ext:sealed-gate@hash".into(),
|
||||
};
|
||||
let context = ToolContext::new(std::path::PathBuf::from("."));
|
||||
let (events, _events) = tokio::sync::mpsc::channel(8);
|
||||
let (gate, mut requests) = NestedCallGate::new(None, events, Duration::from_secs(5));
|
||||
let gate = gate.for_extension(caller.clone(), dsh_broker::BrokerActTool::all().to_vec());
|
||||
let calls = Arc::new(CoreCalls::default());
|
||||
let guard = calls
|
||||
.begin(
|
||||
HostTier::Plugin,
|
||||
1,
|
||||
&owner,
|
||||
"call",
|
||||
&caller,
|
||||
&context,
|
||||
&gate,
|
||||
)
|
||||
.expect("invocation");
|
||||
let broker = BrokerContext {
|
||||
invocation: Arc::clone(&guard.invocation),
|
||||
tool: Arc::new(context),
|
||||
service: DshService::Remote,
|
||||
cancel: CancellationToken::new(),
|
||||
};
|
||||
assert!(broker.record(json!({"method": "before", "outcome": "read"})));
|
||||
let performed = std::sync::atomic::AtomicBool::new(false);
|
||||
let (result, sealed) = tokio::join!(
|
||||
async {
|
||||
broker
|
||||
.act("write", BrokerEffect::Write, Some("late"))
|
||||
.await?;
|
||||
performed.store(true, Ordering::SeqCst);
|
||||
Ok::<(), BrokerRefusal>(())
|
||||
},
|
||||
async {
|
||||
let Some(NestedCallRequest::Tool(request)) = requests.recv().await else {
|
||||
panic!("the act must reach its gate before sealing");
|
||||
};
|
||||
let sealed = guard.seal_receipts().expect("the earlier record survives");
|
||||
assert!(!broker.cancel.is_cancelled());
|
||||
assert!(
|
||||
request
|
||||
.reply
|
||||
.send(NestedCallVerdict::Answered {
|
||||
result: ToolResult::success(request.input.to_string()),
|
||||
hook_context: None,
|
||||
})
|
||||
.is_ok(),
|
||||
"the gate response is still delivered"
|
||||
);
|
||||
sealed
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a successful late answer must be refused: {result:?}"
|
||||
);
|
||||
assert!(!performed.load(Ordering::SeqCst));
|
||||
assert!(!broker.record(json!({"method": "late", "outcome": "allowed"})));
|
||||
assert_eq!(sealed["broker_acts"]["total"], 1);
|
||||
assert_eq!(
|
||||
guard.receipts().unwrap()["broker_acts"],
|
||||
sealed["broker_acts"]
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,10 +7,15 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::protocol::{DshService, RpcErrorWire, error_code};
|
||||
use super::registry::OwnerRegistry;
|
||||
use super::tier::{HOST_OWNER_PREFIX, HostTier};
|
||||
use super::{
|
||||
dsh_catalog, dsh_llm, dsh_network, dsh_remote, dsh_settings, dsh_terminal, dsh_workspace,
|
||||
dsh_catalog, dsh_llm, dsh_network, dsh_plugins, dsh_remote, dsh_settings, dsh_terminal,
|
||||
dsh_workspace,
|
||||
};
|
||||
use crate::tools::codemode::{NestedDecision, NestedFailure};
|
||||
use crate::tools::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
|
||||
};
|
||||
|
||||
pub use super::core_call::BrokerContext;
|
||||
|
||||
@@ -149,8 +154,52 @@ pub const TABLE: &[(DshService, Arm)] = &[
|
||||
(DshService::Catalog, dsh_catalog::serve as Arm),
|
||||
(DshService::Terminal, dsh_terminal::serve as Arm),
|
||||
(DshService::Remote, dsh_remote::serve as Arm),
|
||||
(DshService::Plugins, dsh_plugins::serve as Arm),
|
||||
];
|
||||
|
||||
pub const BUILTIN_SERVICES: &[(&str, &[DshService])] = &[("creator", &[DshService::Plugins])];
|
||||
|
||||
#[must_use]
|
||||
pub fn builtin_services(owner_id: &str) -> &'static [DshService] {
|
||||
owner_id
|
||||
.strip_prefix(HOST_OWNER_PREFIX)
|
||||
.and_then(|module| {
|
||||
BUILTIN_SERVICES
|
||||
.iter()
|
||||
.find(|(id, _)| *id == module)
|
||||
.map(|(_, services)| *services)
|
||||
})
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
fn builtin_route(owner: &OwnerRef, service: DshService) -> bool {
|
||||
owner.plugin_id.starts_with(HOST_OWNER_PREFIX) || service.builtin_only()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn builtin_granted(
|
||||
registry: &OwnerRegistry,
|
||||
owner: &OwnerRef,
|
||||
service: DshService,
|
||||
) -> Option<bool> {
|
||||
builtin_route(owner, service).then(|| {
|
||||
registry.tier_of(owner) == Some(HostTier::Builtin)
|
||||
&& builtin_services(&owner.plugin_id).contains(&service)
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn mintable(tier: HostTier, owner: &OwnerRef, service: DshService, root_agent: bool) -> bool {
|
||||
if builtin_route(owner, service) || tier == HostTier::Builtin {
|
||||
tier == HostTier::Builtin
|
||||
&& owner.plugin_id.starts_with(HOST_OWNER_PREFIX)
|
||||
&& builtin_services(&owner.plugin_id).contains(&service)
|
||||
&& (root_agent || !service.builtin_only())
|
||||
} else {
|
||||
tier == HostTier::Plugin
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_GRANTS: std::cell::RefCell<Option<Vec<DshService>>> =
|
||||
@@ -193,6 +242,9 @@ pub fn offered(service: DshService) -> bool {
|
||||
|
||||
#[must_use]
|
||||
pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService) -> bool {
|
||||
if let Some(granted) = builtin_granted(registry, owner, service) {
|
||||
return granted;
|
||||
}
|
||||
match test_grants() {
|
||||
Some(granted) => granted.contains(&service),
|
||||
None => offered(service) && registry.host_service_granted(owner, service),
|
||||
@@ -201,7 +253,7 @@ pub fn granted(registry: &OwnerRegistry, owner: &OwnerRef, service: DshService)
|
||||
|
||||
#[must_use]
|
||||
pub fn granted_services(registry: &OwnerRegistry, owner: &OwnerRef) -> Vec<DshService> {
|
||||
DshService::ALL
|
||||
DshService::EVERY
|
||||
.into_iter()
|
||||
.filter(|service| granted(registry, owner, *service))
|
||||
.collect()
|
||||
@@ -225,6 +277,7 @@ pub fn ticket_method(service: DshService) -> &'static str {
|
||||
DshService::Catalog => "dsh/broker:catalog",
|
||||
DshService::Terminal => "dsh/broker:terminal",
|
||||
DshService::Remote => "dsh/broker:remote",
|
||||
DshService::Plugins => "dsh/broker:plugins",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,3 +305,242 @@ pub fn not_available(service: DshService) -> BrokerFuture {
|
||||
service,
|
||||
))))
|
||||
}
|
||||
|
||||
pub const ACT_TOOL: &str = "dsh_broker_act";
|
||||
pub const REACH_TOOL: &str = "dsh_broker_reach";
|
||||
pub const GATE_TOOLS: [&str; 2] = [ACT_TOOL, REACH_TOOL];
|
||||
pub const MAX_ACT_TARGET: usize = 256;
|
||||
const MAX_OPEN_ACTS: usize = 256;
|
||||
pub const MAX_OPEN_ACTS_PER_INVOCATION: usize = 4;
|
||||
|
||||
#[must_use]
|
||||
pub fn is_gate_tool(name: &str) -> bool {
|
||||
GATE_TOOLS
|
||||
.iter()
|
||||
.any(|tool| name.eq_ignore_ascii_case(tool))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BrokerEffect {
|
||||
Read,
|
||||
Write,
|
||||
Reach,
|
||||
}
|
||||
|
||||
impl BrokerEffect {
|
||||
#[must_use]
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Read => "read",
|
||||
Self::Write => "write",
|
||||
Self::Reach => "reach",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn gate_tool(self) -> &'static str {
|
||||
match self {
|
||||
Self::Reach => REACH_TOOL,
|
||||
Self::Read | Self::Write => ACT_TOOL,
|
||||
}
|
||||
}
|
||||
|
||||
fn of(input: &Value) -> Option<Self> {
|
||||
serde_json::from_value(input.get("effect")?.clone()).ok()
|
||||
}
|
||||
}
|
||||
|
||||
static OPEN_ACTS: std::sync::Mutex<Vec<(usize, Value)>> = std::sync::Mutex::new(Vec::new());
|
||||
|
||||
fn open_acts() -> std::sync::MutexGuard<'static, Vec<(usize, Value)>> {
|
||||
OPEN_ACTS
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
pub struct OpenAct(usize, Value);
|
||||
|
||||
impl OpenAct {
|
||||
#[must_use]
|
||||
pub fn open(input: &Value, holder: usize) -> Option<Self> {
|
||||
let mut open = open_acts();
|
||||
if open.len() >= MAX_OPEN_ACTS {
|
||||
return None;
|
||||
}
|
||||
open.push((holder, input.clone()));
|
||||
Some(Self(holder, input.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OpenAct {
|
||||
fn drop(&mut self) {
|
||||
let mut open = open_acts();
|
||||
if let Some(at) = open
|
||||
.iter()
|
||||
.position(|(holder, act)| *holder == self.0 && *act == self.1)
|
||||
{
|
||||
open.swap_remove(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn act_is_open(input: &Value) -> bool {
|
||||
open_acts().iter().any(|(_, open)| open == input)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn answered_for(input: &Value, result: &Value) -> bool {
|
||||
match result.get("content") {
|
||||
Some(Value::String(text)) => {
|
||||
serde_json::from_str::<Value>(text).is_ok_and(|answered| answered == *input)
|
||||
}
|
||||
Some(other) => other == input,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_gate_spec(spec: &dyn ToolSpec) -> bool {
|
||||
is_gate_tool(spec.name())
|
||||
&& spec.registration_origin() == std::any::type_name::<BrokerActTool>()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn act_description(input: &Value) -> String {
|
||||
let field = |key: &str| input.get(key).and_then(Value::as_str).unwrap_or("?");
|
||||
let verb = match BrokerEffect::of(input) {
|
||||
Some(BrokerEffect::Write) => "change something",
|
||||
Some(BrokerEffect::Reach) => "reach outside this session",
|
||||
_ => "read",
|
||||
};
|
||||
let target = input
|
||||
.get("target")
|
||||
.and_then(Value::as_str)
|
||||
.map(|target| format!(" ({target})"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"plugin `{}` asks to {verb} through the {} service: `{}`{target}",
|
||||
field("plugin"),
|
||||
field("service"),
|
||||
field("method"),
|
||||
)
|
||||
}
|
||||
|
||||
pub struct BrokerActTool {
|
||||
reach: bool,
|
||||
}
|
||||
|
||||
impl BrokerActTool {
|
||||
pub const ACT: Self = Self { reach: false };
|
||||
pub const REACH: Self = Self { reach: true };
|
||||
|
||||
#[must_use]
|
||||
pub fn all() -> [std::sync::Arc<dyn ToolSpec>; 2] {
|
||||
[
|
||||
std::sync::Arc::new(Self::ACT),
|
||||
std::sync::Arc::new(Self::REACH),
|
||||
]
|
||||
}
|
||||
|
||||
fn effects(&self) -> &'static [&'static str] {
|
||||
if self.reach {
|
||||
&["reach"]
|
||||
} else {
|
||||
&["read", "write"]
|
||||
}
|
||||
}
|
||||
|
||||
fn admits(&self, input: &Value) -> Result<(), ToolError> {
|
||||
let fits = BrokerEffect::of(input)
|
||||
.is_some_and(|effect| (effect == BrokerEffect::Reach) == self.reach);
|
||||
if fits && act_is_open(input) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ToolError::permission_denied(format!(
|
||||
"{} answers only for an act a plugin broker has open",
|
||||
self.name()
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ToolSpec for BrokerActTool {
|
||||
fn name(&self) -> &str {
|
||||
if self.reach { REACH_TOOL } else { ACT_TOOL }
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"The session's permission gate for one act a plugin asked the host broker for."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"plugin": {"type": "string"},
|
||||
"service": {"type": "string"},
|
||||
"method": {"type": "string"},
|
||||
"effect": {"type": "string", "enum": self.effects()},
|
||||
"target": {"type": "string"},
|
||||
},
|
||||
"required": ["plugin", "service", "method", "effect"],
|
||||
"additionalProperties": false,
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
if self.reach {
|
||||
vec![ToolCapability::Network]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Required
|
||||
}
|
||||
|
||||
fn approval_requirement_for(&self, input: &Value) -> ApprovalRequirement {
|
||||
match BrokerEffect::of(input) {
|
||||
Some(BrokerEffect::Read) => ApprovalRequirement::Auto,
|
||||
_ => ApprovalRequirement::Required,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_read_only(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_read_only_for(&self, input: &Value) -> bool {
|
||||
BrokerEffect::of(input) == Some(BrokerEffect::Read)
|
||||
}
|
||||
|
||||
fn model_visible(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn prepare(
|
||||
&self,
|
||||
input: Value,
|
||||
_context: &ToolContext,
|
||||
) -> Result<crate::tools::spec::PreparedToolCall, ToolError> {
|
||||
self.admits(&input)?;
|
||||
Ok(crate::tools::spec::PreparedToolCall {
|
||||
name: self.name().to_string(),
|
||||
description: act_description(&input),
|
||||
read_only: self.is_read_only_for(&input),
|
||||
supports_parallel: false,
|
||||
starts_detached: false,
|
||||
approval: self.approval_requirement_for(&input),
|
||||
resources: vec![crate::tools::spec::ResourceClaim::GlobalExclusive],
|
||||
input,
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
self.admits(&input)?;
|
||||
Ok(ToolResult::success(input.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,337 @@
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
|
||||
use super::protocol::DshService;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
|
||||
not_available(DshService::Catalog)
|
||||
use codewhale_config::PluginAuthority;
|
||||
use codewhale_plugins::PluginRegistry;
|
||||
use codewhale_plugins::activation::{
|
||||
PluginActivationCapability, PolicyScope, extension_host_policy_enabled,
|
||||
};
|
||||
use codewhale_plugins::install::dsh::{plan_npm_install, refusal_text};
|
||||
use codewhale_plugins::providers::{PluginProviderDeclaration, validate_declarations};
|
||||
use codewhale_plugins::recommend::{
|
||||
PluginMatchSource, PluginNextStep, PluginTaskRecommendation, lookup_reviewable_plugin,
|
||||
};
|
||||
use codewhale_plugins::registry::verify_plugin_component_authority;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::dsh_broker::{
|
||||
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
|
||||
};
|
||||
use super::protocol::DshService;
|
||||
use crate::pricing::BalanceInfo;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum CatalogMethod {
|
||||
#[serde(rename = "install.plan")]
|
||||
InstallPlan(InstallRequest),
|
||||
#[serde(rename = "routes.list")]
|
||||
RoutesList(NoInput),
|
||||
#[serde(rename = "routes.resolve")]
|
||||
RoutesResolve(RouteRequest),
|
||||
#[serde(rename = "balance")]
|
||||
Balance(NoInput),
|
||||
}
|
||||
|
||||
impl BrokerMethods for CatalogMethod {
|
||||
const NAMES: &'static [&'static str] =
|
||||
&["install.plan", "routes.list", "routes.resolve", "balance"];
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct NoInput {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct InstallRequest {
|
||||
package: String,
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
registry: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RouteRequest {
|
||||
provider: String,
|
||||
#[serde(default)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct AccountBalance {
|
||||
pub available: bool,
|
||||
pub reading: Option<BalanceInfo>,
|
||||
}
|
||||
|
||||
type ApprovedRoutes = Vec<(String, PluginProviderDeclaration)>;
|
||||
|
||||
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
serve_with(call, None)
|
||||
}
|
||||
|
||||
pub(super) fn serve_with(call: BrokerCall, account: Option<AccountBalance>) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let caller = call.owner.plugin_id.clone();
|
||||
match decode::<CatalogMethod>(&call)? {
|
||||
CatalogMethod::InstallPlan(request) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act("install.plan", BrokerEffect::Read, Some(&request.package))
|
||||
.await?;
|
||||
install_plan(&plugins, &caller, request)
|
||||
}
|
||||
CatalogMethod::RoutesList(NoInput {}) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act("routes.list", BrokerEffect::Read, None)
|
||||
.await?;
|
||||
let routes = approved_routes(plugins, &caller).await?;
|
||||
let routes: Vec<Value> = routes
|
||||
.iter()
|
||||
.map(|(name, declaration)| route(name, declaration))
|
||||
.collect();
|
||||
Ok(json!({ "routes": routes }))
|
||||
}
|
||||
CatalogMethod::RoutesResolve(request) => {
|
||||
let plugins = plugins(&call)?;
|
||||
call.context
|
||||
.act(
|
||||
"routes.resolve",
|
||||
BrokerEffect::Read,
|
||||
Some(&request.provider),
|
||||
)
|
||||
.await?;
|
||||
let routes = approved_routes(plugins, &caller).await?;
|
||||
resolve_route(&caller, routes, request)
|
||||
}
|
||||
CatalogMethod::Balance(NoInput {}) => {
|
||||
call.context
|
||||
.act("balance", BrokerEffect::Read, None)
|
||||
.await?;
|
||||
Ok(balance(account))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn plugins(call: &BrokerCall) -> Result<Arc<PluginRegistry>, BrokerRefusal> {
|
||||
call.context
|
||||
.tool()
|
||||
.plugin_registry
|
||||
.clone()
|
||||
.ok_or(BrokerRefusal::NotAvailable(DshService::Catalog))
|
||||
}
|
||||
|
||||
fn refused(message: impl Into<String>) -> BrokerRefusal {
|
||||
BrokerRefusal::Refused {
|
||||
service: DshService::Catalog,
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn review_command(name: &str, next_step: PluginNextStep) -> String {
|
||||
PluginTaskRecommendation {
|
||||
name: name.to_string(),
|
||||
source: PluginMatchSource::Installed {
|
||||
id: name.to_string(),
|
||||
},
|
||||
matched_terms: Vec::new(),
|
||||
score: 0,
|
||||
next_step,
|
||||
}
|
||||
.command()
|
||||
}
|
||||
|
||||
fn install_plan(
|
||||
plugins: &PluginRegistry,
|
||||
caller: &str,
|
||||
request: InstallRequest,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let plan = plan_npm_install(
|
||||
&request.package,
|
||||
&request.version,
|
||||
request.registry.as_deref(),
|
||||
)
|
||||
.map_err(|reason| {
|
||||
tracing::info!(target: "codewhale::dsh_catalog", plugin = %caller, "install plan refused");
|
||||
refused(reason)
|
||||
})?;
|
||||
let existing = plugins.get(&plan.plugin_name);
|
||||
let (status, command, message) = match existing {
|
||||
Some(existing) => {
|
||||
let command = lookup_reviewable_plugin(existing.name(), plugins, &[])
|
||||
.map(|reviewable| reviewable.command())
|
||||
.unwrap_or_else(|| review_command(existing.name(), PluginNextStep::Inspect));
|
||||
let message = format!(
|
||||
"A plugin named `{}` is already present; nothing was installed. Review it with `{command}`. A market listing does not establish that it is the same bundle.",
|
||||
existing.name()
|
||||
);
|
||||
("already_present", command, message)
|
||||
}
|
||||
None => {
|
||||
let command = review_command(
|
||||
&plan.plugin_name,
|
||||
PluginNextStep::SourceInstall {
|
||||
spec: plan.spec.clone(),
|
||||
},
|
||||
);
|
||||
let message = format!(
|
||||
"Codewhale installs a plugin only through its reviewed install flow; nothing was installed. Review and install {}@{} with `{command}`.",
|
||||
request.package, request.version
|
||||
);
|
||||
("review_required", command, message)
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
package = %request.package,
|
||||
version = %request.version,
|
||||
status,
|
||||
"install planned for review; nothing installed"
|
||||
);
|
||||
let mut answer = json!({
|
||||
"status": status,
|
||||
"package": request.package,
|
||||
"version": request.version,
|
||||
"plugin": plan.plugin_name,
|
||||
"command": command,
|
||||
"message": message,
|
||||
});
|
||||
if existing.is_none() {
|
||||
answer["spec"] = Value::String(plan.spec);
|
||||
}
|
||||
Ok(answer)
|
||||
}
|
||||
|
||||
fn declared_routes(
|
||||
plugins: &PluginRegistry,
|
||||
caller: &str,
|
||||
) -> Result<Option<(ApprovedRoutes, PluginAuthority)>, String> {
|
||||
let Some(plugin) = plugins.get(caller).filter(|plugin| plugin.active()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if plugin.manifest.providers.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
validate_declarations(&plugin.manifest.providers)?;
|
||||
let authority = plugins
|
||||
.authority_for(caller)
|
||||
.ok_or("the plugin has no review receipt")?;
|
||||
let routes = plugin
|
||||
.manifest
|
||||
.providers
|
||||
.iter()
|
||||
.map(|(name, declaration)| (name.clone(), declaration.clone()))
|
||||
.collect();
|
||||
Ok(Some((routes, authority)))
|
||||
}
|
||||
|
||||
async fn approved_routes(
|
||||
plugins: Arc<PluginRegistry>,
|
||||
caller: &str,
|
||||
) -> Result<ApprovedRoutes, BrokerRefusal> {
|
||||
let receipt = |reason: String| {
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
reason = %reason,
|
||||
"provider routes refused: the review receipt does not hold"
|
||||
);
|
||||
refused(
|
||||
"the review that approved this plugin's provider routes no longer holds; review the plugin again in Codewhale",
|
||||
)
|
||||
};
|
||||
let Some((routes, authority)) = declared_routes(&plugins, caller).map_err(receipt)? else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let policy = extension_host_policy_enabled();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let env_scope = crate::test_support::env_scope_ticket();
|
||||
let verified = tokio::task::spawn_blocking(move || {
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let _env_scope = crate::test_support::join_env_scope(env_scope);
|
||||
let _policy = PolicyScope::propagate(policy);
|
||||
verify_plugin_component_authority(&authority, PluginActivationCapability::Providers)
|
||||
})
|
||||
.await;
|
||||
match verified {
|
||||
Ok(Ok(())) => Ok(routes),
|
||||
Ok(Err(reason)) => Err(receipt(reason)),
|
||||
Err(error) => Err(receipt(error.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_route(
|
||||
caller: &str,
|
||||
routes: ApprovedRoutes,
|
||||
request: RouteRequest,
|
||||
) -> Result<Value, BrokerRefusal> {
|
||||
let Some((name, declaration)) = routes
|
||||
.into_iter()
|
||||
.find(|(name, _)| *name == request.provider)
|
||||
else {
|
||||
tracing::info!(
|
||||
target: "codewhale::dsh_catalog",
|
||||
plugin = %caller,
|
||||
"provider route refused: not approved for this plugin"
|
||||
);
|
||||
return Err(refused(format!(
|
||||
"`{}` is not a provider route approved for this plugin. A plugin cannot register a model adapter in Codewhale: a model route is a provider route you approve in the plugin's review",
|
||||
refusal_text(&request.provider)
|
||||
)));
|
||||
};
|
||||
let model = match request.model {
|
||||
None => declaration.model.clone(),
|
||||
Some(model) => {
|
||||
let offered = if declaration.models.is_empty() {
|
||||
declaration
|
||||
.model
|
||||
.as_deref()
|
||||
.is_none_or(|default| default == model)
|
||||
} else {
|
||||
declaration.models.contains(&model)
|
||||
};
|
||||
if !offered {
|
||||
return Err(refused(format!(
|
||||
"model `{}` is not on the approved provider route `{name}`",
|
||||
refusal_text(&model)
|
||||
)));
|
||||
}
|
||||
Some(model)
|
||||
}
|
||||
};
|
||||
Ok(json!({ "route": route(&name, &declaration), "model": model }))
|
||||
}
|
||||
|
||||
fn route(name: &str, declaration: &PluginProviderDeclaration) -> Value {
|
||||
json!({
|
||||
"provider": name,
|
||||
"base_url": declaration.base_url,
|
||||
"default_model": declaration.model,
|
||||
"models": declaration.models,
|
||||
})
|
||||
}
|
||||
|
||||
fn balance(account: Option<AccountBalance>) -> Value {
|
||||
let Some(account) = account else {
|
||||
return json!({ "status": "unknown" });
|
||||
};
|
||||
if !account.available {
|
||||
return json!({ "status": "absent" });
|
||||
}
|
||||
match account
|
||||
.reading
|
||||
.filter(|reading| !reading.total_balance.trim().is_empty())
|
||||
{
|
||||
Some(reading) => json!({
|
||||
"status": "known",
|
||||
"currency": reading.currency,
|
||||
"total": reading.total_balance,
|
||||
"topped_up": reading.topped_up_balance,
|
||||
"granted": reading.granted_balance,
|
||||
}),
|
||||
None => json!({ "status": "unknown" }),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,85 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::dsh_broker::{
|
||||
BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, builtin_services, decode,
|
||||
};
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::spec::ToolContext;
|
||||
|
||||
const LOG: &str = "codewhale::dsh_plugins";
|
||||
const MAX_INPUT_FIELDS: usize = 32;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "method", content = "input", deny_unknown_fields)]
|
||||
enum PluginsMethod {
|
||||
#[serde(rename = "list")]
|
||||
List(Map<String, Value>),
|
||||
#[serde(rename = "status")]
|
||||
Status(Map<String, Value>),
|
||||
#[serde(rename = "posture")]
|
||||
Posture(Map<String, Value>),
|
||||
#[serde(rename = "install")]
|
||||
Install(Map<String, Value>),
|
||||
#[serde(rename = "remove")]
|
||||
Remove(Map<String, Value>),
|
||||
#[serde(rename = "enable")]
|
||||
Enable(Map<String, Value>),
|
||||
#[serde(rename = "disable")]
|
||||
Disable(Map<String, Value>),
|
||||
}
|
||||
|
||||
impl BrokerMethods for PluginsMethod {
|
||||
const NAMES: &'static [&'static str] = &[
|
||||
"list", "status", "posture", "install", "remove", "enable", "disable",
|
||||
];
|
||||
}
|
||||
|
||||
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
Box::pin(async move {
|
||||
let method = decode::<PluginsMethod>(&call)?;
|
||||
if !builtin_services(&call.owner.plugin_id).contains(&DshService::Plugins) {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "plugins refused: not the creator module");
|
||||
return Err(refused());
|
||||
}
|
||||
let tool = live(&call);
|
||||
if tool.execution.owner_agent_id.is_some() {
|
||||
tracing::info!(target: LOG, "plugins refused: a sub-agent caller");
|
||||
return Err(refused());
|
||||
}
|
||||
if method.input().len() > MAX_INPUT_FIELDS {
|
||||
return Err(BrokerRefusal::InvalidInput {
|
||||
service: DshService::Plugins,
|
||||
message: format!("a plugins input has at most {MAX_INPUT_FIELDS} fields"),
|
||||
});
|
||||
}
|
||||
Err(BrokerRefusal::NotAvailable(DshService::Plugins))
|
||||
})
|
||||
}
|
||||
|
||||
impl PluginsMethod {
|
||||
fn input(&self) -> &Map<String, Value> {
|
||||
match self {
|
||||
Self::List(input)
|
||||
| Self::Status(input)
|
||||
| Self::Posture(input)
|
||||
| Self::Install(input)
|
||||
| Self::Remove(input)
|
||||
| Self::Enable(input)
|
||||
| Self::Disable(input) => input,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn live(call: &BrokerCall) -> ToolContext {
|
||||
let mut tool = call.context.tool().clone();
|
||||
let _ = tool.refresh_live_posture();
|
||||
tool
|
||||
}
|
||||
|
||||
fn refused() -> BrokerRefusal {
|
||||
BrokerRefusal::Refused {
|
||||
service: DshService::Plugins,
|
||||
message: "the plugins service is not available to this caller".to_string(),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -5,7 +5,9 @@ use codewhale_workflow_js::ToolCallResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::dsh_broker::{
|
||||
BrokerCall, BrokerEffect, BrokerFuture, BrokerMethods, BrokerRefusal, decode,
|
||||
};
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::execution_envelope::enforce_tool_denial;
|
||||
|
||||
@@ -16,7 +18,7 @@ const MAX_COMMAND_BYTES: usize = 16 * 1024;
|
||||
const MAX_INPUT_BYTES: usize = 64 * 1024;
|
||||
const MAX_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_SETTLE_MS: u64 = 60_000;
|
||||
pub(super) const MAX_TERMINALS: usize = 8;
|
||||
pub(crate) const MAX_TERMINALS: usize = 8;
|
||||
const LOG: &str = "codewhale::dsh_terminal";
|
||||
|
||||
static REGISTRY_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
@@ -121,6 +123,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
match method {
|
||||
TerminalMethod::List(NoInput {}) => {
|
||||
let live = live(&call, "terminal/wait").await?;
|
||||
call.context.act("list", BrokerEffect::Read, None).await?;
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, terminals = live.terminals.len(), "terminals listed");
|
||||
Ok(json!({
|
||||
"instance": live.instance,
|
||||
@@ -221,13 +224,16 @@ fn refused(message: &str) -> BrokerRefusal {
|
||||
}
|
||||
}
|
||||
|
||||
fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
|
||||
let ok = !name.is_empty()
|
||||
pub(crate) fn is_valid_session_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= MAX_SESSION_NAME
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
|
||||
if ok {
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
|
||||
}
|
||||
|
||||
fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
|
||||
if is_valid_session_name(name) {
|
||||
Ok(name)
|
||||
} else {
|
||||
Err(invalid(format!(
|
||||
@@ -236,6 +242,13 @@ fn session_name(name: &str) -> Result<&str, BrokerRefusal> {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_env = "ohos")))]
|
||||
pub(crate) fn session_parts(full_name: &str) -> Option<(&str, &str)> {
|
||||
let rest = full_name.strip_prefix("dsh:")?;
|
||||
let (plugin_id, session) = rest.rsplit_once(':')?;
|
||||
(!plugin_id.is_empty() && is_valid_session_name(session)).then_some((plugin_id, session))
|
||||
}
|
||||
|
||||
fn timeout(secs: Option<u64>) -> Result<Option<u64>, BrokerRefusal> {
|
||||
match secs {
|
||||
Some(secs) if !(1..=MAX_TIMEOUT_SECS).contains(&secs) => Err(invalid(format!(
|
||||
@@ -245,10 +258,21 @@ fn timeout(secs: Option<u64>) -> Result<Option<u64>, BrokerRefusal> {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix(plugin: &str) -> String {
|
||||
pub(crate) fn prefix(plugin: &str) -> String {
|
||||
format!("dsh:{plugin}:")
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(target_env = "ohos")))]
|
||||
pub(super) fn kill_plugin_sessions(plugin_id: &str) {
|
||||
let prefix = prefix(plugin_id);
|
||||
if let Err(error) = crate::tools::terminal_session::kill_sessions_named(&prefix) {
|
||||
tracing::warn!(target: LOG, plugin = %plugin_id, %error, "failed to end plugin terminal sessions");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(unix, not(target_env = "ohos"))))]
|
||||
pub(super) fn kill_plugin_sessions(_plugin_id: &str) {}
|
||||
|
||||
fn owned_name(call: &BrokerCall, session: &str) -> String {
|
||||
format!("{}{session}", prefix(&call.owner.plugin_id))
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@ use serde_json::{Value, json};
|
||||
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
|
||||
use super::protocol::DshService;
|
||||
use crate::tools::execution_envelope::enforce_tool_denial;
|
||||
use crate::tools::spec::{ToolContext, resolve_strict_authority_path};
|
||||
use crate::tools::spec::{
|
||||
ToolContext, has_forbidden_windows_colon, has_windows_drive_or_unc_prefix,
|
||||
is_protected_workspace_metadata_segment, resolve_strict_authority_path,
|
||||
strip_workspace_root_str, validate_windows_relative_segments,
|
||||
};
|
||||
|
||||
const MAX_PATH_BYTES: usize = 512;
|
||||
const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||||
const MAX_READ_LINES: u64 = 10_000;
|
||||
const MAX_ARGS: usize = 8;
|
||||
const MAX_ARG_BYTES: usize = 1024;
|
||||
const PROTECTED: &[&str] = &[".git", ".codewhale"];
|
||||
const LOG: &str = "codewhale::dsh_workspace";
|
||||
|
||||
static CONFINE_GATE: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(4);
|
||||
@@ -96,7 +99,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
"max_lines is between 1 and {MAX_READ_LINES}"
|
||||
)));
|
||||
}
|
||||
let tool = caller(&call, "read_file")?;
|
||||
let tool = caller(&call, "read_file", &json!({}))?;
|
||||
let (shown, resolved) = confine(&call, &tool, &path, Access::Read).await?;
|
||||
let mut input = json!({ "path": resolved });
|
||||
if let Some(start_line) = start_line {
|
||||
@@ -110,7 +113,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::List(ListInput { path }) => {
|
||||
let tool = caller(&call, "list_dir")?;
|
||||
let tool = caller(&call, "list_dir", &json!({}))?;
|
||||
let raw = path.as_deref().unwrap_or(".");
|
||||
let (shown, resolved) = confine(&call, &tool, raw, Access::Read).await?;
|
||||
nested(&call, "list_dir", json!({ "path": resolved }))
|
||||
@@ -137,7 +140,7 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, "workspace write refused: no reviewed overlay grant");
|
||||
return Err(refused("this plugin may not write to the workspace"));
|
||||
}
|
||||
let tool = caller(&call, "write_file")?;
|
||||
let tool = caller(&call, "write_file", &json!({}))?;
|
||||
let (shown, resolved) = confine(&call, &tool, &path, Access::Overlay).await?;
|
||||
let mut input = json!({ "path": resolved, "content": content });
|
||||
if let Some(expected_hash) = expected_hash {
|
||||
@@ -148,8 +151,9 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
.map(|response| file_outcome(&shown, response))
|
||||
}
|
||||
WorkspaceMethod::Exec(ExecInput { argv, cwd }) => {
|
||||
let query = literal_git(&argv)?;
|
||||
let tool = caller(&call, "Git")?;
|
||||
let mut input = literal_git(&argv)?;
|
||||
input["action"] = json!("review_scope");
|
||||
let tool = caller(&call, "Git", &input)?;
|
||||
if let Some(cwd) = cwd.as_deref() {
|
||||
let (shown, _) = confine(&call, &tool, cwd, Access::Read).await?;
|
||||
if shown != "." {
|
||||
@@ -157,8 +161,6 @@ pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
|
||||
return Err(refused("git runs only at the workspace root"));
|
||||
}
|
||||
}
|
||||
let mut input = query;
|
||||
input["action"] = json!("review_scope");
|
||||
nested(&call, "Git", input).await.map(exec_outcome)
|
||||
}
|
||||
}
|
||||
@@ -212,10 +214,14 @@ fn overlay_granted() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, BrokerRefusal> {
|
||||
fn caller(
|
||||
call: &BrokerCall,
|
||||
tool_name: &'static str,
|
||||
input: &Value,
|
||||
) -> Result<ToolContext, BrokerRefusal> {
|
||||
let mut tool = call.context.tool().clone();
|
||||
let _ = tool.refresh_live_posture();
|
||||
if enforce_tool_denial(&tool, tool_name, &json!({})).is_err() {
|
||||
if enforce_tool_denial(&tool, tool_name, input).is_err() {
|
||||
tracing::info!(target: LOG, plugin = %call.owner.plugin_id, tool = tool_name, "workspace refused: the caller may not use this tool");
|
||||
return Err(refused(
|
||||
"this workspace call is not available to this caller",
|
||||
@@ -224,23 +230,17 @@ fn caller(call: &BrokerCall, tool_name: &'static str) -> Result<ToolContext, Bro
|
||||
Ok(tool)
|
||||
}
|
||||
|
||||
fn relative(workspace: &Path, raw: &str) -> Option<String> {
|
||||
fn relative_for(workspace: &Path, canonical: &Path, raw: &str, windows: bool) -> Option<String> {
|
||||
let path = Path::new(raw);
|
||||
if !path.is_absolute() {
|
||||
let is_absolute = path.is_absolute()
|
||||
|| raw.starts_with('/')
|
||||
|| (windows && (raw.starts_with('\\') || has_windows_drive_or_unc_prefix(raw)));
|
||||
if !is_absolute {
|
||||
return Some(raw.to_string());
|
||||
}
|
||||
let canonical = workspace.canonicalize().ok();
|
||||
let rest = path.strip_prefix(workspace).ok().or_else(|| {
|
||||
canonical
|
||||
.as_deref()
|
||||
.and_then(|root| path.strip_prefix(root).ok())
|
||||
})?;
|
||||
let rest = rest.to_str()?;
|
||||
Some(if rest.is_empty() {
|
||||
".".into()
|
||||
} else {
|
||||
rest.into()
|
||||
})
|
||||
let ws_str = workspace.to_str()?;
|
||||
let canonical_str = canonical.to_str();
|
||||
strip_workspace_root_str(ws_str, canonical_str, raw, windows)
|
||||
}
|
||||
|
||||
fn shape(raw: &str) -> Result<(), BrokerRefusal> {
|
||||
@@ -256,18 +256,37 @@ fn shape(raw: &str) -> Result<(), BrokerRefusal> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn protected(relative: &str) -> bool {
|
||||
relative.replace('\\', "/").split('/').any(|segment| {
|
||||
PROTECTED
|
||||
.iter()
|
||||
.any(|name| segment.eq_ignore_ascii_case(name))
|
||||
})
|
||||
fn protected_for(relative: &str, windows: bool) -> bool {
|
||||
relative
|
||||
.replace('\\', "/")
|
||||
.split('/')
|
||||
.any(|segment| is_protected_workspace_metadata_segment(segment, windows))
|
||||
}
|
||||
|
||||
fn resolve(tool: &ToolContext, raw: &str, access: Access) -> Result<(String, PathBuf), String> {
|
||||
let relative = relative(&tool.workspace, raw).ok_or_else(|| "outside".to_string())?;
|
||||
let resolved = resolve_strict_authority_path(tool, &relative).map_err(|e| e.to_string())?;
|
||||
resolve_for(tool, raw, access, cfg!(windows))
|
||||
}
|
||||
|
||||
fn resolve_for(
|
||||
tool: &ToolContext,
|
||||
raw: &str,
|
||||
access: Access,
|
||||
windows: bool,
|
||||
) -> Result<(String, PathBuf), String> {
|
||||
if has_forbidden_windows_colon(raw, windows) {
|
||||
return Err("windows colons and alternate data streams are not allowed".to_string());
|
||||
}
|
||||
let root = tool.workspace.canonicalize().map_err(|e| e.to_string())?;
|
||||
let relative =
|
||||
relative_for(&tool.workspace, &root, raw, windows).ok_or_else(|| "outside".to_string())?;
|
||||
validate_windows_relative_segments(&relative, windows)?;
|
||||
let resolved = resolve_strict_authority_path(tool, &relative).map_err(|e| e.to_string())?;
|
||||
#[cfg(windows)]
|
||||
let resolved = if resolved.exists() {
|
||||
resolved.canonicalize().map_err(|e| e.to_string())?
|
||||
} else {
|
||||
resolved
|
||||
};
|
||||
let shown = resolved
|
||||
.strip_prefix(&root)
|
||||
.map_err(|_| "outside".to_string())?
|
||||
@@ -275,7 +294,8 @@ fn resolve(tool: &ToolContext, raw: &str, access: Access) -> Result<(String, Pat
|
||||
.ok_or_else(|| "not UTF-8".to_string())?
|
||||
.replace('\\', "/");
|
||||
let shown = if shown.is_empty() { ".".into() } else { shown };
|
||||
if access == Access::Overlay && (shown == "." || protected(&shown)) {
|
||||
validate_windows_relative_segments(&shown, windows)?;
|
||||
if access == Access::Overlay && (shown == "." || protected_for(&shown, windows)) {
|
||||
return Err(format!("overlay excludes {shown}"));
|
||||
}
|
||||
Ok((shown, resolved))
|
||||
@@ -685,6 +705,91 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_metadata_aliases_short_names_and_ads_are_refused_in_validation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/a.txt"), "a").unwrap();
|
||||
let tool = ToolContext::new(&root);
|
||||
|
||||
for alias in [
|
||||
"GIT~1",
|
||||
"GIT~1/hooks/pre-commit",
|
||||
"git~12/config",
|
||||
"sub/GIT~2/HEAD",
|
||||
"CODEWH~1",
|
||||
"CODEWH~1/config.json",
|
||||
"codew~1/auth.json",
|
||||
"code~1/sessions",
|
||||
"SUBDIR~1/file.txt",
|
||||
".git::$INDEX_ALLOCATION",
|
||||
".GIT::$INDEX_ALLOCATION",
|
||||
"sub/.git:stream",
|
||||
"sub/.codewhale:secret",
|
||||
"file.txt:Zone.Identifier",
|
||||
"C:rel.txt",
|
||||
".git.",
|
||||
".git /config",
|
||||
".codewhale.",
|
||||
"sub/.codewhale /x",
|
||||
] {
|
||||
assert!(
|
||||
resolve_for(&tool, alias, Access::Read, true).is_err(),
|
||||
"read must reject {alias}"
|
||||
);
|
||||
assert!(
|
||||
resolve_for(&tool, alias, Access::Overlay, true).is_err(),
|
||||
"overlay must reject {alias}"
|
||||
);
|
||||
}
|
||||
|
||||
for metadata in [
|
||||
"GIT~1/hooks/pre-commit",
|
||||
"git~1",
|
||||
"CODEWH~1/config.json",
|
||||
"codew~2",
|
||||
".git::$INDEX_ALLOCATION",
|
||||
"sub/.git:stream",
|
||||
"sub/.codewhale:secret",
|
||||
".git.",
|
||||
".git ",
|
||||
".codewhale.",
|
||||
".codewhale ",
|
||||
] {
|
||||
assert!(
|
||||
protected_for(metadata, true),
|
||||
"protected_for must catch {metadata}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
resolve_for(&tool, "src/a.txt", Access::Overlay, true)
|
||||
.unwrap()
|
||||
.0,
|
||||
"src/a.txt"
|
||||
);
|
||||
assert!(has_forbidden_windows_colon(".git::$INDEX_ALLOCATION", true));
|
||||
assert!(has_forbidden_windows_colon("sub/.git:stream", true));
|
||||
assert!(has_forbidden_windows_colon("C:rel.txt", true));
|
||||
assert!(!has_forbidden_windows_colon(r"C:\ws\src\a.txt", true));
|
||||
assert!(!has_forbidden_windows_colon(r"\\?\C:\ws\src\a.txt", true));
|
||||
|
||||
assert_eq!(
|
||||
strip_workspace_root_str(r"C:\ws", Some(r"\\?\C:\ws"), r"\\?\C:\ws\src\a.txt", true)
|
||||
.as_deref(),
|
||||
Some("src/a.txt")
|
||||
);
|
||||
assert_eq!(
|
||||
strip_workspace_root_str(r"C:\ws", None, r"c:/ws/src/a.txt", true).as_deref(),
|
||||
Some("src/a.txt")
|
||||
);
|
||||
assert!(
|
||||
has_forbidden_windows_colon(r"C:\ws\.git::$INDEX_ALLOCATION", true),
|
||||
"drive prefix must not exempt trailing colons"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_are_typed_projections() {
|
||||
let read = file_outcome(
|
||||
|
||||
@@ -126,12 +126,15 @@ pub mod dsh_broker;
|
||||
mod dsh_catalog;
|
||||
mod dsh_llm;
|
||||
mod dsh_network;
|
||||
mod dsh_plugins;
|
||||
mod dsh_remote;
|
||||
pub use dsh_remote::{RemoteClient, RemoteDenial, RemoteGrants, RemotePairing, remote_grants};
|
||||
pub(crate) use dsh_remote::end_remote_session;
|
||||
pub use dsh_remote::{RemoteClient, RemoteDenial, RemotePairing, RemoteSecret, RemoteState};
|
||||
mod dsh_settings;
|
||||
mod dsh_terminal;
|
||||
pub(crate) mod dsh_terminal;
|
||||
mod dsh_workspace;
|
||||
pub mod execution;
|
||||
pub use dsh_network::forget_plugin_credentials;
|
||||
pub use execution::StockOperation;
|
||||
pub mod avatars;
|
||||
mod hooks;
|
||||
@@ -771,6 +774,14 @@ impl ManagerShared {
|
||||
.record_dirty_teardown(Instant::now(), &self.options.supervision);
|
||||
}
|
||||
|
||||
fn revoke_owner_state(&self, plugin_id: &str) {
|
||||
self.core_calls.revoke_owner(plugin_id);
|
||||
self.execution_broker.revoke_owner(plugin_id);
|
||||
self.mcp_users
|
||||
.fetch_sub(self.mcp_broker.revoke_owner(plugin_id), Ordering::SeqCst);
|
||||
dsh_terminal::kill_plugin_sessions(plugin_id);
|
||||
}
|
||||
|
||||
fn diagnostic(&self, message: String) {
|
||||
self.record_diagnostic(None, message);
|
||||
}
|
||||
@@ -1201,14 +1212,8 @@ impl HostEvents for Events {
|
||||
host.revoke_calls_of(¶ms.owner.plugin_id);
|
||||
}
|
||||
drop(slot);
|
||||
shared.core_calls.revoke_owner(¶ms.owner.plugin_id);
|
||||
shared
|
||||
.execution_broker
|
||||
.revoke_owner(¶ms.owner.plugin_id);
|
||||
shared.mcp_users.fetch_sub(
|
||||
shared.mcp_broker.revoke_owner(¶ms.owner.plugin_id),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
shared.revoke_owner_state(¶ms.owner.plugin_id);
|
||||
dsh_remote::withdraw_owner(¶ms.owner);
|
||||
shared.plugin_diagnostic(
|
||||
¶ms.owner.plugin_id,
|
||||
format!(
|
||||
@@ -1933,6 +1938,11 @@ impl ExtensionHostManager {
|
||||
if tools.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
for gate in dsh_broker::BrokerActTool::all() {
|
||||
if tool_registry.get(gate.name()).is_none() {
|
||||
tool_registry.register(gate);
|
||||
}
|
||||
}
|
||||
let taken: HashSet<String> = tool_registry
|
||||
.names()
|
||||
.into_iter()
|
||||
@@ -1968,6 +1978,9 @@ impl ExtensionHostManager {
|
||||
desired.revision,
|
||||
)));
|
||||
}
|
||||
if !installed.is_empty() && tool_registry.get(dsh_network::SEND_TOOL).is_none() {
|
||||
tool_registry.register(Arc::new(dsh_network::SendConsent));
|
||||
}
|
||||
installed
|
||||
}
|
||||
|
||||
@@ -2198,12 +2211,8 @@ impl ExtensionHostManager {
|
||||
}
|
||||
let host = shared.ready_host(HostTier::Plugin).ok();
|
||||
for owner in revoked {
|
||||
shared.core_calls.revoke_owner(&owner.plugin_id);
|
||||
shared.execution_broker.revoke_owner(&owner.plugin_id);
|
||||
shared.mcp_users.fetch_sub(
|
||||
shared.mcp_broker.revoke_owner(&owner.plugin_id),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
shared.revoke_owner_state(&owner.plugin_id);
|
||||
dsh_remote::withdraw_owner(&owner);
|
||||
shared.plugin_diagnostic(
|
||||
&owner.plugin_id,
|
||||
format!("extension `{}` revoked", owner.plugin_id),
|
||||
@@ -2328,12 +2337,7 @@ impl ExtensionHostManager {
|
||||
.owner(owner_id)
|
||||
.is_some_and(|entry| entry.state != OwnerState::Active);
|
||||
if stale {
|
||||
self.shared.core_calls.revoke_owner(owner_id);
|
||||
self.shared.execution_broker.revoke_owner(owner_id);
|
||||
self.shared.mcp_users.fetch_sub(
|
||||
self.shared.mcp_broker.revoke_owner(owner_id),
|
||||
Ordering::SeqCst,
|
||||
);
|
||||
self.shared.revoke_owner_state(owner_id);
|
||||
self.shared
|
||||
.registry
|
||||
.lock()
|
||||
@@ -3509,6 +3513,7 @@ impl HostAttachment {
|
||||
}
|
||||
|
||||
pub fn set_identity(&self, session_id: Option<String>, agent_id: Option<String>) {
|
||||
let mut left = None;
|
||||
if let Some(state) = self
|
||||
.manager
|
||||
.shared
|
||||
@@ -3517,9 +3522,15 @@ impl HostAttachment {
|
||||
.expect("attachments lock")
|
||||
.get_mut(&self.id)
|
||||
{
|
||||
if state.session_id != session_id {
|
||||
left = state.session_id.take();
|
||||
}
|
||||
state.session_id = session_id;
|
||||
state.agent_id = agent_id;
|
||||
}
|
||||
if let Some(left) = left {
|
||||
dsh_remote::end_remote_session(&left);
|
||||
}
|
||||
}
|
||||
pub fn plugin_view(&self) -> Arc<PluginRegistry> {
|
||||
self.manager
|
||||
@@ -3629,6 +3640,9 @@ impl ManagerShared {
|
||||
})
|
||||
}
|
||||
pub(super) fn broker_services(&self, owner: &OwnerRef) -> Vec<protocol::DshService> {
|
||||
if let Some(granted) = dsh_broker::test_grants() {
|
||||
return granted;
|
||||
}
|
||||
dsh_broker::granted_services(&self.registry.lock().expect("registry lock"), owner)
|
||||
}
|
||||
|
||||
@@ -3637,6 +3651,13 @@ impl ManagerShared {
|
||||
owner: &OwnerRef,
|
||||
service: protocol::DshService,
|
||||
) -> bool {
|
||||
if let Some(granted) = dsh_broker::builtin_granted(
|
||||
&self.registry.lock().expect("registry lock"),
|
||||
owner,
|
||||
service,
|
||||
) {
|
||||
return granted;
|
||||
}
|
||||
if let Some(granted) = dsh_broker::test_grants() {
|
||||
return granted.contains(&service);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ pub const MAX_PLUGIN_CONFIG_BYTES: usize = 16 * 1024;
|
||||
/// Deepest nesting of tables and arrays accepted.
|
||||
const MAX_CONFIG_DEPTH: usize = 16;
|
||||
/// How the `toml` crate serializes a date-time (a one-key table).
|
||||
const TOML_DATETIME_KEY: &str = "$__toml_private_datetime";
|
||||
pub(super) const TOML_DATETIME_KEY: &str = "$__toml_private_datetime";
|
||||
|
||||
/// The config one activation is given, and its digest.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -89,7 +89,7 @@ fn has_datetime(value: &Value) -> bool {
|
||||
}
|
||||
|
||||
/// One plugin's `[plugins."<name>".config]`, checked.
|
||||
fn convert(name: &str, settings: &PluginSettings) -> Result<PluginConfig, String> {
|
||||
pub(super) fn convert(name: &str, settings: &PluginSettings) -> Result<PluginConfig, String> {
|
||||
let Some(table) = &settings.config else {
|
||||
return Ok(PluginConfig::empty());
|
||||
};
|
||||
|
||||
@@ -540,6 +540,7 @@ pub enum DshService {
|
||||
Catalog,
|
||||
Terminal,
|
||||
Remote,
|
||||
Plugins,
|
||||
}
|
||||
|
||||
impl DshService {
|
||||
@@ -553,6 +554,19 @@ impl DshService {
|
||||
Self::Remote,
|
||||
];
|
||||
|
||||
pub const BUILTIN_ONLY: [Self; 1] = [Self::Plugins];
|
||||
|
||||
pub const EVERY: [Self; 8] = [
|
||||
Self::Workspace,
|
||||
Self::Llm,
|
||||
Self::Network,
|
||||
Self::Settings,
|
||||
Self::Catalog,
|
||||
Self::Terminal,
|
||||
Self::Remote,
|
||||
Self::Plugins,
|
||||
];
|
||||
|
||||
#[must_use]
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -563,12 +577,20 @@ impl DshService {
|
||||
Self::Catalog => "catalog",
|
||||
Self::Terminal => "terminal",
|
||||
Self::Remote => "remote",
|
||||
Self::Plugins => "plugins",
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|service| service.name() == name)
|
||||
Self::EVERY
|
||||
.into_iter()
|
||||
.find(|service| service.name() == name)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn builtin_only(self) -> bool {
|
||||
Self::BUILTIN_ONLY.contains(&self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ fn render() -> String {
|
||||
let _ = writeln!(out, " {name}: {code},");
|
||||
}
|
||||
out.push_str("} as const\n\nexport type Direction = 'core_to_host' | 'host_to_core'\n");
|
||||
let services: Vec<String> = DshService::ALL
|
||||
let services: Vec<String> = DshService::EVERY
|
||||
.iter()
|
||||
.map(|service| quote(service.name()))
|
||||
.collect();
|
||||
@@ -676,7 +676,7 @@ fn dsh_broker_is_one_strict_envelope_and_names_only_the_closed_service_list() {
|
||||
unknown["params"]["service"] = json!("vault");
|
||||
assert!(parse_host_message(unknown, tier).is_ok());
|
||||
}
|
||||
let names: Vec<&str> = DshService::ALL
|
||||
let names: Vec<&str> = DshService::EVERY
|
||||
.iter()
|
||||
.map(|service| service.name())
|
||||
.collect();
|
||||
@@ -689,10 +689,17 @@ fn dsh_broker_is_one_strict_envelope_and_names_only_the_closed_service_list() {
|
||||
"settings",
|
||||
"catalog",
|
||||
"terminal",
|
||||
"remote"
|
||||
"remote",
|
||||
"plugins"
|
||||
]
|
||||
);
|
||||
for service in DshService::ALL {
|
||||
assert_eq!(DshService::BUILTIN_ONLY, [DshService::Plugins]);
|
||||
assert!(
|
||||
DshService::ALL
|
||||
.iter()
|
||||
.all(|service| !service.builtin_only())
|
||||
);
|
||||
for service in DshService::EVERY {
|
||||
assert_eq!(DshService::parse(service.name()), Some(service));
|
||||
}
|
||||
assert_eq!(DshService::parse("Workspace"), None);
|
||||
|
||||
@@ -51,6 +51,8 @@ const RESERVED_NAMES: &[&str] = &[
|
||||
"js_execution",
|
||||
"request_user_input",
|
||||
"multi_tool_use.parallel",
|
||||
"dsh_broker_act",
|
||||
"dsh_broker_reach",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@@ -226,6 +226,23 @@ fn map_call_error(tool: &str, error: HostCallError) -> ToolError {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn failed_with_acts(
|
||||
error: ToolError,
|
||||
receipts: Option<&Value>,
|
||||
) -> Result<ToolResultWire, ToolError> {
|
||||
let holds_acts = receipts.is_some_and(|receipts| receipts.get("broker_acts").is_some());
|
||||
if !holds_acts {
|
||||
return Err(error);
|
||||
}
|
||||
Ok(ToolResultWire {
|
||||
content: vec![ContentBlockWire::Text {
|
||||
text: error.to_string(),
|
||||
}],
|
||||
is_error: true,
|
||||
structured: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn wire_to_result(wire: ToolResultWire, origin: &str) -> ToolResult {
|
||||
let content = wire
|
||||
.content
|
||||
@@ -396,26 +413,44 @@ impl ToolSpec for HostToolSpec {
|
||||
});
|
||||
// The deadline stops while one of this call's `core/call`s waits on
|
||||
// the gate (an approval card), so a person's time is not the tool's.
|
||||
let value = host
|
||||
let outcome = host
|
||||
.call_with_clock(
|
||||
request,
|
||||
Some(registration.owner.plugin_id.clone()),
|
||||
invocation.as_ref().map(InvocationGuard::clock),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| map_call_error(®istration.name, error))?;
|
||||
self.check_caller(context)?;
|
||||
self.manager
|
||||
.live_host_for(registration)
|
||||
.await
|
||||
.map_err(ToolError::not_available)?;
|
||||
self.check_caller(context)?;
|
||||
let wire: ToolResultWire = serde_json::from_value(value).map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"extension tool `{}` returned a malformed result: {error}",
|
||||
registration.name
|
||||
))
|
||||
})?;
|
||||
.await;
|
||||
let receipts = invocation.as_ref().and_then(InvocationGuard::seal_receipts);
|
||||
let settled: Result<ToolResultWire, ToolError> = async {
|
||||
match outcome {
|
||||
Ok(value) => {
|
||||
self.check_caller(context)?;
|
||||
self.manager
|
||||
.live_host_for(registration)
|
||||
.await
|
||||
.map_err(ToolError::not_available)?;
|
||||
self.check_caller(context)?;
|
||||
serde_json::from_value(value).map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"extension tool `{}` returned a malformed result: {error}",
|
||||
registration.name
|
||||
))
|
||||
})
|
||||
}
|
||||
Err(error) => Err(map_call_error(®istration.name, error)),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
let wire = match settled {
|
||||
Ok(wire) => wire,
|
||||
Err(error) => {
|
||||
let wire = failed_with_acts(error, receipts.as_ref())?;
|
||||
match self.check_caller(context) {
|
||||
Ok(()) => wire,
|
||||
Err(stale) => failed_with_acts(stale, receipts.as_ref())?,
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut result = wire_to_result(wire, &self.origin());
|
||||
if let Some(Value::Object(metadata)) = result.metadata.as_mut() {
|
||||
metadata.insert(
|
||||
@@ -433,10 +468,9 @@ impl ToolSpec for HostToolSpec {
|
||||
);
|
||||
}
|
||||
// What the tool asked the core to run, so the persisted record shows it.
|
||||
if let (Some(receipts), Some(Value::Object(metadata))) = (
|
||||
invocation.as_ref().and_then(InvocationGuard::receipts),
|
||||
result.metadata.as_mut(),
|
||||
) {
|
||||
if let (Some(receipts), Some(Value::Object(metadata))) =
|
||||
(receipts, result.metadata.as_mut())
|
||||
{
|
||||
metadata.insert("core_calls".to_string(), receipts);
|
||||
}
|
||||
Ok(result)
|
||||
|
||||
@@ -3,46 +3,145 @@ use codewhale_config::ProjectHookAuthority;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ProjectFileKind {
|
||||
label: &'static str,
|
||||
file_name: &'static str,
|
||||
receipt_key: &'static str,
|
||||
review_hint: &'static str,
|
||||
}
|
||||
|
||||
pub(crate) const HOOKS_FILE: ProjectFileKind = ProjectFileKind {
|
||||
label: "hooks",
|
||||
file_name: "hooks.toml",
|
||||
receipt_key: codewhale_runtime::config::HOOKS_RECEIPT_KEY,
|
||||
review_hint: "/hooks review, then /hooks approve <digest>",
|
||||
};
|
||||
|
||||
pub(crate) const MCP_FILE: ProjectFileKind = ProjectFileKind {
|
||||
label: "MCP servers",
|
||||
file_name: "mcp.json",
|
||||
receipt_key: codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
review_hint: "/mcp review, then /mcp approve <digest>",
|
||||
};
|
||||
|
||||
pub struct ReviewedProjectFile {
|
||||
pub workspace: std::path::PathBuf,
|
||||
pub digest: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
/// Read once for both the digest and parsing. Repository symlinks and special
|
||||
/// files are not executable configuration, even if the workspace is trusted.
|
||||
pub fn review_project_hooks(workspace: &Path) -> Result<(ProjectHookAuthority, String), String> {
|
||||
pub(crate) fn review_project_file(
|
||||
workspace: &Path,
|
||||
kind: ProjectFileKind,
|
||||
) -> Result<ReviewedProjectFile, String> {
|
||||
let label = kind.label;
|
||||
let workspace = workspace
|
||||
.canonicalize()
|
||||
.map_err(|_| "Cannot resolve hooks workspace")?;
|
||||
.map_err(|_| format!("Cannot resolve {label} workspace"))?;
|
||||
let mut path = workspace.clone();
|
||||
for component in [".codewhale", "hooks.toml"] {
|
||||
for component in [".codewhale", kind.file_name] {
|
||||
path.push(component);
|
||||
let metadata =
|
||||
std::fs::symlink_metadata(&path).map_err(|_| "Cannot read project hooks path")?;
|
||||
let metadata = std::fs::symlink_metadata(&path)
|
||||
.map_err(|_| format!("Cannot read project {label} path"))?;
|
||||
if metadata.file_type().is_symlink() {
|
||||
return Err("Project hooks path must not contain symlinks".into());
|
||||
return Err(format!("Project {label} path must not contain symlinks"));
|
||||
}
|
||||
if (component == ".codewhale" && !metadata.is_dir())
|
||||
|| (component == "hooks.toml" && !metadata.is_file())
|
||||
|| (component == kind.file_name && !metadata.is_file())
|
||||
{
|
||||
return Err("Project hooks must be a regular file in .codewhale".into());
|
||||
return Err(format!(
|
||||
"Project {label} must be a regular file in .codewhale"
|
||||
));
|
||||
}
|
||||
}
|
||||
let contents = super::config::read_project_hooks_file(&path)
|
||||
.map_err(|_| "Cannot read project hooks file (maximum 1 MiB)")?;
|
||||
.map_err(|_| format!("Cannot read project {label} file (maximum 1 MiB)"))?;
|
||||
let digest = Sha256::digest(contents.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
Ok((ProjectHookAuthority { workspace, digest }, contents))
|
||||
Ok(ReviewedProjectFile {
|
||||
workspace,
|
||||
digest,
|
||||
contents,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn approved_project_file(
|
||||
workspace: &Path,
|
||||
kind: ProjectFileKind,
|
||||
) -> Result<ReviewedProjectFile, String> {
|
||||
let label = kind.label;
|
||||
if !codewhale_runtime::config::is_workspace_trusted(workspace) {
|
||||
return Err(format!(
|
||||
"Project {label} require workspace trust and separate approval"
|
||||
));
|
||||
}
|
||||
let reviewed = review_project_file(workspace, kind)?;
|
||||
if codewhale_runtime::config::project_receipt_for_workspace(
|
||||
&reviewed.workspace,
|
||||
kind.receipt_key,
|
||||
)
|
||||
.as_deref()
|
||||
!= Some(&reviewed.digest)
|
||||
{
|
||||
return Err(format!(
|
||||
"Project {label} are unapproved or changed; use {}",
|
||||
kind.review_hint
|
||||
));
|
||||
}
|
||||
Ok(reviewed)
|
||||
}
|
||||
|
||||
pub(crate) fn approve_project_file(
|
||||
workspace: &Path,
|
||||
kind: ProjectFileKind,
|
||||
reviewed_digest: &str,
|
||||
validate: impl FnOnce(&str) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
let label = kind.label;
|
||||
if !codewhale_runtime::config::is_workspace_trusted(workspace) {
|
||||
return Err(format!("Trust the workspace before approving its {label}"));
|
||||
}
|
||||
let reviewed = review_project_file(workspace, kind)?;
|
||||
if reviewed_digest != reviewed.digest {
|
||||
return Err(format!(
|
||||
"The {label} file does not match the reviewed digest; review it again"
|
||||
));
|
||||
}
|
||||
validate(&reviewed.contents)?;
|
||||
codewhale_runtime::config::save_workspace_project_receipt(
|
||||
&reviewed.workspace,
|
||||
kind.receipt_key,
|
||||
reviewed_digest,
|
||||
)
|
||||
.map_err(|_| format!("Could not save {label} approval in user config"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn review_project_hooks(workspace: &Path) -> Result<(ProjectHookAuthority, String), String> {
|
||||
let reviewed = review_project_file(workspace, HOOKS_FILE)?;
|
||||
Ok((
|
||||
ProjectHookAuthority {
|
||||
workspace: reviewed.workspace,
|
||||
digest: reviewed.digest,
|
||||
},
|
||||
reviewed.contents,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn approved_project_hooks(workspace: &Path) -> Result<(ProjectHookAuthority, String), String> {
|
||||
if !codewhale_runtime::config::is_workspace_trusted(workspace) {
|
||||
return Err("Project hooks require workspace trust and separate hook approval".into());
|
||||
}
|
||||
let (authority, contents) = review_project_hooks(workspace)?;
|
||||
if codewhale_runtime::config::hook_receipt_for_workspace(&authority.workspace).as_deref()
|
||||
!= Some(&authority.digest)
|
||||
{
|
||||
return Err("Project hooks are unapproved or changed; use /hooks review, then /hooks approve <digest>".into());
|
||||
}
|
||||
Ok((authority, contents))
|
||||
let reviewed = approved_project_file(workspace, HOOKS_FILE)?;
|
||||
Ok((
|
||||
ProjectHookAuthority {
|
||||
workspace: reviewed.workspace,
|
||||
digest: reviewed.digest,
|
||||
},
|
||||
reviewed.contents,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn verify_hook_authorities(
|
||||
@@ -65,21 +164,14 @@ pub fn verify_hook_authorities(
|
||||
}
|
||||
|
||||
pub fn approve_project_hooks(workspace: &Path, reviewed_digest: &str) -> Result<(), String> {
|
||||
if !codewhale_runtime::config::is_workspace_trusted(workspace) {
|
||||
return Err("Trust the workspace before approving its hooks".into());
|
||||
}
|
||||
let (authority, contents) = review_project_hooks(workspace)?;
|
||||
if reviewed_digest != authority.digest {
|
||||
return Err("Hooks do not match the reviewed digest; use /hooks review again".into());
|
||||
}
|
||||
let parsed = toml::from_str::<codewhale_config::hooks::HooksConfig>(&contents)
|
||||
.map_err(|_| "Invalid hooks TOML")?;
|
||||
if parsed.validate().iter().any(|problem| problem.rejected) {
|
||||
return Err("Hooks contain invalid entries; correct them before approval".into());
|
||||
}
|
||||
codewhale_runtime::config::save_workspace_hook_receipt(&authority.workspace, reviewed_digest)
|
||||
.map_err(|_| "Could not save hook approval in user config".to_string())?;
|
||||
Ok(())
|
||||
approve_project_file(workspace, HOOKS_FILE, reviewed_digest, |contents| {
|
||||
let parsed = toml::from_str::<codewhale_config::hooks::HooksConfig>(contents)
|
||||
.map_err(|_| "Invalid hooks TOML")?;
|
||||
if parsed.validate().iter().any(|problem| problem.rejected) {
|
||||
return Err("Hooks contain invalid entries; correct them before approval".into());
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The Native floor is sufficient for Native-authored shell contributions; it is not a legacy Hooks grant.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
pub mod rate_limit;
|
||||
|
||||
use anyhow::Result;
|
||||
use codewhale_command_contract::metrics::format_duration;
|
||||
use codewhale_models::{MessageRequest, MessageResponse, StreamEvent};
|
||||
use codewhale_runtime::config::{RateLimitRetryPolicy, RetryPolicy};
|
||||
use serde_json::Value;
|
||||
@@ -457,6 +458,18 @@ pub enum LlmError {
|
||||
Other(String),
|
||||
}
|
||||
|
||||
fn opencode_go_hint(message: &str) -> &'static str {
|
||||
let lowered = message.to_lowercase();
|
||||
if lowered.contains("codewhale auth set") {
|
||||
return "";
|
||||
}
|
||||
if lowered.contains("opencode go") || lowered.contains("opencode.ai/go") {
|
||||
" OpenCode Go is a separate provider with its own key; run: codewhale auth set --provider opencode-go"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LlmError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
@@ -464,11 +477,25 @@ impl std::fmt::Display for LlmError {
|
||||
message,
|
||||
retry_after,
|
||||
..
|
||||
} => write!(
|
||||
} => {
|
||||
let hint = opencode_go_hint(message);
|
||||
match retry_after {
|
||||
Some(d) => write!(
|
||||
f,
|
||||
"Rate limit exceeded: {message}; quota window resets in {}; automatic retry stopped{hint}",
|
||||
format_duration(*d)
|
||||
),
|
||||
None => write!(
|
||||
f,
|
||||
"Rate limit exceeded: {message}; automatic retry stopped{hint}"
|
||||
),
|
||||
}
|
||||
}
|
||||
LlmError::RateLimited { message, .. } => write!(
|
||||
f,
|
||||
"Rate limit exceeded: {message}; quota window reset in {retry_after:?}; automatic retry stopped"
|
||||
"Rate limit exceeded: {message}{}",
|
||||
opencode_go_hint(message)
|
||||
),
|
||||
LlmError::RateLimited { message, .. } => write!(f, "Rate limit exceeded: {message}"),
|
||||
LlmError::QuotaExhausted(error) => {
|
||||
write!(f, "Provider plan quota exhausted: {}", error.message)
|
||||
}
|
||||
|
||||
@@ -119,6 +119,76 @@ fn retryability_distinguishes_transient_failures_from_durable_failures() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quota_window_display_prints_reset_time_as_plain_text() {
|
||||
let windowed = LlmError::QuotaWindow {
|
||||
message: "Free usage limit reached".into(),
|
||||
retry_after: Some(Duration::from_secs(70168)),
|
||||
transmission: RequestTransmission::Rejected,
|
||||
}
|
||||
.to_string();
|
||||
assert!(windowed.contains("resets in 19h29m"), "{windowed}");
|
||||
assert!(windowed.contains("automatic retry stopped"), "{windowed}");
|
||||
|
||||
let open_ended = LlmError::QuotaWindow {
|
||||
message: "Free usage limit reached".into(),
|
||||
retry_after: None,
|
||||
transmission: RequestTransmission::Rejected,
|
||||
}
|
||||
.to_string();
|
||||
assert!(!open_ended.contains("reset"), "{open_ended}");
|
||||
assert!(
|
||||
open_ended.contains("automatic retry stopped"),
|
||||
"{open_ended}"
|
||||
);
|
||||
|
||||
for text in [&windowed, &open_ended] {
|
||||
assert!(!text.contains("Some("), "{text}");
|
||||
assert!(!text.contains("None"), "{text}");
|
||||
}
|
||||
|
||||
let hinted = LlmError::QuotaWindow {
|
||||
message: "OpenCode Go free usage limit reached".into(),
|
||||
retry_after: Some(Duration::from_secs(70168)),
|
||||
transmission: RequestTransmission::Rejected,
|
||||
}
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
hinted
|
||||
.matches("codewhale auth set --provider opencode-go")
|
||||
.count(),
|
||||
1,
|
||||
"{hinted}"
|
||||
);
|
||||
|
||||
let rate_limited = LlmError::RateLimited {
|
||||
message: "OpenCode Go free usage limit reached".into(),
|
||||
retry_after: None,
|
||||
}
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
rate_limited
|
||||
.matches("codewhale auth set --provider opencode-go")
|
||||
.count(),
|
||||
1,
|
||||
"{rate_limited}"
|
||||
);
|
||||
|
||||
let already_hinted = LlmError::QuotaWindow {
|
||||
message: hinted.clone(),
|
||||
retry_after: Some(Duration::from_secs(70168)),
|
||||
transmission: RequestTransmission::Rejected,
|
||||
}
|
||||
.to_string();
|
||||
assert_eq!(
|
||||
already_hinted
|
||||
.matches("codewhale auth set --provider opencode-go")
|
||||
.count(),
|
||||
1,
|
||||
"{already_hinted}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_response_boundary_classifies_status_contract() {
|
||||
for status in [408, 409, 499, 500, 502, 503, 504] {
|
||||
|
||||
+22
-18
@@ -32,6 +32,7 @@ mod http;
|
||||
pub mod http_client;
|
||||
pub mod oauth;
|
||||
pub mod process_broker;
|
||||
pub mod project_authority;
|
||||
pub mod sse;
|
||||
mod stdio;
|
||||
mod streamable_http;
|
||||
@@ -7440,7 +7441,7 @@ pub enum McpServerScope {
|
||||
/// The user's own config file, shared by every workspace.
|
||||
Global,
|
||||
/// `<workspace>/.codewhale/mcp.json`, honoured only once the workspace
|
||||
/// is trusted in user-owned config (#417).
|
||||
/// is trusted in user-owned config (#417) and its exact contents are approved.
|
||||
Project(PathBuf),
|
||||
/// Contributed by an installed plugin bundle. It lives in no config
|
||||
/// file, so it is switched off by disabling the plugin that owns it.
|
||||
@@ -7475,16 +7476,15 @@ pub fn project_server_names(global_path: &Path, workspace: &Path) -> BTreeSet<St
|
||||
let Ok(workspace) = checked_workspace_path(workspace) else {
|
||||
return BTreeSet::new();
|
||||
};
|
||||
if !workspace_allows_project_mcp_config(&workspace) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
let Ok(project_path) = checked_workspace_mcp_config_path(&workspace) else {
|
||||
return BTreeSet::new();
|
||||
};
|
||||
if !project_path.exists() || paths_refer_to_same_config(global_path, &project_path) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
load_config(&project_path)
|
||||
project_authority::approved_project_mcp(&workspace)
|
||||
.ok()
|
||||
.and_then(|reviewed| serde_json::from_str::<McpConfig>(&reviewed.contents).ok())
|
||||
.map(|config| config.servers.into_keys().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
@@ -7492,11 +7492,13 @@ pub fn project_server_names(global_path: &Path, workspace: &Path) -> BTreeSet<St
|
||||
#[must_use]
|
||||
pub fn resolve_server_scope(global_path: &Path, workspace: &Path, name: &str) -> McpServerScope {
|
||||
if let Ok(workspace) = checked_workspace_path(workspace)
|
||||
&& workspace_allows_project_mcp_config(&workspace)
|
||||
&& let Ok(project_path) = checked_workspace_mcp_config_path(&workspace)
|
||||
&& project_path.exists()
|
||||
&& !paths_refer_to_same_config(global_path, &project_path)
|
||||
&& load_config(&project_path).is_ok_and(|config| config.servers.contains_key(name))
|
||||
&& project_authority::approved_project_mcp(&workspace)
|
||||
.ok()
|
||||
.and_then(|reviewed| serde_json::from_str::<McpConfig>(&reviewed.contents).ok())
|
||||
.is_some_and(|config| config.servers.contains_key(name))
|
||||
{
|
||||
return McpServerScope::Project(project_path);
|
||||
}
|
||||
@@ -7731,15 +7733,20 @@ pub fn load_config_with_workspace_and_plugins(
|
||||
return merge_plugin_mcp_servers(merged, plugins);
|
||||
}
|
||||
// Workspace-local MCP can spawn stdio servers, so it is only honored after
|
||||
// the user has trusted this workspace in user-owned config. Do not accept
|
||||
// project-local legacy trust markers here: a repository could carry those
|
||||
// files itself and silently reintroduce the project-scope `mcp_config_path`
|
||||
// risk denied in #417.
|
||||
if !workspace_allows_project_mcp_config(&workspace) {
|
||||
// the user has trusted this workspace and approved the exact file contents
|
||||
// in user-owned config. Do not accept project-local legacy trust markers
|
||||
// here: a repository could carry those files itself and silently
|
||||
// reintroduce the project-scope `mcp_config_path` risk denied in #417.
|
||||
let Ok(reviewed) = project_authority::approved_project_mcp(&workspace) else {
|
||||
return merge_plugin_mcp_servers(merged, plugins);
|
||||
}
|
||||
};
|
||||
|
||||
let mut project = load_config(&project_path)?;
|
||||
let mut project: McpConfig = serde_json::from_str(&reviewed.contents).map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to parse MCP config {}; file contents were omitted",
|
||||
codewhale_config::quote_os_path(&project_path)
|
||||
)
|
||||
})?;
|
||||
for server in project.servers.values_mut() {
|
||||
server.always_allow_tools.clear();
|
||||
if server.command.is_some() && server.url.is_none() {
|
||||
@@ -7983,10 +7990,6 @@ fn resolve_plugin_mcp_cwd(plugin_path: &Path, cwd: Option<&Path>) -> Result<Path
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn workspace_allows_project_mcp_config(workspace: &Path) -> bool {
|
||||
codewhale_runtime::config::is_workspace_trusted(workspace)
|
||||
}
|
||||
|
||||
fn checked_workspace_mcp_config_path(workspace: &Path) -> Result<PathBuf> {
|
||||
Ok(checked_workspace_path(workspace)?
|
||||
.join(".codewhale")
|
||||
@@ -8308,6 +8311,7 @@ pub fn mutate_config<T>(
|
||||
}
|
||||
write_atomic(path, rendered.as_bytes())
|
||||
.with_context(|| format!("Failed to write MCP config {}", path.display()))?;
|
||||
project_authority::carry_approval_across_product_edit(path, original.as_deref(), &rendered);
|
||||
Ok((result, config_revision(Some(&rendered))))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
use crate::hooks::authority::{
|
||||
MCP_FILE, ReviewedProjectFile, approve_project_file, approved_project_file, review_project_file,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProjectMcpStatus {
|
||||
Absent,
|
||||
Untrusted,
|
||||
Unreadable(String),
|
||||
Unapproved { digest: String },
|
||||
Approved { digest: String },
|
||||
}
|
||||
|
||||
pub fn review_project_mcp(workspace: &Path) -> Result<ReviewedProjectFile, String> {
|
||||
review_project_file(workspace, MCP_FILE)
|
||||
}
|
||||
|
||||
pub fn approved_project_mcp(workspace: &Path) -> Result<ReviewedProjectFile, String> {
|
||||
approved_project_file(workspace, MCP_FILE)
|
||||
}
|
||||
|
||||
pub fn approve_project_mcp(workspace: &Path, reviewed_digest: &str) -> Result<(), String> {
|
||||
approve_project_file(workspace, MCP_FILE, reviewed_digest, |contents| {
|
||||
let workspace =
|
||||
super::checked_workspace_path(workspace).map_err(|error| error.to_string())?;
|
||||
let config: codewhale_mcp::config::McpConfig = serde_json::from_str(contents)
|
||||
.map_err(|_| "Invalid MCP JSON; correct it before approval".to_string())?;
|
||||
for server in config.servers.values() {
|
||||
if server.command.is_some() && server.url.is_none() {
|
||||
super::resolve_project_mcp_cwd(&workspace, server.cwd.as_deref())
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn revoke_project_mcp(workspace: &Path) -> Result<(), String> {
|
||||
codewhale_runtime::config::save_workspace_project_receipt(
|
||||
workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
"",
|
||||
)
|
||||
.map(|_| ())
|
||||
.map_err(|_| "Could not revoke project MCP approval".to_string())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_mcp_status(workspace: &Path) -> ProjectMcpStatus {
|
||||
let Ok(workspace) = super::checked_workspace_path(workspace) else {
|
||||
return ProjectMcpStatus::Absent;
|
||||
};
|
||||
let path = workspace.join(".codewhale").join("mcp.json");
|
||||
if path
|
||||
.symlink_metadata()
|
||||
.is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound)
|
||||
{
|
||||
return ProjectMcpStatus::Absent;
|
||||
}
|
||||
if !codewhale_runtime::config::is_workspace_trusted(&workspace) {
|
||||
return ProjectMcpStatus::Untrusted;
|
||||
}
|
||||
match review_project_mcp(&workspace) {
|
||||
Err(error) => ProjectMcpStatus::Unreadable(error),
|
||||
Ok(reviewed) => {
|
||||
let approved = codewhale_runtime::config::project_receipt_for_workspace(
|
||||
&reviewed.workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
)
|
||||
.as_deref()
|
||||
== Some(&reviewed.digest);
|
||||
if approved {
|
||||
ProjectMcpStatus::Approved {
|
||||
digest: reviewed.digest,
|
||||
}
|
||||
} else {
|
||||
ProjectMcpStatus::Unapproved {
|
||||
digest: reviewed.digest,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn carry_approval_across_product_edit(
|
||||
path: &Path,
|
||||
original: Option<&str>,
|
||||
written: &str,
|
||||
) {
|
||||
let Some(original) = original else { return };
|
||||
if path.file_name() != Some(std::ffi::OsStr::new("mcp.json")) {
|
||||
return;
|
||||
}
|
||||
let Some(dir) = path.parent() else { return };
|
||||
if dir.file_name() != Some(std::ffi::OsStr::new(".codewhale")) {
|
||||
return;
|
||||
}
|
||||
let Some(workspace) = dir.parent().map(super::normalize_workspace_path) else {
|
||||
return;
|
||||
};
|
||||
let digest = |text: &str| -> String {
|
||||
Sha256::digest(text.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
};
|
||||
let receipt = codewhale_runtime::config::project_receipt_for_workspace(
|
||||
&workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
);
|
||||
if receipt.as_deref() != Some(digest(original).as_str())
|
||||
|| !codewhale_runtime::config::is_workspace_trusted(&workspace)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if codewhale_runtime::config::save_workspace_project_receipt(
|
||||
&workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
&digest(written),
|
||||
)
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(target: "mcp", "Could not carry project MCP approval across an edit");
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,21 @@ fn mark_workspace_trusted(workspace: &Path) -> WorkspaceTrustConfigGuard {
|
||||
guard
|
||||
}
|
||||
|
||||
fn approve_project_mcp_for_test(workspace: &Path) {
|
||||
let reviewed = project_authority::review_project_mcp(workspace).unwrap();
|
||||
project_authority::approve_project_mcp(workspace, &reviewed.digest).unwrap();
|
||||
}
|
||||
|
||||
fn record_project_mcp_receipt_for_test(workspace: &Path) {
|
||||
let reviewed = project_authority::review_project_mcp(workspace).unwrap();
|
||||
codewhale_runtime::config::save_workspace_project_receipt(
|
||||
&reviewed.workspace,
|
||||
codewhale_runtime::config::MCP_RECEIPT_KEY,
|
||||
&reviewed.digest,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_config_defaults() {
|
||||
let config = McpConfig::default();
|
||||
@@ -882,6 +897,7 @@ fn workspace_mcp_config_merges_with_project_overrides() {
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
let workspace = workspace.canonicalize().unwrap();
|
||||
@@ -922,6 +938,7 @@ fn workspace_manager_snapshot_counts_global_and_project_servers() {
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let plain = manager_snapshot_from_config(&global_path, false).unwrap();
|
||||
let merged =
|
||||
@@ -2377,6 +2394,220 @@ fn workspace_mcp_config_ignores_project_file_until_workspace_trusted() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_mcp_config_does_not_load_project_file_on_trust_alone() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let project_dir = workspace.join(".codewhale");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(
|
||||
&global_path,
|
||||
r#"{"servers": {"global": {"command": "node", "args": ["global.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
project_dir.join("mcp.json"),
|
||||
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
|
||||
assert!(cfg.servers.contains_key("global"));
|
||||
assert!(!cfg.servers.contains_key("project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_mcp_approval_binds_the_exact_file_contents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let project_dir = workspace.join(".codewhale");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
|
||||
let project_path = project_dir.join("mcp.json");
|
||||
let reviewed_bytes = r#"{"servers": {"project": {"command": "node", "args": ["a.js"]}}}"#;
|
||||
fs::write(&project_path, reviewed_bytes).unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
project_authority::project_mcp_status(&workspace),
|
||||
project_authority::ProjectMcpStatus::Unapproved { .. }
|
||||
));
|
||||
assert!(project_server_names(&global_path, &workspace).is_empty());
|
||||
assert!(matches!(
|
||||
resolve_server_scope(&global_path, &workspace, "project"),
|
||||
McpServerScope::Plugin
|
||||
));
|
||||
assert!(
|
||||
project_authority::approve_project_mcp(&workspace, "0".repeat(64).as_str()).is_err(),
|
||||
"a digest that does not match the file is refused"
|
||||
);
|
||||
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(cfg.servers.contains_key("project"));
|
||||
assert!(project_server_names(&global_path, &workspace).contains("project"));
|
||||
assert!(matches!(
|
||||
resolve_server_scope(&global_path, &workspace, "project"),
|
||||
McpServerScope::Project(_)
|
||||
));
|
||||
|
||||
fs::write(
|
||||
&project_path,
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["b.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(
|
||||
!cfg.servers.contains_key("project"),
|
||||
"a changed project file loads nothing until it is approved again"
|
||||
);
|
||||
assert!(project_server_names(&global_path, &workspace).is_empty());
|
||||
|
||||
fs::write(&project_path, reviewed_bytes).unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(
|
||||
cfg.servers.contains_key("project"),
|
||||
"the approval names bytes, not a moment in time"
|
||||
);
|
||||
|
||||
project_authority::revoke_project_mcp(&workspace).unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(!cfg.servers.contains_key("project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_mcp_approval_cannot_come_from_the_repository_or_another_workspace() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let other = dir.path().join("other");
|
||||
fs::create_dir_all(workspace.join(".codewhale")).unwrap();
|
||||
fs::create_dir_all(other.join(".codewhale")).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
|
||||
let body = r#"{"servers": {"project": {"command": "node", "args": ["a.js"]}}}"#;
|
||||
fs::write(workspace.join(".codewhale").join("mcp.json"), body).unwrap();
|
||||
fs::write(other.join(".codewhale").join("mcp.json"), body).unwrap();
|
||||
|
||||
let reviewed = project_authority::review_project_mcp(&workspace).unwrap();
|
||||
let key = workspace.canonicalize().unwrap();
|
||||
let key = key.to_string_lossy().replace('\\', "\\\\");
|
||||
fs::write(
|
||||
workspace.join(".codewhale").join("config.toml"),
|
||||
format!(
|
||||
"mcp_sha256 = \"{d}\"\n[projects.\"{key}\"]\ntrust_level = \"trusted\"\nmcp_sha256 = \"{d}\"\n",
|
||||
d = reviewed.digest
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(!cfg.servers.contains_key("project"));
|
||||
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
let cfg = load_config_with_workspace(&global_path, &other).unwrap();
|
||||
assert!(
|
||||
!cfg.servers.contains_key("project"),
|
||||
"an approval is bound to one workspace"
|
||||
);
|
||||
assert!(
|
||||
project_authority::approve_project_mcp(&other, &reviewed.digest).is_err(),
|
||||
"an untrusted workspace cannot be approved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_mcp_approval_refuses_invalid_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let workspace = dir.path().join("workspace");
|
||||
fs::create_dir_all(workspace.join(".codewhale")).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
let path = workspace.join(".codewhale").join("mcp.json");
|
||||
|
||||
fs::write(&path, "{ not json").unwrap();
|
||||
let reviewed = project_authority::review_project_mcp(&workspace).unwrap();
|
||||
assert!(project_authority::approve_project_mcp(&workspace, &reviewed.digest).is_err());
|
||||
|
||||
fs::write(
|
||||
&path,
|
||||
r#"{"servers": {"project": {"command": "node", "cwd": "../outside"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let reviewed = project_authority::review_project_mcp(&workspace).unwrap();
|
||||
let error = project_authority::approve_project_mcp(&workspace, &reviewed.digest).unwrap_err();
|
||||
assert!(error.contains("must stay within workspace"), "{error}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn project_mcp_approval_refuses_a_symlinked_project_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let project_dir = workspace.join(".codewhale");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
|
||||
fs::write(
|
||||
project_dir.join("mcp.json"),
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["a.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
assert!(
|
||||
load_config_with_workspace(&global_path, &workspace)
|
||||
.unwrap()
|
||||
.servers
|
||||
.contains_key("project")
|
||||
);
|
||||
|
||||
let moved = dir.path().join("moved");
|
||||
fs::rename(&project_dir, &moved).unwrap();
|
||||
std::os::unix::fs::symlink(&moved, &project_dir).unwrap();
|
||||
|
||||
assert!(project_authority::review_project_mcp(&workspace).is_err());
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(!cfg.servers.contains_key("project"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn product_edits_of_an_approved_project_file_keep_its_approval() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let global_path = dir.path().join("global-mcp.json");
|
||||
let workspace = dir.path().join("workspace");
|
||||
let project_dir = workspace.join(".codewhale");
|
||||
fs::create_dir_all(&project_dir).unwrap();
|
||||
let _trust = mark_workspace_trusted(&workspace);
|
||||
fs::write(&global_path, r#"{"servers": {}}"#).unwrap();
|
||||
let project_path = project_dir.join("mcp.json");
|
||||
fs::write(
|
||||
&project_path,
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["a.js"]}, "second": {"command": "node", "args": ["b.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
set_server_enabled(&project_path, "project", false).unwrap();
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(!cfg.servers["project"].is_enabled());
|
||||
assert!(cfg.servers.contains_key("second"));
|
||||
|
||||
fs::write(
|
||||
&project_path,
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["evil.js"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = set_server_enabled(&project_path, "project", true);
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
assert!(
|
||||
!cfg.servers.contains_key("project"),
|
||||
"an edit on top of unreviewed bytes does not carry the approval"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_mcp_config_ignores_project_local_legacy_trust_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -2457,6 +2688,7 @@ fn workspace_mcp_config_resolves_relative_cwd_from_workspace() {
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "tools/mcp"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let cfg = load_config_with_workspace(&global_path, &workspace).unwrap();
|
||||
let workspace = workspace.canonicalize().unwrap();
|
||||
@@ -2482,6 +2714,7 @@ fn workspace_mcp_config_rejects_project_cwd_escape() {
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "../outside"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
record_project_mcp_receipt_for_test(&workspace);
|
||||
|
||||
let err = load_config_with_workspace(&global_path, &workspace)
|
||||
.expect_err("project MCP cwd escape must be rejected");
|
||||
@@ -2511,6 +2744,7 @@ fn workspace_mcp_config_rejects_symlinked_project_cwd_escape() {
|
||||
r#"{"servers": {"project": {"command": "node", "args": ["server.js"], "cwd": "tools"}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
record_project_mcp_receipt_for_test(&workspace);
|
||||
|
||||
let err = load_config_with_workspace(&global_path, &workspace)
|
||||
.expect_err("project MCP symlink cwd escape must be rejected");
|
||||
@@ -2564,6 +2798,7 @@ async fn workspace_mcp_pool_reload_picks_up_project_config_creation() {
|
||||
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
assert!(pool.reload_if_config_changed().await.unwrap());
|
||||
let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
|
||||
@@ -2599,6 +2834,7 @@ async fn workspace_mcp_pool_reload_picks_up_project_config_after_workspace_trust
|
||||
assert_eq!(pool.server_names(), vec!["global".to_string()]);
|
||||
|
||||
write_workspace_trust_config(&trust_env.config_path, &workspace);
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
assert!(pool.reload_if_config_changed().await.unwrap());
|
||||
let names: std::collections::BTreeSet<String> = pool.server_names().into_iter().collect();
|
||||
@@ -2627,6 +2863,7 @@ async fn workspace_mcp_pool_reload_drops_project_config_after_workspace_trust_re
|
||||
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace)
|
||||
.unwrap()
|
||||
@@ -2663,6 +2900,7 @@ async fn workspace_mcp_pool_reload_drops_project_config_after_deletion() {
|
||||
r#"{"servers": {"project": {"command": "php", "args": ["artisan", "boost:mcp"]}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
approve_project_mcp_for_test(&workspace);
|
||||
|
||||
let mut pool = McpPool::from_config_path_with_workspace(&global_path, &workspace)
|
||||
.unwrap()
|
||||
|
||||
@@ -1300,8 +1300,11 @@ pub fn upsert_keepalive(
|
||||
next_run_at: None,
|
||||
last_run_at: None,
|
||||
revision: 0,
|
||||
disallowed_tools: Vec::new(),
|
||||
});
|
||||
record.schema_version = crate::automation_manager::CURRENT_AUTOMATION_SCHEMA_VERSION;
|
||||
record.schema_version = record.schema_version.max(
|
||||
crate::automation_manager::required_automation_schema_version(&record.disallowed_tools),
|
||||
);
|
||||
if record.execution_scope.is_none() {
|
||||
record.execution_scope = Some(scope.to_string());
|
||||
}
|
||||
|
||||
@@ -244,22 +244,38 @@ pub async fn capture(
|
||||
if let Some(snapshot) = supplied {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
// Unit tests never read an operator's account credentials. Transport tests
|
||||
// exercise load_from with an explicitly isolated store instead.
|
||||
#[cfg(not(any(test, feature = "test-support")))]
|
||||
if let Some(snapshot) = load(profile).await? {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
{
|
||||
let _ = profile;
|
||||
if let Some(message) = TEST_ACCOUNT_LOAD_FAILURE.get() {
|
||||
anyhow::bail!(message);
|
||||
}
|
||||
if !account_api_base_is_loopback() {
|
||||
return Ok(crate::prompts::load_user_constitution_block());
|
||||
}
|
||||
}
|
||||
if let Some(snapshot) = load(profile).await? {
|
||||
return snapshot.render().map(Some);
|
||||
}
|
||||
Ok(crate::prompts::load_user_constitution_block())
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
fn account_api_base_is_loopback() -> bool {
|
||||
std::env::var(codewhale_secrets::account::ACCOUNT_API_BASE_ENV)
|
||||
.ok()
|
||||
.and_then(|value| normalize_runtime_account_api_base(&value))
|
||||
.and_then(|base| reqwest::Url::parse(&base).ok())
|
||||
.and_then(|url| url.host_str().map(str::to_owned))
|
||||
.is_some_and(|host| {
|
||||
host.eq_ignore_ascii_case("localhost")
|
||||
|| host
|
||||
.trim_start_matches('[')
|
||||
.trim_end_matches(']')
|
||||
.parse::<std::net::IpAddr>()
|
||||
.is_ok_and(|address| address.is_loopback())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
thread_local! {
|
||||
static TEST_ACCOUNT_LOAD_FAILURE: std::cell::Cell<Option<&'static str>> =
|
||||
|
||||
@@ -544,7 +544,7 @@ pub enum ResolvedProviderReadiness {
|
||||
impl ResolvedProviderReadiness {
|
||||
pub fn label(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
Self::MissingKey => Cow::Borrowed("missing key"),
|
||||
Self::MissingKey => Cow::Borrowed("needs key"),
|
||||
Self::MissingLogin => Cow::Borrowed("missing login"),
|
||||
Self::ExternalConsentPendingSelection => {
|
||||
Cow::Borrowed("external consent · select to check")
|
||||
|
||||
@@ -227,7 +227,16 @@ const IMAGE_RUNTIME_SCHEMA_VERSION: u32 = 3;
|
||||
const OUTPUT_LIMIT_RUNTIME_SCHEMA_VERSION: u32 = 4;
|
||||
const PREFIX_CHECKPOINT_RUNTIME_SCHEMA_VERSION: u32 = 5;
|
||||
const SOURCE_ACCOUNTING_RUNTIME_SCHEMA_VERSION: u32 = 6;
|
||||
const MAX_SUPPORTED_RUNTIME_SCHEMA_VERSION: u32 = SOURCE_ACCOUNTING_RUNTIME_SCHEMA_VERSION;
|
||||
const DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION: u32 = 7;
|
||||
const MAX_SUPPORTED_RUNTIME_SCHEMA_VERSION: u32 = DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION;
|
||||
|
||||
pub(crate) const fn required_thread_schema_version(disallowed_tools: &[String]) -> u32 {
|
||||
if disallowed_tools.is_empty() {
|
||||
CURRENT_RUNTIME_SCHEMA_VERSION
|
||||
} else {
|
||||
DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
fn is_zero_u64(value: &u64) -> bool {
|
||||
*value == 0
|
||||
@@ -386,6 +395,8 @@ pub struct ThreadRecord {
|
||||
/// normal configured tool catalog; `Some([])` deliberately exposes none.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
/// Extra roots the file tools may read and write outside the workspace.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub add_dirs: Vec<PathBuf>,
|
||||
@@ -2526,6 +2537,19 @@ impl RuntimeThreadStore {
|
||||
|
||||
pub fn save_thread(&self, thread: &ThreadRecord) -> Result<()> {
|
||||
let path = self.thread_path(&thread.id)?;
|
||||
let min_version = required_thread_schema_version(&thread.disallowed_tools);
|
||||
if thread.schema_version < min_version {
|
||||
let mut updated = thread.clone();
|
||||
updated.schema_version = min_version;
|
||||
return write_json_atomic(&path, &updated).with_context(|| {
|
||||
RuntimeStoreRecordFailure::new(
|
||||
RuntimeStoreOperation::Write,
|
||||
RuntimeStoreRecordKind::Thread,
|
||||
&updated.id,
|
||||
&path,
|
||||
)
|
||||
});
|
||||
}
|
||||
write_json_atomic(&path, thread).with_context(|| {
|
||||
RuntimeStoreRecordFailure::new(
|
||||
RuntimeStoreOperation::Write,
|
||||
|
||||
@@ -831,6 +831,7 @@ pub(super) fn thread_execution_state_matches(left: &ThreadRecord, right: &Thread
|
||||
&& left.model_provider_id == right.model_provider_id
|
||||
&& left.reasoning_effort == right.reasoning_effort
|
||||
&& left.allowed_tools == right.allowed_tools
|
||||
&& left.disallowed_tools == right.disallowed_tools
|
||||
&& left.workspace == right.workspace
|
||||
&& left.mode == right.mode
|
||||
&& left.permission_posture == right.permission_posture
|
||||
@@ -1438,6 +1439,12 @@ pub struct CreateThreadRequest {
|
||||
/// An empty array intentionally disables every model-visible tool.
|
||||
#[serde(default)]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "disallowedTools",
|
||||
skip_serializing_if = "Vec::is_empty"
|
||||
)]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
/// Extra directories the file tools may read and write outside the
|
||||
/// workspace. Each path must exist and be a directory.
|
||||
#[serde(default, alias = "addDirs")]
|
||||
@@ -1835,6 +1842,7 @@ pub(super) enum RuntimeTurnInputSource {
|
||||
Scheduled {
|
||||
task_id: String,
|
||||
},
|
||||
AsyncCompletionWake,
|
||||
}
|
||||
|
||||
impl RuntimeTurnInputSource {
|
||||
@@ -1850,7 +1858,7 @@ impl RuntimeTurnInputSource {
|
||||
Self::GoalContinuation {
|
||||
continuation_index, ..
|
||||
} if *continuation_index > 0 => crate::core::ops::UserInputProvenance::GoalContinuation,
|
||||
Self::GoalContinuation { .. } | Self::Scheduled { .. } => {
|
||||
Self::GoalContinuation { .. } | Self::Scheduled { .. } | Self::AsyncCompletionWake => {
|
||||
crate::core::ops::UserInputProvenance::Runtime
|
||||
}
|
||||
}
|
||||
@@ -1863,7 +1871,9 @@ impl RuntimeTurnInputSource {
|
||||
| Self::ExtensionCommand { .. }
|
||||
| Self::WatchEvent(_) => None,
|
||||
Self::AgentMail { message_id, .. } => Some(message_id),
|
||||
Self::GoalContinuation { .. } | Self::Scheduled { .. } => None,
|
||||
Self::GoalContinuation { .. } | Self::Scheduled { .. } | Self::AsyncCompletionWake => {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1874,6 +1884,7 @@ impl RuntimeTurnInputSource {
|
||||
| Self::Review(_)
|
||||
| Self::ExtensionCommand { .. }
|
||||
| Self::Scheduled { .. } => Some(prompt.to_string()),
|
||||
Self::AsyncCompletionWake => Some("[runtime] Background work finished".to_string()),
|
||||
// The provider projection contains runtime-only framing. Persist
|
||||
// the bounded canonical mail summary instead, so history and app
|
||||
// clients never mistake that projection for typed user input.
|
||||
@@ -1938,6 +1949,9 @@ impl RuntimeTurnInputSource {
|
||||
"input_provenance": "scheduled",
|
||||
"task_id": task_id,
|
||||
})),
|
||||
Self::AsyncCompletionWake => Some(json!({
|
||||
"input_provenance": "async_completion_wake",
|
||||
})),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5599,6 +5613,7 @@ impl RuntimeThreadManager {
|
||||
/// deleting a thread calls this, so a grant never outlives the
|
||||
/// conversation it was given in.
|
||||
pub(super) fn take_approval_grants(&self, thread_id: &str) -> Vec<RuntimeApprovalGrant> {
|
||||
crate::extension_host::end_remote_session(thread_id);
|
||||
self.grants
|
||||
.revoke_thread(thread_id, "runtime", "thread archived or deleted")
|
||||
.iter()
|
||||
@@ -6599,6 +6614,695 @@ impl RuntimeThreadManager {
|
||||
self.track_receipt_worker(worker);
|
||||
}
|
||||
|
||||
const ASYNC_COMPLETION_WAKE_DEBOUNCE: std::time::Duration =
|
||||
std::time::Duration::from_millis(75);
|
||||
const IDLE_SHELL_WAKE_POLL_INTERVAL: std::time::Duration =
|
||||
std::time::Duration::from_millis(150);
|
||||
const ASYNC_COMPLETION_WAKE_PROMPT: &'static str = "[runtime] Background work finished";
|
||||
|
||||
pub(super) fn parse_resumed_subagent_completion_count(message: &str) -> Option<usize> {
|
||||
let rest = message.strip_prefix("Resuming turn with ")?;
|
||||
if !rest.contains("sub-agent completion(s)") {
|
||||
return None;
|
||||
}
|
||||
rest.split_whitespace().next()?.parse::<usize>().ok()
|
||||
}
|
||||
|
||||
fn async_wake_arm_op(
|
||||
is_shell: bool,
|
||||
manager_key: usize,
|
||||
owner: Option<std::sync::Weak<Mutex<ActiveThreads>>>,
|
||||
thread_id: &str,
|
||||
arm: bool,
|
||||
) -> bool {
|
||||
type WakeOwnerMap = HashMap<(usize, String), std::sync::Weak<Mutex<ActiveThreads>>>;
|
||||
static ARMED_WAKES: std::sync::OnceLock<parking_lot::Mutex<WakeOwnerMap>> =
|
||||
std::sync::OnceLock::new();
|
||||
static ARMED_SHELL_MONITORS: std::sync::OnceLock<parking_lot::Mutex<WakeOwnerMap>> =
|
||||
std::sync::OnceLock::new();
|
||||
let table = if is_shell {
|
||||
ARMED_SHELL_MONITORS.get_or_init(|| parking_lot::Mutex::new(HashMap::new()))
|
||||
} else {
|
||||
ARMED_WAKES.get_or_init(|| parking_lot::Mutex::new(HashMap::new()))
|
||||
};
|
||||
let mut guard = table.lock();
|
||||
guard.retain(|_, existing| existing.upgrade().is_some());
|
||||
let key = (manager_key, thread_id.to_string());
|
||||
if arm {
|
||||
let Some(owner) = owner else {
|
||||
return false;
|
||||
};
|
||||
if guard.contains_key(&key) {
|
||||
return false;
|
||||
}
|
||||
guard.insert(key, owner);
|
||||
true
|
||||
} else {
|
||||
guard.remove(&key).is_some()
|
||||
}
|
||||
}
|
||||
|
||||
fn async_completion_wake_arm(&self, thread_id: &str) -> bool {
|
||||
Self::async_wake_arm_op(
|
||||
false,
|
||||
Arc::as_ptr(&self.active) as usize,
|
||||
Some(Arc::downgrade(&self.active)),
|
||||
thread_id,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn async_completion_wake_release_key(manager_key: usize, thread_id: &str) {
|
||||
let _ = Self::async_wake_arm_op(false, manager_key, None, thread_id, false);
|
||||
}
|
||||
|
||||
fn async_shell_monitor_arm(&self, thread_id: &str) -> bool {
|
||||
Self::async_wake_arm_op(
|
||||
true,
|
||||
Arc::as_ptr(&self.active) as usize,
|
||||
Some(Arc::downgrade(&self.active)),
|
||||
thread_id,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn async_shell_monitor_release_key(manager_key: usize, thread_id: &str) {
|
||||
let _ = Self::async_wake_arm_op(true, manager_key, None, thread_id, false);
|
||||
}
|
||||
|
||||
fn async_subagent_wake_op(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
op: u8,
|
||||
agent_id: Option<&str>,
|
||||
flag: bool,
|
||||
count: usize,
|
||||
ids: &[String],
|
||||
) -> (bool, Vec<String>, HashSet<String>) {
|
||||
#[derive(Default)]
|
||||
struct SubAgentWakeEntry {
|
||||
owner: std::sync::Weak<Mutex<ActiveThreads>>,
|
||||
pending: Vec<String>,
|
||||
late_after_turn_complete: Vec<String>,
|
||||
delivered: HashSet<String>,
|
||||
active_turn_ids: Vec<String>,
|
||||
active_turn_drained: usize,
|
||||
saw_turn_complete: bool,
|
||||
}
|
||||
|
||||
static STATES: std::sync::OnceLock<
|
||||
parking_lot::Mutex<HashMap<(usize, String), SubAgentWakeEntry>>,
|
||||
> = std::sync::OnceLock::new();
|
||||
let table = STATES.get_or_init(|| parking_lot::Mutex::new(HashMap::new()));
|
||||
let manager_key = Arc::as_ptr(&self.active) as usize;
|
||||
let mut guard = table.lock();
|
||||
guard.retain(|_, entry| entry.owner.upgrade().is_some());
|
||||
let entry = guard
|
||||
.entry((manager_key, thread_id.to_string()))
|
||||
.or_insert_with(|| SubAgentWakeEntry {
|
||||
owner: Arc::downgrade(&self.active),
|
||||
..Default::default()
|
||||
});
|
||||
entry.owner = Arc::downgrade(&self.active);
|
||||
|
||||
match op {
|
||||
0 => {
|
||||
entry.active_turn_ids.clear();
|
||||
for pending_id in entry.pending.drain(..) {
|
||||
if !entry.delivered.contains(&pending_id)
|
||||
&& !entry.active_turn_ids.contains(&pending_id)
|
||||
{
|
||||
entry.active_turn_ids.push(pending_id);
|
||||
}
|
||||
}
|
||||
for late in entry.late_after_turn_complete.drain(..) {
|
||||
if !entry.delivered.contains(&late) && !entry.active_turn_ids.contains(&late) {
|
||||
entry.active_turn_ids.push(late);
|
||||
}
|
||||
}
|
||||
entry.active_turn_drained = 0;
|
||||
entry.saw_turn_complete = false;
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
1 => {
|
||||
entry.saw_turn_complete = true;
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
2 => {
|
||||
entry.active_turn_drained = entry.active_turn_drained.saturating_add(count);
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
3 => {
|
||||
let Some(id) = agent_id else {
|
||||
return (false, Vec::new(), HashSet::new());
|
||||
};
|
||||
if entry.delivered.contains(id) {
|
||||
return (false, Vec::new(), HashSet::new());
|
||||
}
|
||||
if flag {
|
||||
if !entry.pending.iter().any(|existing| existing == id) {
|
||||
entry.pending.push(id.to_string());
|
||||
}
|
||||
(true, Vec::new(), HashSet::new())
|
||||
} else if entry.saw_turn_complete {
|
||||
if !entry
|
||||
.late_after_turn_complete
|
||||
.iter()
|
||||
.any(|existing| existing == id)
|
||||
{
|
||||
entry.late_after_turn_complete.push(id.to_string());
|
||||
}
|
||||
(false, Vec::new(), HashSet::new())
|
||||
} else {
|
||||
if !entry.active_turn_ids.iter().any(|existing| existing == id) {
|
||||
entry.active_turn_ids.push(id.to_string());
|
||||
}
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
let mut remaining_drain = entry.active_turn_drained;
|
||||
let active_ids = std::mem::take(&mut entry.active_turn_ids);
|
||||
for id in active_ids {
|
||||
if entry.delivered.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
if remaining_drain > 0 {
|
||||
remaining_drain -= 1;
|
||||
entry.delivered.insert(id);
|
||||
} else if !entry.pending.iter().any(|existing| existing == &id) {
|
||||
entry.pending.push(id);
|
||||
}
|
||||
}
|
||||
for late in entry.late_after_turn_complete.drain(..) {
|
||||
if !entry.delivered.contains(&late)
|
||||
&& !entry.pending.iter().any(|existing| existing == &late)
|
||||
{
|
||||
entry.pending.push(late);
|
||||
}
|
||||
}
|
||||
entry.active_turn_drained = 0;
|
||||
entry.saw_turn_complete = false;
|
||||
(!entry.pending.is_empty(), Vec::new(), HashSet::new())
|
||||
}
|
||||
5 => (false, Vec::new(), entry.delivered.clone()),
|
||||
6 => (false, entry.pending.clone(), entry.delivered.clone()),
|
||||
7 => {
|
||||
let keep: HashSet<&str> = ids.iter().map(String::as_str).collect();
|
||||
entry.pending.retain(|id| keep.contains(id.as_str()));
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
8 => {
|
||||
let mark: HashSet<&str> = ids.iter().map(String::as_str).collect();
|
||||
entry.pending.retain(|id| !mark.contains(id.as_str()));
|
||||
for id in ids {
|
||||
entry.delivered.insert(id.clone());
|
||||
}
|
||||
(false, Vec::new(), HashSet::new())
|
||||
}
|
||||
_ => (false, Vec::new(), HashSet::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn async_wake_on_turn_started(&self, thread_id: &str) {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 0, None, false, 0, &[]);
|
||||
}
|
||||
|
||||
pub(super) fn async_wake_on_turn_complete(&self, thread_id: &str) {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 1, None, false, 0, &[]);
|
||||
}
|
||||
|
||||
pub(super) fn async_wake_on_resumed_status(&self, thread_id: &str, count: usize) {
|
||||
if count > 0 {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 2, None, false, count, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
fn async_wake_on_agent_complete(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
agent_id: &str,
|
||||
idle_now: bool,
|
||||
) -> bool {
|
||||
self.async_subagent_wake_op(thread_id, 3, Some(agent_id), idle_now, 0, &[])
|
||||
.0
|
||||
}
|
||||
|
||||
fn async_wake_settle_turn(&self, thread_id: &str, turn_completed: bool) -> bool {
|
||||
self.async_subagent_wake_op(thread_id, 4, None, turn_completed, 0, &[])
|
||||
.0
|
||||
}
|
||||
|
||||
fn async_wake_delivered_ids(&self, thread_id: &str) -> HashSet<String> {
|
||||
self.async_subagent_wake_op(thread_id, 5, None, false, 0, &[])
|
||||
.2
|
||||
}
|
||||
|
||||
fn async_wake_pending_snapshot(&self, thread_id: &str) -> (Vec<String>, HashSet<String>) {
|
||||
let (_, pending, delivered) =
|
||||
self.async_subagent_wake_op(thread_id, 6, None, false, 0, &[]);
|
||||
(pending, delivered)
|
||||
}
|
||||
|
||||
fn async_wake_prune_pending(&self, thread_id: &str, keep_ids: &[String]) {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 7, None, false, 0, keep_ids);
|
||||
}
|
||||
|
||||
fn async_wake_mark_delivered(&self, thread_id: &str, ids: &[String]) {
|
||||
if !ids.is_empty() {
|
||||
let _ = self.async_subagent_wake_op(thread_id, 8, None, false, 0, ids);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn weak_wake_manager(
|
||||
&self,
|
||||
) -> Arc<dyn Fn() -> Option<RuntimeThreadManager> + Send + Sync> {
|
||||
let host_profile = self.host_profile;
|
||||
let config = self.config.clone();
|
||||
let workspace = self.workspace.clone();
|
||||
let plugin_registry = self.plugin_registry.clone();
|
||||
let store = self.store.clone();
|
||||
let weak_process_owner_lock = Arc::downgrade(&self._process_owner_lock);
|
||||
let config_admission = self.config_admission.clone();
|
||||
let engine_load = self.engine_load.clone();
|
||||
let shutdown_drain = self.shutdown_drain.clone();
|
||||
let engine_workers = self.engine_workers.clone();
|
||||
let subagent_manager = self.subagent_manager.clone();
|
||||
let turn_monitors = self.turn_monitors.clone();
|
||||
let event_monitors = self.event_monitors.clone();
|
||||
let event_reader_wake = self.event_reader_wake.clone();
|
||||
let event_pump_stop = self.event_pump_stop.clone();
|
||||
let weak_event_pump_owner = Arc::downgrade(&self._event_pump_owner);
|
||||
let weak_active = Arc::downgrade(&self.active);
|
||||
let event_emit = self.event_emit.clone();
|
||||
let projection_locks = self.projection_locks.clone();
|
||||
let event_tx = self.event_tx.clone();
|
||||
let manager_cfg = self.manager_cfg.clone();
|
||||
let runtime_mcp_servers = self.runtime_mcp_servers.clone();
|
||||
let cancel_token = self.cancel_token.clone();
|
||||
let task_manager = self.task_manager.clone();
|
||||
let task_execution_lease = self.task_execution_lease.clone();
|
||||
let automations = self.automations.clone();
|
||||
let pending_approvals = self.pending_approvals.clone();
|
||||
let grants = self.grants.clone();
|
||||
let pending_user_inputs = self.pending_user_inputs.clone();
|
||||
let pending_dynamic_tools = self.pending_dynamic_tools.clone();
|
||||
let pending_commands = self.pending_commands.clone();
|
||||
let recovery_receipts = self.recovery_receipts.clone();
|
||||
let notices = self.notices.clone();
|
||||
let recovery_flush = self.recovery_flush.clone();
|
||||
let hook_base = self.hook_base.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let snapshot_test_hook = self.snapshot_test_hook.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let replay_test_hook = self.replay_test_hook.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let test_model_client = self.test_model_client.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let test_approval_decision_timeout_ms = self.test_approval_decision_timeout_ms.clone();
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
let test_dynamic_tool_result_timeout_ms = self.test_dynamic_tool_result_timeout_ms.clone();
|
||||
|
||||
Arc::new(move || {
|
||||
if cancel_token.is_cancelled() || event_pump_stop.is_cancelled() {
|
||||
return None;
|
||||
}
|
||||
let process_owner_lock = weak_process_owner_lock.upgrade()?;
|
||||
let event_pump_owner = weak_event_pump_owner.upgrade()?;
|
||||
let active = weak_active.upgrade()?;
|
||||
Some(RuntimeThreadManager {
|
||||
host_profile,
|
||||
config: config.clone(),
|
||||
workspace: workspace.clone(),
|
||||
plugin_registry: plugin_registry.clone(),
|
||||
store: store.clone(),
|
||||
_process_owner_lock: process_owner_lock,
|
||||
config_admission: config_admission.clone(),
|
||||
engine_load: engine_load.clone(),
|
||||
shutdown_drain: shutdown_drain.clone(),
|
||||
engine_workers: engine_workers.clone(),
|
||||
subagent_manager: subagent_manager.clone(),
|
||||
turn_monitors: turn_monitors.clone(),
|
||||
event_monitors: event_monitors.clone(),
|
||||
event_reader_wake: event_reader_wake.clone(),
|
||||
event_pump_stop: event_pump_stop.clone(),
|
||||
_event_pump_owner: event_pump_owner,
|
||||
active,
|
||||
event_emit: event_emit.clone(),
|
||||
projection_locks: projection_locks.clone(),
|
||||
event_tx: event_tx.clone(),
|
||||
manager_cfg: manager_cfg.clone(),
|
||||
runtime_mcp_servers: runtime_mcp_servers.clone(),
|
||||
cancel_token: cancel_token.clone(),
|
||||
task_manager: task_manager.clone(),
|
||||
task_execution_lease: task_execution_lease.clone(),
|
||||
automations: automations.clone(),
|
||||
pending_approvals: pending_approvals.clone(),
|
||||
grants: grants.clone(),
|
||||
pending_user_inputs: pending_user_inputs.clone(),
|
||||
pending_dynamic_tools: pending_dynamic_tools.clone(),
|
||||
pending_commands: pending_commands.clone(),
|
||||
recovery_receipts: recovery_receipts.clone(),
|
||||
notices: notices.clone(),
|
||||
recovery_flush: recovery_flush.clone(),
|
||||
hook_base: hook_base.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
snapshot_test_hook: snapshot_test_hook.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
replay_test_hook: replay_test_hook.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
test_model_client: test_model_client.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
test_approval_decision_timeout_ms: test_approval_decision_timeout_ms.clone(),
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
test_dynamic_tool_result_timeout_ms: test_dynamic_tool_result_timeout_ms.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn handle_agent_complete_async_wake(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
agent_id: &str,
|
||||
subagent_manager: &SharedSubAgentManager,
|
||||
active_weak: &std::sync::Weak<Mutex<ActiveThreads>>,
|
||||
identity: &std::sync::Weak<AsyncRwLock<mpsc::Receiver<EngineEvent>>>,
|
||||
) {
|
||||
if self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let delivered = self.async_wake_delivered_ids(thread_id);
|
||||
let eligible = {
|
||||
let guard = subagent_manager.read().await;
|
||||
guard
|
||||
.terminal_results_excluding_for_session(thread_id, &delivered)
|
||||
.into_iter()
|
||||
.any(|result| result.agent_id == agent_id)
|
||||
};
|
||||
if !eligible {
|
||||
return;
|
||||
}
|
||||
let Some(active) = active_weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let (owns_receiver, engine_active, turn_is_active) = {
|
||||
let guard = active.lock().await;
|
||||
guard
|
||||
.engines
|
||||
.get(thread_id)
|
||||
.map(|state| {
|
||||
let owns_receiver = identity
|
||||
.upgrade()
|
||||
.is_some_and(|receiver| Arc::ptr_eq(&receiver, &state.engine.rx_event));
|
||||
(
|
||||
owns_receiver,
|
||||
!state.engine.is_cancelled(),
|
||||
state.active_turn.is_some(),
|
||||
)
|
||||
})
|
||||
.unwrap_or((false, false, false))
|
||||
};
|
||||
if !owns_receiver || !engine_active {
|
||||
return;
|
||||
}
|
||||
if self.async_wake_on_agent_complete(thread_id, agent_id, !turn_is_active) {
|
||||
self.spawn_async_completion_wake(thread_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_non_acp_turn_settled_async_completions(
|
||||
&self,
|
||||
thread_id: &str,
|
||||
turn_completed: bool,
|
||||
) {
|
||||
let has_pending_subagent = self.async_wake_settle_turn(thread_id, turn_completed);
|
||||
if !turn_completed
|
||||
|| self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Ok(thread) = self.get_thread(thread_id).await else {
|
||||
return;
|
||||
};
|
||||
if thread.archived || thread.executor.is_some() {
|
||||
return;
|
||||
}
|
||||
let (engine_ready, shell_manager) = {
|
||||
let active = self.active.lock().await;
|
||||
let engine_ready = active
|
||||
.engines
|
||||
.get(thread_id)
|
||||
.is_some_and(|state| !state.engine.is_cancelled() && state.active_turn.is_none());
|
||||
let shell_manager = active.shell_managers.get(thread_id).cloned();
|
||||
(engine_ready, shell_manager)
|
||||
};
|
||||
if !engine_ready {
|
||||
return;
|
||||
}
|
||||
let (has_finished_shell, may_have_shell) = shell_manager
|
||||
.and_then(|manager| {
|
||||
manager.lock().ok().map(|mut guard| {
|
||||
(
|
||||
guard.has_finished_unreported_jobs_for_session(thread_id),
|
||||
guard.may_have_undelivered_completion_for_session(thread_id),
|
||||
)
|
||||
})
|
||||
})
|
||||
.unwrap_or((false, false));
|
||||
if has_pending_subagent || has_finished_shell {
|
||||
self.spawn_async_completion_wake(thread_id.to_string());
|
||||
}
|
||||
if may_have_shell {
|
||||
self.spawn_idle_shell_completion_monitor(thread_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn deliver_async_completion_wake(&self, thread_id: &str) -> Result<()> {
|
||||
if self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
self.ensure_accepting_execution()?;
|
||||
let thread = match self.get_thread(thread_id).await {
|
||||
Ok(thread) => thread,
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
if thread.archived || thread.executor.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let (subagent_manager, shell_manager) = {
|
||||
let active = self.active.lock().await;
|
||||
let Some(state) = active.engines.get(thread_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
if state.engine.is_cancelled() || state.active_turn.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
(
|
||||
state.engine.subagent_manager(),
|
||||
active.shell_managers.get(thread_id).cloned(),
|
||||
)
|
||||
};
|
||||
let has_finished_shell = shell_manager.as_ref().is_some_and(|manager| {
|
||||
manager
|
||||
.lock()
|
||||
.is_ok_and(|mut guard| guard.has_finished_unreported_jobs_for_session(thread_id))
|
||||
});
|
||||
let (pending_ids, delivered_ids) = self.async_wake_pending_snapshot(thread_id);
|
||||
let claimed_subagent_ids = if pending_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let eligible_ids: HashSet<String> = {
|
||||
let guard = subagent_manager.read().await;
|
||||
guard
|
||||
.terminal_results_excluding_for_session(thread_id, &delivered_ids)
|
||||
.into_iter()
|
||||
.map(|result| result.agent_id)
|
||||
.collect()
|
||||
};
|
||||
let claimed: Vec<String> = pending_ids
|
||||
.into_iter()
|
||||
.filter(|id| eligible_ids.contains(id))
|
||||
.collect();
|
||||
self.async_wake_prune_pending(thread_id, &claimed);
|
||||
claimed
|
||||
};
|
||||
if !has_finished_shell && claimed_subagent_ids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let request = StartTurnRequest {
|
||||
prompt: Self::ASYNC_COMPLETION_WAKE_PROMPT.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
match Box::pin(self.start_turn_with_source(
|
||||
thread_id,
|
||||
request,
|
||||
RuntimeTurnInputSource::AsyncCompletionWake,
|
||||
None,
|
||||
false,
|
||||
))
|
||||
.await
|
||||
{
|
||||
Ok((_turn, _replayed)) => {
|
||||
self.async_wake_mark_delivered(thread_id, &claimed_subagent_ids);
|
||||
Ok(())
|
||||
}
|
||||
Err(error) if error.to_string().contains("already has an active") => Ok(()),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn spawn_async_completion_wake(&self, thread_id: String) {
|
||||
if self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.async_completion_wake_arm(&thread_id) {
|
||||
return;
|
||||
}
|
||||
let manager_key = Arc::as_ptr(&self.active) as usize;
|
||||
let weak = self.weak_wake_manager();
|
||||
let cancel = self.cancel_token.clone();
|
||||
let stop = self.event_pump_stop.clone();
|
||||
let worker = tokio::spawn(async move {
|
||||
struct WakeArmGuard {
|
||||
manager_key: usize,
|
||||
thread_id: String,
|
||||
armed: bool,
|
||||
}
|
||||
impl Drop for WakeArmGuard {
|
||||
fn drop(&mut self) {
|
||||
if self.armed {
|
||||
RuntimeThreadManager::async_completion_wake_release_key(
|
||||
self.manager_key,
|
||||
&self.thread_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut guard = WakeArmGuard {
|
||||
manager_key,
|
||||
thread_id: thread_id.clone(),
|
||||
armed: true,
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => return,
|
||||
_ = stop.cancelled() => return,
|
||||
_ = tokio::time::sleep(Self::ASYNC_COMPLETION_WAKE_DEBOUNCE) => {}
|
||||
}
|
||||
guard.armed = false;
|
||||
Self::async_completion_wake_release_key(manager_key, &thread_id);
|
||||
let Some(manager) = weak() else {
|
||||
return;
|
||||
};
|
||||
if let Err(error) = Box::pin(manager.deliver_async_completion_wake(&thread_id)).await {
|
||||
tracing::warn!(
|
||||
thread_id,
|
||||
%error,
|
||||
"Failed to deliver coalesced async completion wake"
|
||||
);
|
||||
}
|
||||
});
|
||||
self.track_receipt_worker(worker);
|
||||
}
|
||||
|
||||
pub(super) fn spawn_idle_shell_completion_monitor(&self, thread_id: String) {
|
||||
if self.cancel_token.is_cancelled()
|
||||
|| self.event_pump_stop.is_cancelled()
|
||||
|| self.is_acp_host()
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.async_shell_monitor_arm(&thread_id) {
|
||||
return;
|
||||
}
|
||||
let manager_key = Arc::as_ptr(&self.active) as usize;
|
||||
let weak = self.weak_wake_manager();
|
||||
let cancel = self.cancel_token.clone();
|
||||
let stop = self.event_pump_stop.clone();
|
||||
let worker = tokio::spawn(async move {
|
||||
struct ShellMonitorGuard {
|
||||
manager_key: usize,
|
||||
thread_id: String,
|
||||
}
|
||||
impl Drop for ShellMonitorGuard {
|
||||
fn drop(&mut self) {
|
||||
RuntimeThreadManager::async_shell_monitor_release_key(
|
||||
self.manager_key,
|
||||
&self.thread_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
let _guard = ShellMonitorGuard {
|
||||
manager_key,
|
||||
thread_id: thread_id.clone(),
|
||||
};
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancel.cancelled() => break,
|
||||
_ = stop.cancelled() => break,
|
||||
_ = tokio::time::sleep(Self::IDLE_SHELL_WAKE_POLL_INTERVAL) => {}
|
||||
}
|
||||
let Some(manager) = weak() else {
|
||||
break;
|
||||
};
|
||||
if manager.ensure_accepting_execution().is_err() || manager.is_acp_host() {
|
||||
break;
|
||||
}
|
||||
let Ok(thread) = manager.get_thread(&thread_id).await else {
|
||||
break;
|
||||
};
|
||||
if thread.archived || thread.executor.is_some() {
|
||||
break;
|
||||
}
|
||||
let (engine_alive, is_idle, shell_manager) = {
|
||||
let active = manager.active.lock().await;
|
||||
let engine_alive = active
|
||||
.engines
|
||||
.get(&thread_id)
|
||||
.is_some_and(|state| !state.engine.is_cancelled());
|
||||
let is_idle = active
|
||||
.engines
|
||||
.get(&thread_id)
|
||||
.is_some_and(|state| state.active_turn.is_none());
|
||||
let shell_manager = active.shell_managers.get(&thread_id).cloned();
|
||||
(engine_alive, is_idle, shell_manager)
|
||||
};
|
||||
if !engine_alive {
|
||||
break;
|
||||
}
|
||||
let Some(shell_manager) = shell_manager else {
|
||||
break;
|
||||
};
|
||||
let (has_finished, may_have_pending) = match shell_manager.lock() {
|
||||
Ok(mut guard) => (
|
||||
guard.has_finished_unreported_jobs_for_session(&thread_id),
|
||||
guard.may_have_undelivered_completion_for_session(&thread_id),
|
||||
),
|
||||
Err(_) => break,
|
||||
};
|
||||
if !may_have_pending {
|
||||
break;
|
||||
}
|
||||
if has_finished && is_idle {
|
||||
manager.spawn_async_completion_wake(thread_id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
self.track_receipt_worker(worker);
|
||||
}
|
||||
|
||||
pub(super) fn projection_lock(&self, thread_id: &str) -> Arc<Mutex<()>> {
|
||||
let mut locks = self.projection_locks.lock();
|
||||
Arc::clone(
|
||||
@@ -7301,8 +8005,9 @@ impl RuntimeThreadManager {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let disallowed_tools = crate::task_manager::normalize_deny_rules(req.disallowed_tools);
|
||||
let thread = ThreadRecord {
|
||||
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
|
||||
schema_version: super::required_thread_schema_version(&disallowed_tools),
|
||||
id: match reserved_id {
|
||||
Some(id) => validated_record_id(&id, "reserved history thread id")?.to_string(),
|
||||
None => runtime_record_id("thr"),
|
||||
@@ -7314,6 +8019,7 @@ impl RuntimeThreadManager {
|
||||
model_provider_id,
|
||||
reasoning_effort,
|
||||
allowed_tools: req.allowed_tools,
|
||||
disallowed_tools,
|
||||
add_dirs,
|
||||
workspace,
|
||||
mode,
|
||||
@@ -7365,6 +8071,9 @@ impl RuntimeThreadManager {
|
||||
if let Some(preset) = thread.agent_preset.as_ref() {
|
||||
fingerprint_body["agent_preset"] = json!(preset);
|
||||
}
|
||||
if !thread.disallowed_tools.is_empty() {
|
||||
fingerprint_body["disallowed_tools"] = json!(thread.disallowed_tools);
|
||||
}
|
||||
let request_fingerprint =
|
||||
codewhale_runtime::hashing::sha256_hex(serde_json::to_vec(&fingerprint_body)?);
|
||||
let _emit_order = self.event_emit.lock().await;
|
||||
@@ -11206,7 +11915,7 @@ impl RuntimeThreadManager {
|
||||
};
|
||||
if !turn_seed.image_content.is_empty() {
|
||||
item.set_image_content(turn_seed.image_content.clone());
|
||||
thread.schema_version = IMAGE_RUNTIME_SCHEMA_VERSION;
|
||||
thread.schema_version = thread.schema_version.max(IMAGE_RUNTIME_SCHEMA_VERSION);
|
||||
}
|
||||
seed_items.push(item);
|
||||
item_ids.push(item_id);
|
||||
@@ -14827,7 +15536,8 @@ source_usage_records: Default::default(),
|
||||
cfg.reasoning_only_reprompt_message().to_string(),
|
||||
),
|
||||
allowed_tools: isolated_chat.then(Vec::new),
|
||||
disallowed_tools: None,
|
||||
disallowed_tools: (!thread.disallowed_tools.is_empty())
|
||||
.then(|| thread.disallowed_tools.clone()),
|
||||
max_tool_calls: None,
|
||||
hook_executor: Some(Arc::clone(&thread_hooks)),
|
||||
locale_tag: codewhale_localization::resolve_locale(&settings.locale)
|
||||
@@ -15006,6 +15716,9 @@ source_usage_records: Default::default(),
|
||||
owner: Arc::downgrade(&self._process_owner_lock),
|
||||
};
|
||||
let input_settlement = self.user_input_settlement(&thread.id);
|
||||
let allow_reactive_wake = !self.is_acp_host() && thread.executor.is_none();
|
||||
let weak_wake_manager = self.weak_wake_manager();
|
||||
let pump_cancel = self.cancel_token.clone();
|
||||
let handle = engine.clone();
|
||||
let thread_id = thread.id.clone();
|
||||
let pump = tokio::spawn(async move {
|
||||
@@ -15015,6 +15728,51 @@ source_usage_records: Default::default(),
|
||||
event = async { original.write().await.recv().await } => match event { Some(event) => event, None => break },
|
||||
_ = stop.cancelled() => break,
|
||||
};
|
||||
if allow_reactive_wake
|
||||
&& !stop.is_cancelled()
|
||||
&& !pump_cancel.is_cancelled()
|
||||
&& !handle.is_cancelled()
|
||||
{
|
||||
enum WakeTurnSignal {
|
||||
Started,
|
||||
Complete,
|
||||
Resumed(usize),
|
||||
}
|
||||
let signal = match &event {
|
||||
EngineEvent::TurnStarted { .. } => Some(WakeTurnSignal::Started),
|
||||
EngineEvent::TurnComplete { .. } => Some(WakeTurnSignal::Complete),
|
||||
EngineEvent::Status { message } => {
|
||||
Self::parse_resumed_subagent_completion_count(message)
|
||||
.map(WakeTurnSignal::Resumed)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(signal) = signal
|
||||
&& let Some(active_arc) = active.upgrade()
|
||||
{
|
||||
let owns_receiver = {
|
||||
let guard = active_arc.lock().await;
|
||||
guard.engines.get(&thread_id).is_some_and(|state| {
|
||||
identity.upgrade().is_some_and(|receiver| {
|
||||
Arc::ptr_eq(&receiver, &state.engine.rx_event)
|
||||
})
|
||||
})
|
||||
};
|
||||
if owns_receiver && let Some(manager) = weak_wake_manager() {
|
||||
match signal {
|
||||
WakeTurnSignal::Started => {
|
||||
manager.async_wake_on_turn_started(&thread_id);
|
||||
}
|
||||
WakeTurnSignal::Complete => {
|
||||
manager.async_wake_on_turn_complete(&thread_id);
|
||||
}
|
||||
WakeTurnSignal::Resumed(count) => {
|
||||
manager.async_wake_on_resumed_status(&thread_id, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(
|
||||
&event,
|
||||
EngineEvent::AgentSpawned { .. }
|
||||
@@ -15057,6 +15815,30 @@ source_usage_records: Default::default(),
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if allow_reactive_wake
|
||||
&& !stop.is_cancelled()
|
||||
&& !pump_cancel.is_cancelled()
|
||||
&& !handle.is_cancelled()
|
||||
&& let EngineEvent::AgentComplete {
|
||||
owner_session_id,
|
||||
id,
|
||||
parent_run_id,
|
||||
..
|
||||
} = &event
|
||||
&& owner_session_id == &thread_id
|
||||
&& parent_run_id.is_none()
|
||||
&& let Some(manager) = weak_wake_manager()
|
||||
{
|
||||
manager
|
||||
.handle_agent_complete_async_wake(
|
||||
&thread_id,
|
||||
id,
|
||||
&sink.manager,
|
||||
&active,
|
||||
&identity,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if !owns_origin {
|
||||
continue;
|
||||
}
|
||||
@@ -18145,6 +18927,11 @@ source_usage_records: Default::default(),
|
||||
// next one, keeping every wake explicit and bounded to one turn.
|
||||
if !admitted_acp {
|
||||
self.spawn_agent_mail_safe_boundary_delivery(thread_id.clone());
|
||||
self.handle_non_acp_turn_settled_async_completions(
|
||||
&thread_id,
|
||||
turn.status == RuntimeTurnStatus::Completed,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1824,6 +1824,135 @@ mod recovery {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_deny_rules_persist_and_reach_the_engine_that_runs_the_thread() -> Result<()> {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let dir = tempfile::tempdir()?;
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
|
||||
let workspace = dir.path().join("workspace");
|
||||
fs::create_dir(&workspace)?;
|
||||
let manager = RuntimeThreadManager::open(
|
||||
config(),
|
||||
workspace,
|
||||
test_manager_config(dir.path().join("runtime")),
|
||||
)?;
|
||||
let mock = Arc::new(MockLlmClient::new(vec![
|
||||
canned::simple_text_turn("open"),
|
||||
canned::simple_text_turn("restricted"),
|
||||
]));
|
||||
manager.set_test_model_client(mock.clone());
|
||||
let rules = vec!["file".to_string()];
|
||||
let tool_names = |request: &codewhale_models::MessageRequest| -> Vec<String> {
|
||||
request
|
||||
.tools
|
||||
.iter()
|
||||
.flatten()
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect()
|
||||
};
|
||||
|
||||
let open = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let first = manager
|
||||
.start_turn(
|
||||
&open.id,
|
||||
StartTurnRequest {
|
||||
prompt: "list".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
wait_for_terminal_turn(&manager, &first.id).await?;
|
||||
|
||||
let restricted: CreateThreadRequest =
|
||||
serde_json::from_value(json!({ "disallowed_tools": ["file"] }))?;
|
||||
let restricted = manager.create_thread(restricted).await?;
|
||||
assert_eq!(
|
||||
serde_json::to_value(manager.get_thread(&restricted.id).await?)?["disallowed_tools"],
|
||||
json!(["file"])
|
||||
);
|
||||
let second = manager
|
||||
.start_turn(
|
||||
&restricted.id,
|
||||
StartTurnRequest {
|
||||
prompt: "list".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
wait_for_terminal_turn(&manager, &second.id).await?;
|
||||
|
||||
let requests = mock.captured_requests();
|
||||
assert_eq!(requests.len(), 2);
|
||||
let denied = |names: &[String]| {
|
||||
names
|
||||
.iter()
|
||||
.any(|name| codewhale_execpolicy::tool_rules::tool_denied(Some(&rules), name))
|
||||
};
|
||||
assert!(
|
||||
denied(&tool_names(&requests[0])),
|
||||
"the control thread must expose a tool the rule covers"
|
||||
);
|
||||
assert!(
|
||||
!denied(&tool_names(&requests[1])),
|
||||
"{:?}",
|
||||
tool_names(&requests[1])
|
||||
);
|
||||
manager.shutdown_and_wait().await?;
|
||||
drop(manager);
|
||||
|
||||
let open_disk: serde_json::Value = serde_json::from_str(&fs::read_to_string(
|
||||
dir.path()
|
||||
.join("runtime")
|
||||
.join("threads")
|
||||
.join(format!("{}.json", open.id)),
|
||||
)?)?;
|
||||
assert_eq!(open_disk["schema_version"], CURRENT_RUNTIME_SCHEMA_VERSION);
|
||||
assert!(open_disk.get("disallowed_tools").is_none());
|
||||
|
||||
let restricted_path = dir
|
||||
.path()
|
||||
.join("runtime")
|
||||
.join("threads")
|
||||
.join(format!("{}.json", restricted.id));
|
||||
let mut restricted_disk: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&restricted_path)?)?;
|
||||
assert_eq!(
|
||||
restricted_disk["schema_version"],
|
||||
DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(restricted_disk["disallowed_tools"], json!(["file"]));
|
||||
|
||||
let reopened = RuntimeThreadManager::open(
|
||||
config(),
|
||||
dir.path().join("workspace"),
|
||||
test_manager_config(dir.path().join("runtime")),
|
||||
)?;
|
||||
let reloaded = reopened.get_thread(&restricted.id).await?;
|
||||
assert_eq!(
|
||||
reloaded.schema_version,
|
||||
DISALLOWED_TOOLS_RUNTIME_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(reloaded.disallowed_tools, vec!["file"]);
|
||||
|
||||
restricted_disk["schema_version"] = json!(MAX_SUPPORTED_RUNTIME_SCHEMA_VERSION + 1);
|
||||
fs::write(
|
||||
&restricted_path,
|
||||
serde_json::to_string_pretty(&restricted_disk)?,
|
||||
)?;
|
||||
let err = reopened
|
||||
.get_thread(&restricted.id)
|
||||
.await
|
||||
.expect_err("schema v8 must be rejected");
|
||||
assert!(
|
||||
format!("{err:#}").contains("newer than supported"),
|
||||
"{err:#}"
|
||||
);
|
||||
reopened.shutdown_and_wait().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn control_case(interrupt: bool, follow_up: bool) -> Result<()> {
|
||||
let _env = crate::test_support::lock_test_env();
|
||||
let dir = tempfile::tempdir()?;
|
||||
@@ -2669,6 +2798,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11615,10 +11745,9 @@ async fn compact_thread_receipt_keeps_exact_named_custom_identity() -> Result<()
|
||||
|
||||
#[tokio::test]
|
||||
async fn compact_thread_with_real_engine_reaches_terminal_status() -> Result<()> {
|
||||
// A real engine takes the workspace coordination lock exclusively, and
|
||||
// nextest runs tests in parallel processes with this crate as their working
|
||||
// directory. A `"."` workspace would therefore be the crate directory
|
||||
// itself, and two exclusive holders of it cannot both start.
|
||||
// itself, shared by every test process.
|
||||
let workspace = tempfile::tempdir()?;
|
||||
let manager = RuntimeThreadManager::open(
|
||||
{
|
||||
@@ -19970,6 +20099,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
|
||||
executor: None,
|
||||
environment: None,
|
||||
agent_preset: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
manager.store.save_thread(&thread)?;
|
||||
|
||||
@@ -23835,6 +23965,7 @@ mod adoption_refusal {
|
||||
auto_approve: Some(false),
|
||||
delivery_mode: None,
|
||||
status: Some(AutomationStatus::Paused),
|
||||
disallowed_tools: Vec::new(),
|
||||
})?;
|
||||
automations.edit_automation(&created.id, |record| {
|
||||
let mut record =
|
||||
@@ -26147,3 +26278,619 @@ async fn profile_constitution_runtime_admission_binds_the_complete_snapshot_to_r
|
||||
wait_for_terminal_turn(&manager, &turn.id).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_coalesces_rapid_idle_subagent_completions() -> Result<()> {
|
||||
use crate::tools::subagent::{SubAgentManager, SubAgentStatus};
|
||||
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut harness = mock_engine_handle();
|
||||
let subagent_manager = Arc::new(tokio::sync::RwLock::new(SubAgentManager::new(
|
||||
thread.workspace.clone(),
|
||||
2,
|
||||
)));
|
||||
harness
|
||||
.handle
|
||||
.set_subagent_manager(subagent_manager.clone());
|
||||
manager
|
||||
.install_test_engine(&thread.id, harness.handle.clone())
|
||||
.await?;
|
||||
let mut rx_op = harness.rx_op;
|
||||
let tx_event = harness.tx_event;
|
||||
|
||||
let (sub_a, sub_b) = {
|
||||
let mut guard = subagent_manager.write().await;
|
||||
let sub_a = guard.insert_test_terminal_direct_child("sub_a", &thread.workspace);
|
||||
guard.assign_test_session_owner(&sub_a, &thread.id);
|
||||
let sub_b = guard.insert_test_terminal_direct_child("sub_b", &thread.workspace);
|
||||
guard.assign_test_session_owner(&sub_b, &thread.id);
|
||||
(sub_a, sub_b)
|
||||
};
|
||||
|
||||
for (index, agent_id) in [sub_a, sub_b].into_iter().enumerate() {
|
||||
if index > 0 {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: Some("worker".into()),
|
||||
owner_session_id: thread.id.clone(),
|
||||
id: agent_id,
|
||||
result: "done".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
|
||||
let op = tokio::time::timeout(Duration::from_secs(2), rx_op.recv())
|
||||
.await
|
||||
.context("expected coalesced wake turn")?;
|
||||
let Some(Op::SendMessage(spec)) = op else {
|
||||
bail!("expected Op::SendMessage wake operation");
|
||||
};
|
||||
assert_eq!(
|
||||
spec.provenance,
|
||||
crate::core::ops::UserInputProvenance::Runtime
|
||||
);
|
||||
assert_eq!(spec.content, "[runtime] Background work finished");
|
||||
|
||||
tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: "engine_wake_coalesced".into(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: "processed coalesced subagents".to_string(),
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turns =
|
||||
wait_for_terminal_turn_count(&manager, &thread.id, 1, Duration::from_secs(2)).await?;
|
||||
assert_eq!(turns.len(), 1);
|
||||
assert_eq!(turns[0].status, RuntimeTurnStatus::Completed);
|
||||
let items = manager.store.list_items_for_turn(&turns[0].id)?;
|
||||
let input_item = items
|
||||
.iter()
|
||||
.find(|item| item.kind == TurnItemKind::UserMessage)
|
||||
.context("missing wake input item")?;
|
||||
assert_eq!(
|
||||
input_item.detail.as_deref(),
|
||||
Some("[runtime] Background work finished")
|
||||
);
|
||||
assert_eq!(
|
||||
input_item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get("input_provenance"))
|
||||
.and_then(Value::as_str),
|
||||
Some("async_completion_wake")
|
||||
);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(rx_op.try_recv().is_err());
|
||||
assert_eq!(manager.store.list_turns_for_thread(&thread.id)?.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_skips_cancelled_and_archived_threads() -> Result<()> {
|
||||
use crate::tools::subagent::{SubAgentManager, SubAgentStatus};
|
||||
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let archived_thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut archived_harness = mock_engine_handle();
|
||||
let shared_subagents = Arc::new(tokio::sync::RwLock::new(SubAgentManager::new(
|
||||
archived_thread.workspace.clone(),
|
||||
2,
|
||||
)));
|
||||
archived_harness
|
||||
.handle
|
||||
.set_subagent_manager(shared_subagents.clone());
|
||||
manager
|
||||
.install_test_engine(&archived_thread.id, archived_harness.handle.clone())
|
||||
.await?;
|
||||
|
||||
let cancelled_thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut cancelled_harness = mock_engine_handle();
|
||||
cancelled_harness
|
||||
.handle
|
||||
.set_subagent_manager(shared_subagents.clone());
|
||||
manager
|
||||
.install_test_engine(&cancelled_thread.id, cancelled_harness.handle.clone())
|
||||
.await?;
|
||||
|
||||
let (sub_archived, sub_cancelled) = {
|
||||
let mut guard = shared_subagents.write().await;
|
||||
let a = guard.insert_test_terminal_direct_child("sub_arch", &archived_thread.workspace);
|
||||
guard.assign_test_session_owner(&a, &archived_thread.id);
|
||||
let b = guard.insert_test_terminal_direct_child("sub_canc", &cancelled_thread.workspace);
|
||||
guard.assign_test_session_owner(&b, &cancelled_thread.id);
|
||||
(a, b)
|
||||
};
|
||||
|
||||
manager
|
||||
.update_thread(
|
||||
&archived_thread.id,
|
||||
UpdateThreadRequest {
|
||||
archived: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
cancelled_harness
|
||||
.handle
|
||||
.cancel_with_reason(crate::core::engine::CancelReason::User);
|
||||
|
||||
archived_harness
|
||||
.tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: None,
|
||||
owner_session_id: archived_thread.id.clone(),
|
||||
id: sub_archived,
|
||||
result: "done".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
cancelled_harness
|
||||
.tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: None,
|
||||
owner_session_id: cancelled_thread.id.clone(),
|
||||
id: sub_cancelled,
|
||||
result: "done".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(140)).await;
|
||||
assert!(archived_harness.rx_op.try_recv().is_err());
|
||||
assert!(cancelled_harness.rx_op.try_recv().is_err());
|
||||
assert!(
|
||||
manager
|
||||
.store
|
||||
.list_turns_for_thread(&archived_thread.id)?
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
manager
|
||||
.store
|
||||
.list_turns_for_thread(&cancelled_thread.id)?
|
||||
.is_empty()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_waits_for_busy_turn_settlement_and_wakes_once() -> Result<()> {
|
||||
use crate::tools::subagent::{SubAgentManager, SubAgentStatus};
|
||||
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut harness = mock_engine_handle();
|
||||
let subagent_manager = Arc::new(tokio::sync::RwLock::new(SubAgentManager::new(
|
||||
thread.workspace.clone(),
|
||||
1,
|
||||
)));
|
||||
harness
|
||||
.handle
|
||||
.set_subagent_manager(subagent_manager.clone());
|
||||
manager
|
||||
.install_test_engine(&thread.id, harness.handle.clone())
|
||||
.await?;
|
||||
let mut rx_op = harness.rx_op;
|
||||
let tx_event = harness.tx_event;
|
||||
|
||||
let first_turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "primary user turn".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let Some(Op::SendMessage(first_spec)) =
|
||||
tokio::time::timeout(Duration::from_secs(2), rx_op.recv()).await?
|
||||
else {
|
||||
bail!("missing Turn 1 operation");
|
||||
};
|
||||
assert_eq!(first_spec.content, "primary user turn");
|
||||
|
||||
tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: "engine_busy_1".into(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let sub_busy = {
|
||||
let mut guard = subagent_manager.write().await;
|
||||
let id = guard.insert_test_terminal_direct_child("sub_busy", &thread.workspace);
|
||||
guard.assign_test_session_owner(&id, &thread.id);
|
||||
id
|
||||
};
|
||||
tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: None,
|
||||
owner_session_id: thread.id.clone(),
|
||||
id: sub_busy,
|
||||
result: "done while busy".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
manager.spawn_async_completion_wake(thread.id.clone());
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(rx_op.try_recv().is_err());
|
||||
|
||||
tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: "finished primary user turn".to_string(),
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
let settled_first = wait_for_terminal_turn(&manager, &first_turn.id).await?;
|
||||
assert_eq!(settled_first.status, RuntimeTurnStatus::Completed);
|
||||
|
||||
let Some(Op::SendMessage(wake_spec)) =
|
||||
tokio::time::timeout(Duration::from_secs(2), rx_op.recv()).await?
|
||||
else {
|
||||
bail!("missing deferred wake operation");
|
||||
};
|
||||
assert_eq!(
|
||||
wake_spec.provenance,
|
||||
crate::core::ops::UserInputProvenance::Runtime
|
||||
);
|
||||
assert_eq!(wake_spec.content, "[runtime] Background work finished");
|
||||
|
||||
tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: "engine_busy_2".into(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: "handled deferred wake".to_string(),
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turns =
|
||||
wait_for_terminal_turn_count(&manager, &thread.id, 2, Duration::from_secs(2)).await?;
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert_eq!(turns[1].status, RuntimeTurnStatus::Completed);
|
||||
let second_items = manager.store.list_items_for_turn(&turns[1].id)?;
|
||||
assert!(second_items.iter().any(|item| {
|
||||
item.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get("input_provenance"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("async_completion_wake")
|
||||
}));
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(120)).await;
|
||||
assert!(rx_op.try_recv().is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_skips_turn_that_already_drained_subagent() -> Result<()> {
|
||||
use crate::tools::subagent::{SubAgentManager, SubAgentStatus};
|
||||
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut harness = mock_engine_handle();
|
||||
let subagent_manager = Arc::new(tokio::sync::RwLock::new(SubAgentManager::new(
|
||||
thread.workspace.clone(),
|
||||
1,
|
||||
)));
|
||||
harness
|
||||
.handle
|
||||
.set_subagent_manager(subagent_manager.clone());
|
||||
manager
|
||||
.install_test_engine(&thread.id, harness.handle.clone())
|
||||
.await?;
|
||||
let mut rx_op = harness.rx_op;
|
||||
let tx_event = harness.tx_event;
|
||||
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "draining turn".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(
|
||||
tokio::time::timeout(Duration::from_secs(2), rx_op.recv()).await?,
|
||||
Some(Op::SendMessage(_))
|
||||
));
|
||||
tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: "engine_drain_1".into(),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let sub_drained = {
|
||||
let mut guard = subagent_manager.write().await;
|
||||
let id = guard.insert_test_terminal_direct_child("sub_drained", &thread.workspace);
|
||||
guard.assign_test_session_owner(&id, &thread.id);
|
||||
id
|
||||
};
|
||||
tx_event
|
||||
.send(EngineEvent::AgentComplete {
|
||||
display_name: None,
|
||||
owner_session_id: thread.id.clone(),
|
||||
id: sub_drained,
|
||||
result: "already absorbed".into(),
|
||||
outcome: Some(SubAgentStatus::Completed),
|
||||
parent_run_id: None,
|
||||
spawn_depth: Some(1),
|
||||
continuable: Some(false),
|
||||
usage: None,
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::status(
|
||||
"Resuming turn with 1 late sub-agent completion(s)",
|
||||
))
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: "already consumed child completion".to_string(),
|
||||
})
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await?;
|
||||
tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let settled = wait_for_terminal_turn(&manager, &turn.id).await?;
|
||||
assert_eq!(settled.status, RuntimeTurnStatus::Completed);
|
||||
tokio::time::sleep(Duration::from_millis(140)).await;
|
||||
assert!(rx_op.try_recv().is_err());
|
||||
assert_eq!(manager.store.list_turns_for_thread(&thread.id)?.len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn async_completion_wake_monitors_background_shell_after_turn_settlement() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let shell_manager = manager
|
||||
.thread_shell_manager(&thread.id, true)
|
||||
.await?
|
||||
.context("expected thread shell manager")?;
|
||||
|
||||
{
|
||||
let mut guard = shell_manager
|
||||
.lock()
|
||||
.map_err(|_| anyhow!("shell manager lock poisoned"))?;
|
||||
guard.execute_with_options_env_for_session(
|
||||
"sleep 0.12",
|
||||
None,
|
||||
5_000,
|
||||
true,
|
||||
None,
|
||||
false,
|
||||
None,
|
||||
HashMap::new(),
|
||||
&thread.id,
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut rx_op = harness.rx_op;
|
||||
let tx_event = harness.tx_event;
|
||||
let thread_id = thread.id.clone();
|
||||
let shell_mgr_for_mock = shell_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut index = 0usize;
|
||||
while let Some(op) = rx_op.recv().await {
|
||||
if let Op::SendMessage(spec) = op {
|
||||
index += 1;
|
||||
if index > 1 {
|
||||
assert_eq!(
|
||||
spec.provenance,
|
||||
crate::core::ops::UserInputProvenance::Runtime
|
||||
);
|
||||
if let Ok(mut guard) = shell_mgr_for_mock.lock() {
|
||||
let _ = guard.drain_finished_jobs_with_evidence_for_session(&thread_id);
|
||||
}
|
||||
}
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::TurnStarted {
|
||||
previous_source: None,
|
||||
turn_id: format!("engine_shell_{index}"),
|
||||
created_at: Utc::now(),
|
||||
route: None,
|
||||
submission_id: None,
|
||||
})
|
||||
.await;
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::MessageStarted { index: 0 })
|
||||
.await;
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::MessageDelta {
|
||||
index: 0,
|
||||
content: format!("shell step {index}"),
|
||||
})
|
||||
.await;
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::MessageComplete { index: 0 })
|
||||
.await;
|
||||
let _ = tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
source_accounting: false,
|
||||
turn_id: None,
|
||||
submission_id: None,
|
||||
compaction_id: None,
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "launch background shell".into(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let turns =
|
||||
wait_for_terminal_turn_count(&manager, &thread.id, 2, Duration::from_secs(5)).await?;
|
||||
assert_eq!(turns.len(), 2);
|
||||
assert_eq!(turns[0].status, RuntimeTurnStatus::Completed);
|
||||
assert_eq!(turns[1].status, RuntimeTurnStatus::Completed);
|
||||
let second_items = manager.store.list_items_for_turn(&turns[1].id)?;
|
||||
assert!(second_items.iter().any(|item| {
|
||||
item.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.get("input_provenance"))
|
||||
.and_then(Value::as_str)
|
||||
== Some("async_completion_wake")
|
||||
}));
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
assert_eq!(manager.store.list_turns_for_thread(&thread.id)?.len(), 2);
|
||||
Ok(())
|
||||
}
|
||||
@@ -49,6 +49,16 @@ const PARKED_POLL_CAP: Duration = Duration::from_secs(2);
|
||||
// v4 binds execution to a trusted Runtime scope. Older executors must not
|
||||
// ignore its eligibility or generation fence.
|
||||
const CURRENT_TASK_SCHEMA_VERSION: u32 = 4;
|
||||
const DENY_RULES_TASK_SCHEMA_VERSION: u32 = 5;
|
||||
const MAX_SUPPORTED_TASK_SCHEMA_VERSION: u32 = DENY_RULES_TASK_SCHEMA_VERSION;
|
||||
|
||||
const fn required_task_schema_version(disallowed_tools: &[String]) -> u32 {
|
||||
if disallowed_tools.is_empty() {
|
||||
CURRENT_TASK_SCHEMA_VERSION
|
||||
} else {
|
||||
DENY_RULES_TASK_SCHEMA_VERSION
|
||||
}
|
||||
}
|
||||
const STORE_REFRESH_INTERVAL: Duration = Duration::from_millis(200);
|
||||
/// How long a worker with nothing claimable naps between queue-fingerprint
|
||||
/// checks. An in-process notify still wakes it at once; this only bounds how
|
||||
@@ -342,6 +352,8 @@ pub struct TaskRecord {
|
||||
/// posture from that bit exactly as it always did.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permission_posture: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub status: TaskStatus,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub started_at: Option<DateTime<Utc>>,
|
||||
@@ -485,6 +497,8 @@ pub struct NewTaskRequest {
|
||||
/// Posture for the thread this task runs on. Takes precedence over
|
||||
/// `auto_approve`, which the runtime only reads when no posture is given.
|
||||
pub permission_posture: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub disallowed_tools: Vec<String>,
|
||||
pub owner_session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub destination_thread_id: Option<String>,
|
||||
@@ -505,6 +519,7 @@ impl NewTaskRequest {
|
||||
trust_mode: Some(task.trust_mode),
|
||||
auto_approve: Some(task.auto_approve),
|
||||
permission_posture: task.permission_posture.clone(),
|
||||
disallowed_tools: task.disallowed_tools.clone(),
|
||||
owner_session_id: task.owner_session_id.clone(),
|
||||
destination_thread_id: task.destination_thread_id.clone(),
|
||||
}
|
||||
@@ -525,6 +540,7 @@ impl NewTaskRequest {
|
||||
trust_mode: None,
|
||||
auto_approve: Some(true),
|
||||
permission_posture: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
owner_session_id: None,
|
||||
destination_thread_id: None,
|
||||
}
|
||||
@@ -852,6 +868,7 @@ pub struct ExecutionTask {
|
||||
trust_mode: bool,
|
||||
auto_approve: bool,
|
||||
permission_posture: Option<String>,
|
||||
disallowed_tools: Vec<String>,
|
||||
destination_thread_id: Option<String>,
|
||||
}
|
||||
|
||||
@@ -869,6 +886,7 @@ impl From<&TaskRecord> for ExecutionTask {
|
||||
trust_mode: task.trust_mode,
|
||||
auto_approve: task.auto_approve,
|
||||
permission_posture: task.permission_posture.clone(),
|
||||
disallowed_tools: task.disallowed_tools.clone(),
|
||||
destination_thread_id: task.destination_thread_id.clone(),
|
||||
}
|
||||
}
|
||||
@@ -886,6 +904,7 @@ impl ExecutionTask {
|
||||
trust_mode: Some(self.trust_mode),
|
||||
auto_approve: Some(self.auto_approve),
|
||||
permission_posture: self.permission_posture.clone(),
|
||||
disallowed_tools: self.disallowed_tools.clone(),
|
||||
task_id: Some(self.id.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -1067,6 +1086,22 @@ impl TaskExecutor for EngineTaskExecutor {
|
||||
return TaskExecutionResult::from_reason(TaskTerminalReason::Canceled, None);
|
||||
}
|
||||
let (thread_id, turn) = if let Some(destination) = task.destination_thread_id.clone() {
|
||||
if !task.disallowed_tools.is_empty() {
|
||||
match self.runtime_threads.get_thread(&destination).await {
|
||||
Ok(thread)
|
||||
if deny_rules_cover(&thread.disallowed_tools, &task.disallowed_tools) => {}
|
||||
Ok(_) => {
|
||||
return TaskExecutionResult::failed(format!(
|
||||
"Destination thread {destination} does not run under the deny rules this task was created with"
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
return TaskExecutionResult::failed(format!(
|
||||
"Destination thread {destination} cannot take this turn: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let deadline = Instant::now() + self.limits.wall_time;
|
||||
let turn = loop {
|
||||
match self
|
||||
@@ -2349,9 +2384,10 @@ impl TaskManager {
|
||||
req.auto_approve,
|
||||
)?;
|
||||
validate_preallocated_task_id(&task_id)?;
|
||||
let disallowed_tools = normalize_deny_rules(req.disallowed_tools);
|
||||
|
||||
let task = TaskRecord {
|
||||
schema_version: CURRENT_TASK_SCHEMA_VERSION,
|
||||
schema_version: required_task_schema_version(&disallowed_tools),
|
||||
// 16 random hex chars (was 8; ~60 bits of entropy once UUIDv4's
|
||||
// fixed version nibble is discounted): task ids live in durable
|
||||
// state that accumulates across restarts, and a collision
|
||||
@@ -2378,6 +2414,7 @@ impl TaskManager {
|
||||
// (GHSA-72w5-pf8h-xfp4).
|
||||
auto_approve: req.auto_approve.unwrap_or(false),
|
||||
permission_posture: req.permission_posture,
|
||||
disallowed_tools,
|
||||
status: TaskStatus::Queued,
|
||||
created_at: Utc::now(),
|
||||
started_at: None,
|
||||
@@ -3877,6 +3914,12 @@ impl TaskManager {
|
||||
|
||||
fn persist_task_locked(&self, task: &TaskRecord) -> Result<()> {
|
||||
let path = self.tasks_dir.join(format!("{}.json", task.id));
|
||||
let min_version = required_task_schema_version(&task.disallowed_tools);
|
||||
if task.schema_version < min_version {
|
||||
let mut updated = task.clone();
|
||||
updated.schema_version = min_version;
|
||||
return write_json_atomic(&path, &updated);
|
||||
}
|
||||
write_json_atomic(&path, task)
|
||||
}
|
||||
}
|
||||
@@ -3898,12 +3941,40 @@ fn read_bound_task_file(path: &Path, task_id: &str) -> Result<Option<TaskRecord>
|
||||
Err(error) => return Err(error).context("read bound task"),
|
||||
};
|
||||
let task: TaskRecord = serde_json::from_slice(&bytes).context("decode bound task")?;
|
||||
if task.id != task_id || task.schema_version > CURRENT_TASK_SCHEMA_VERSION {
|
||||
if task.id != task_id || task.schema_version > MAX_SUPPORTED_TASK_SCHEMA_VERSION {
|
||||
bail!("Bound task identity or schema does not match its durable admission");
|
||||
}
|
||||
Ok(Some(task))
|
||||
}
|
||||
|
||||
pub fn normalize_deny_rules(rules: Vec<String>) -> Vec<String> {
|
||||
let mut seen = Vec::with_capacity(rules.len());
|
||||
for rule in rules {
|
||||
let rule = rule.trim();
|
||||
if !rule.is_empty() && !seen.iter().any(|existing: &String| existing == rule) {
|
||||
seen.push(rule.to_string());
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
fn deny_rule_covers(held: &str, required: &str) -> bool {
|
||||
let held = held.to_ascii_lowercase();
|
||||
let required = required.to_ascii_lowercase();
|
||||
match (held.strip_suffix('*'), required.strip_suffix('*')) {
|
||||
(Some(held_prefix), Some(required_prefix)) => required_prefix.starts_with(held_prefix),
|
||||
(Some(held_prefix), None) => required.starts_with(held_prefix),
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => held == required,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn deny_rules_cover(held: &[String], required: &[String]) -> bool {
|
||||
required
|
||||
.iter()
|
||||
.all(|rule| held.iter().any(|held| deny_rule_covers(held, rule)))
|
||||
}
|
||||
|
||||
pub fn validate_bound_task_request(task: &TaskRecord, request: &NewTaskRequest) -> Result<()> {
|
||||
if task.prompt != request.prompt.trim()
|
||||
|| task.owner_session_id != request.owner_session_id
|
||||
@@ -3933,6 +4004,7 @@ pub fn validate_bound_task_request(task: &TaskRecord, request: &NewTaskRequest)
|
||||
.as_deref()
|
||||
.is_some_and(|value| Some(value) != task.permission_posture.as_deref())
|
||||
|| task.auto_approve != request.auto_approve.unwrap_or(false)
|
||||
|| task.disallowed_tools != normalize_deny_rules(request.disallowed_tools.clone())
|
||||
{
|
||||
bail!("Task admission replay does not match the bound request");
|
||||
}
|
||||
@@ -3961,11 +4033,11 @@ fn load_state(tasks_dir: &Path, queue_path: &Path) -> Result<LoadedTaskState> {
|
||||
.with_context(|| format!("Failed to read task file {}", path.display()))?;
|
||||
let task: TaskRecord = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse task file {}", path.display()))?;
|
||||
if task.schema_version > CURRENT_TASK_SCHEMA_VERSION {
|
||||
if task.schema_version > MAX_SUPPORTED_TASK_SCHEMA_VERSION {
|
||||
bail!(
|
||||
"Task schema v{} is newer than supported v{}",
|
||||
task.schema_version,
|
||||
CURRENT_TASK_SCHEMA_VERSION
|
||||
MAX_SUPPORTED_TASK_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
ensure_safe_storage_id("task id", &task.id)?;
|
||||
@@ -4282,6 +4354,33 @@ mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use tokio::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn deny_rules_cover_only_when_every_required_rule_is_held() {
|
||||
let rules = |items: &[&str]| items.iter().map(ToString::to_string).collect::<Vec<_>>();
|
||||
assert!(deny_rules_cover(&[], &[]));
|
||||
assert!(!deny_rules_cover(&[], &rules(&["mcp_secret_*"])));
|
||||
assert!(deny_rules_cover(
|
||||
&rules(&["MCP_secret_*", "Bash"]),
|
||||
&rules(&["mcp_secret_*"])
|
||||
));
|
||||
assert!(deny_rules_cover(
|
||||
&rules(&["mcp_*"]),
|
||||
&rules(&["mcp_secret_*", "mcp_secret_read"])
|
||||
));
|
||||
assert!(!deny_rules_cover(
|
||||
&rules(&["mcp_secret_read"]),
|
||||
&rules(&["mcp_secret_*"])
|
||||
));
|
||||
assert!(!deny_rules_cover(
|
||||
&rules(&["mcp_secret_*"]),
|
||||
&rules(&["mcp_*"])
|
||||
));
|
||||
assert_eq!(
|
||||
normalize_deny_rules(rules(&[" a ", "", "a", "b*"])),
|
||||
rules(&["a", "b*"])
|
||||
);
|
||||
}
|
||||
|
||||
struct MockExecutor;
|
||||
|
||||
/// Poll until the task is claimed as `Running`, or fail at `timeout`.
|
||||
@@ -5761,6 +5860,7 @@ mod tests {
|
||||
summary: "Task started".to_string(),
|
||||
detail_path: None,
|
||||
}],
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
fs::write(
|
||||
tasks_dir.join(format!("{task_id}.json")),
|
||||
@@ -5929,6 +6029,7 @@ mod tests {
|
||||
auto_approve: None,
|
||||
permission_posture: None,
|
||||
owner_session_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
let task = manager.add_task(req).await?;
|
||||
|
||||
@@ -6459,6 +6560,7 @@ mod tests {
|
||||
github_events: Vec::new(),
|
||||
tool_calls: Vec::new(),
|
||||
timeline: Vec::new(),
|
||||
disallowed_tools: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7079,6 +7181,34 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_with_deny_rules_refuses_a_destination_thread_that_lacks_them() -> Result<()> {
|
||||
let runtime = Arc::new(test_runtime_manager().await?);
|
||||
let open = runtime
|
||||
.create_thread(CreateThreadRequest::default())
|
||||
.await?;
|
||||
let mut record = sample_task_record();
|
||||
record.destination_thread_id = Some(open.id.clone());
|
||||
record.disallowed_tools = vec!["mcp_secret_*".to_string()];
|
||||
let (tx, rx) = mpsc::channel(64);
|
||||
tokio::spawn(drain_task_events(rx));
|
||||
let executor =
|
||||
EngineTaskExecutor::new(runtime, TaskExecutionLimits::short_for_tests(), false);
|
||||
let result = executor
|
||||
.execute(ExecutionTask::from(&record), tx, CancellationToken::new())
|
||||
.await;
|
||||
assert_eq!(result.status, TaskStatus::Failed);
|
||||
assert!(
|
||||
result
|
||||
.error
|
||||
.as_deref()
|
||||
.is_some_and(|error| error.contains("deny rules")),
|
||||
"{:?}",
|
||||
result.error
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn engine_turn_without_terminal_event_idle_times_out() -> Result<()> {
|
||||
let runtime = test_runtime_manager().await?;
|
||||
@@ -8480,6 +8610,70 @@ mod tests {
|
||||
assert!(error.is_some_and(|message| message.contains(path)));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn denied_task_uses_v5_schema_while_open_task_stays_v4() -> Result<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().to_path_buf();
|
||||
let manager =
|
||||
TaskManager::start_with_executor(test_config(root.clone()), Arc::new(MockExecutor))
|
||||
.await?;
|
||||
|
||||
let open = manager
|
||||
.add_task(NewTaskRequest {
|
||||
disallowed_tools: vec![" ".to_string()],
|
||||
..NewTaskRequest::from_prompt("open task")
|
||||
})
|
||||
.await?;
|
||||
let mut restricted_req = NewTaskRequest::from_prompt("restricted task");
|
||||
restricted_req.disallowed_tools = vec!["shell".to_string(), " shell ".to_string()];
|
||||
let restricted = manager.add_task(restricted_req).await?;
|
||||
|
||||
assert_eq!(open.schema_version, CURRENT_TASK_SCHEMA_VERSION);
|
||||
assert!(open.disallowed_tools.is_empty());
|
||||
assert_eq!(restricted.schema_version, DENY_RULES_TASK_SCHEMA_VERSION);
|
||||
assert_eq!(restricted.disallowed_tools, vec!["shell"]);
|
||||
|
||||
let _ = wait_for_terminal_state(&manager, &open.id, Duration::from_secs(10)).await?;
|
||||
let _ = wait_for_terminal_state(&manager, &restricted.id, Duration::from_secs(10)).await?;
|
||||
manager.shutdown_and_wait().await?;
|
||||
drop(manager);
|
||||
|
||||
let open_json: serde_json::Value = serde_json::from_str(&fs::read_to_string(
|
||||
root.join("tasks").join(format!("{}.json", open.id)),
|
||||
)?)?;
|
||||
assert_eq!(open_json["schema_version"], CURRENT_TASK_SCHEMA_VERSION);
|
||||
assert!(open_json.get("disallowed_tools").is_none());
|
||||
|
||||
let restricted_path = root.join("tasks").join(format!("{}.json", restricted.id));
|
||||
let mut restricted_json: serde_json::Value =
|
||||
serde_json::from_str(&fs::read_to_string(&restricted_path)?)?;
|
||||
assert_eq!(
|
||||
restricted_json["schema_version"],
|
||||
DENY_RULES_TASK_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(restricted_json["disallowed_tools"], json!(["shell"]));
|
||||
|
||||
let reloaded =
|
||||
TaskManager::start_with_executor(test_config(root.clone()), Arc::new(MockExecutor))
|
||||
.await?;
|
||||
let reloaded_task = reloaded.get_task(&restricted.id).await?;
|
||||
assert_eq!(reloaded_task.schema_version, DENY_RULES_TASK_SCHEMA_VERSION);
|
||||
assert_eq!(reloaded_task.disallowed_tools, vec!["shell"]);
|
||||
reloaded.shutdown_and_wait().await?;
|
||||
drop(reloaded);
|
||||
|
||||
restricted_json["schema_version"] = json!(MAX_SUPPORTED_TASK_SCHEMA_VERSION + 1);
|
||||
fs::write(
|
||||
&restricted_path,
|
||||
serde_json::to_string_pretty(&restricted_json)?,
|
||||
)?;
|
||||
match TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await {
|
||||
Ok(_) => panic!("manager should reject task schema v6"),
|
||||
Err(err) => assert!(err.to_string().contains("newer than supported"), "{err:#}"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -82,13 +82,7 @@ pub fn build_approval_key(tool_name: &str, input: &serde_json::Value) -> Approva
|
||||
| "exec_shell_wait"
|
||||
| "exec_shell_interact"
|
||||
| "exec_wait"
|
||||
| "exec_interact" => {
|
||||
let mut execution_input = input.clone();
|
||||
if let Some(fields) = execution_input.as_object_mut() {
|
||||
fields.remove("description");
|
||||
}
|
||||
format!("shell:{tool_name}:{}", hash_json_value(&execution_input))
|
||||
}
|
||||
| "exec_interact" => exact_shell_call_key(tool_name, input),
|
||||
"fetch_url" | "web.fetch" | "web_fetch" => {
|
||||
let host = parse_host(input);
|
||||
format!("net:{host}")
|
||||
@@ -113,6 +107,9 @@ pub fn build_approval_grouping_key(tool_name: &str, input: &serde_json::Value) -
|
||||
let paths_hash = hash_patch_paths(input);
|
||||
format!("patch:{paths_hash}")
|
||||
}
|
||||
"exec_shell" | "task_shell_start" if shell_call_carries_stdin(input) => {
|
||||
exact_shell_call_key(tool_name, input)
|
||||
}
|
||||
"exec_shell" | "task_shell_start" => shell_command_grant_scope(input),
|
||||
// Interact and wait calls carry no command, only input for a live
|
||||
// session. Keying them on the (empty) command prefix gave every one
|
||||
@@ -447,6 +444,20 @@ pub fn computer_use_batch_hidden_gate(tool_name: &str, input: &Value) -> Option<
|
||||
})
|
||||
}
|
||||
|
||||
fn exact_shell_call_key(tool_name: &str, input: &serde_json::Value) -> String {
|
||||
let mut execution_input = input.clone();
|
||||
if let Some(fields) = execution_input.as_object_mut() {
|
||||
fields.remove("description");
|
||||
}
|
||||
format!("shell:{tool_name}:{}", hash_json_value(&execution_input))
|
||||
}
|
||||
|
||||
fn shell_call_carries_stdin(input: &serde_json::Value) -> bool {
|
||||
["stdin", "input", "data"]
|
||||
.iter()
|
||||
.any(|key| input.get(*key).is_some_and(|value| !value.is_null()))
|
||||
}
|
||||
|
||||
/// The session-grant scope for a shell command.
|
||||
///
|
||||
/// A simple command whose family is in the arity dictionary keeps the
|
||||
@@ -1021,6 +1032,50 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_run_grant_does_not_cover_a_different_stdin() {
|
||||
for tool in ["Bash", "bash", "exec_shell", "task_shell_start"] {
|
||||
let plain = json!({"command": "python3 -"});
|
||||
let hello = json!({"command": "python3 -", "stdin": "print(1)"});
|
||||
let other = json!({"command": "python3 -", "stdin": "print(2)"});
|
||||
let granted = build_approval_grouping_key(tool, &plain);
|
||||
let with_stdin = build_approval_grouping_key(tool, &hello);
|
||||
assert_ne!(granted, with_stdin, "{tool}: stdin is part of what runs");
|
||||
assert_ne!(
|
||||
with_stdin,
|
||||
build_approval_grouping_key(tool, &other),
|
||||
"{tool}: different stdin must not share a grant"
|
||||
);
|
||||
assert_eq!(
|
||||
with_stdin,
|
||||
build_approval_grouping_key(tool, &hello),
|
||||
"{tool}: the same call keeps its grant"
|
||||
);
|
||||
for alias in ["input", "data"] {
|
||||
let aliased = json!({"command": "python3 -", alias: "print(2)"});
|
||||
assert_ne!(
|
||||
granted,
|
||||
build_approval_grouping_key(tool, &aliased),
|
||||
"{tool}: `{alias}` carries stdin too"
|
||||
);
|
||||
assert_ne!(
|
||||
with_stdin,
|
||||
build_approval_grouping_key(tool, &aliased),
|
||||
"{tool}: `{alias}` input is not the `stdin` input of another call"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
granted,
|
||||
build_approval_grouping_key(tool, &json!({"command": "python3 -", "stdin": null})),
|
||||
"{tool}: a null stdin is no stdin"
|
||||
);
|
||||
}
|
||||
let family_plain = build_approval_grouping_key("Bash", &json!({"command": "git status"}));
|
||||
let family_stdin =
|
||||
build_approval_grouping_key("Bash", &json!({"command": "git status", "stdin": "x"}));
|
||||
assert_ne!(family_plain, family_stdin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_key_grants_mcp_tools_as_reviewed_kinds() {
|
||||
// A session grant for a reviewed plugin MCP tool is the kind
|
||||
|
||||
@@ -165,7 +165,7 @@ fn generic_summary(locale: Locale, name: &str, input: &Value, workspace: Option<
|
||||
fn is_delegated_work_tool(tool_name: &str) -> bool {
|
||||
matches!(
|
||||
tool_name,
|
||||
"task_create" | "automation_create" | "automation_update"
|
||||
"task_create" | "automation_create" | "automation_update" | "send_later"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -728,6 +728,13 @@ mod tests {
|
||||
assert!(summary.contains("Auto-approve: on"), "{summary}");
|
||||
assert!(summary.contains("Workspace: /x"), "{summary}");
|
||||
|
||||
let summary = approval_summary(
|
||||
"send_later",
|
||||
&json!({"action": "schedule", "delay_minutes": 5, "message": "m", "workspace": "/elsewhere"}),
|
||||
None,
|
||||
);
|
||||
assert!(summary.contains("Workspace: /elsewhere"), "{summary}");
|
||||
|
||||
let zh = approval_summary_in(
|
||||
Locale::ZhHans,
|
||||
"automation",
|
||||
|
||||
@@ -357,6 +357,7 @@ impl AutomationTool {
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
delivery_mode: optional_delivery_mode(input)?,
|
||||
disallowed_tools: context.disallowed_tools.clone(),
|
||||
status: Some(
|
||||
if input
|
||||
.get("paused")
|
||||
@@ -568,6 +569,15 @@ fn require_within_session_authority(
|
||||
automation: &AutomationRecord,
|
||||
verb: &str,
|
||||
) -> Result<(), ToolError> {
|
||||
if !crate::task_manager::deny_rules_cover(
|
||||
&automation.disallowed_tools,
|
||||
&context.disallowed_tools,
|
||||
) {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"automation: cannot {verb} `{}`: it was created without every tool deny rule this session runs under. Ask the user to {verb} it from a session that holds that access.",
|
||||
automation.id,
|
||||
)));
|
||||
}
|
||||
let stored = [
|
||||
("allow_shell", automation.allow_shell),
|
||||
("trust_mode", automation.trust_mode),
|
||||
@@ -750,6 +760,70 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::tools::spec::ToolSpec;
|
||||
|
||||
#[tokio::test]
|
||||
async fn stored_automation_carries_the_creating_session_deny_rules() {
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
let manager = crate::automation_manager::AutomationManager::open_for_test(
|
||||
workspace.path().join("automations"),
|
||||
)
|
||||
.expect("automation manager");
|
||||
let shared = std::sync::Arc::new(tokio::sync::Mutex::new(manager));
|
||||
let tool = AutomationTool::new("automation");
|
||||
let mut restricted = ToolContext::new(workspace.path());
|
||||
restricted.runtime.automations = Some(shared.clone());
|
||||
restricted.disallowed_tools = vec!["mcp_secret_*".to_string()];
|
||||
let mut open = ToolContext::new(workspace.path());
|
||||
open.runtime.automations = Some(shared.clone());
|
||||
let create = |name: &str| {
|
||||
json!({
|
||||
"action": "create",
|
||||
"name": name,
|
||||
"prompt": "summarise the day",
|
||||
"rrule": "FREQ=HOURLY",
|
||||
"paused": true
|
||||
})
|
||||
};
|
||||
|
||||
let created = tool
|
||||
.execute(create("restricted"), &restricted)
|
||||
.await
|
||||
.expect("create accepted");
|
||||
let record: Value = serde_json::from_str(&created.content).expect("record json");
|
||||
assert_eq!(
|
||||
record["disallowed_tools"],
|
||||
json!(["mcp_secret_*"]),
|
||||
"{record}"
|
||||
);
|
||||
let restricted_id = record["id"].as_str().expect("id").to_string();
|
||||
|
||||
let created = tool
|
||||
.execute(create("open"), &open)
|
||||
.await
|
||||
.expect("create accepted");
|
||||
let record: Value = serde_json::from_str(&created.content).expect("record json");
|
||||
assert!(record.get("disallowed_tools").is_none(), "{record}");
|
||||
let open_id = record["id"].as_str().expect("id").to_string();
|
||||
|
||||
tool.execute(
|
||||
json!({"action": "update", "automation_id": restricted_id, "prompt": "again"}),
|
||||
&restricted,
|
||||
)
|
||||
.await
|
||||
.expect("a session may edit what runs under its own rules");
|
||||
for action in ["update", "resume"] {
|
||||
let refused = tool
|
||||
.execute(
|
||||
json!({"action": action, "automation_id": open_id, "prompt": "inject"}),
|
||||
&restricted,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
refused.is_err(),
|
||||
"{action}: an automation without the session's deny rules is out of reach"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_and_update_cap_requested_authority_at_the_session() {
|
||||
let workspace = tempfile::tempdir().expect("workspace");
|
||||
|
||||
@@ -730,6 +730,10 @@ impl CodemodeInvoker {
|
||||
extra_refusal: ExtraRefusal,
|
||||
) -> Self {
|
||||
context.execution.nested_call_gate = None;
|
||||
context.execution.strict_workspace_confinement = true;
|
||||
context.execution.trust_mode = false;
|
||||
context.execution.follow_symlinks = false;
|
||||
context.execution.trusted_external_paths.clear();
|
||||
Self::with_gate(
|
||||
specs,
|
||||
context,
|
||||
|
||||
@@ -54,13 +54,13 @@
|
||||
//! forks a process, which `analyst`/`scout` were never granted), and
|
||||
//! anything that can name a program is held to the raw-shell bar. Every
|
||||
//! consumer of that contract — the catalog filter, the dispatch guard, and
|
||||
//! `reject_unbounded_verification` / `is_delegated_builtin_verification` in
|
||||
//! [`crate::tools::subagent`] — reads this one classifier rather than
|
||||
//! re-deriving it.
|
||||
//! `reject_unbounded_verification` in [`crate::tools::subagent`] — reads this
|
||||
//! one classifier rather than re-deriving it.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::tools::spec::{ApprovalRequirement, ToolCapability, ToolSpec};
|
||||
use codewhale_agent_contract::worker_profile::ShellGrant;
|
||||
use codewhale_execpolicy::canonical_action::canonical_action_alias;
|
||||
|
||||
/// The execution authority a child actually holds, read off the runtime posture
|
||||
@@ -477,6 +477,15 @@ pub fn enforce_execution_envelope(
|
||||
}
|
||||
|
||||
pub fn tool_call_denied(rules: Option<&[String]>, name: &str, input: &Value) -> bool {
|
||||
tool_call_denied_for(rules, name, input, false)
|
||||
}
|
||||
|
||||
pub fn tool_call_denied_for(
|
||||
rules: Option<&[String]>,
|
||||
name: &str,
|
||||
input: &Value,
|
||||
holds_bounded_verification: bool,
|
||||
) -> bool {
|
||||
let action = canonical_action_alias(name, input);
|
||||
codewhale_execpolicy::tool_rules::tool_denied(rules, name)
|
||||
|| codewhale_execpolicy::tool_rules::tool_denied(rules, action)
|
||||
@@ -486,12 +495,40 @@ pub fn tool_call_denied(rules: Option<&[String]>, name: &str, input: &Value) ->
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|url| !url.trim().is_empty())
|
||||
&& codewhale_execpolicy::tool_rules::tool_denied(rules, "fetch_url"))
|
||||
|| (matches!(
|
||||
classify_verification(action, input),
|
||||
Some(VerificationBound::Unbounded)
|
||||
) && rules.is_some_and(|rules| {
|
||||
codewhale_execpolicy::tool_rules::tool_matches_any_rule(rules, "Bash")
|
||||
}))
|
||||
|| (starts_workspace_execution(name, action, input, holds_bounded_verification)
|
||||
&& rules.is_some_and(|rules| {
|
||||
codewhale_execpolicy::tool_rules::tool_matches_any_rule(rules, "Bash")
|
||||
}))
|
||||
}
|
||||
|
||||
fn starts_workspace_execution(
|
||||
name: &str,
|
||||
action: &str,
|
||||
input: &Value,
|
||||
holds_bounded_verification: bool,
|
||||
) -> bool {
|
||||
match classify_verification(action, input) {
|
||||
Some(VerificationBound::Unbounded) => true,
|
||||
Some(VerificationBound::Default | VerificationBound::Filter) => !holds_bounded_verification,
|
||||
None => {
|
||||
(name.eq_ignore_ascii_case("send_later")
|
||||
&& input.get("action").and_then(Value::as_str) == Some("schedule"))
|
||||
|| (name.eq_ignore_ascii_case("workflow")
|
||||
&& input.get("verify").and_then(Value::as_bool) == Some(true)
|
||||
&& !holds_bounded_verification)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn holds_bounded_verification(context: &crate::tools::spec::ToolContext) -> bool {
|
||||
context
|
||||
.child_host
|
||||
.as_ref()
|
||||
.is_some_and(|child| child.grant.shell == ShellGrant::Verify)
|
||||
|| context.tool_authority.as_deref().is_some_and(|authority| {
|
||||
authority.verification == crate::tools::spec::ToolVerificationAuthority::Bounded
|
||||
})
|
||||
}
|
||||
|
||||
/// Repeat the command ceiling at native dispatch and direct delegation sinks.
|
||||
@@ -507,7 +544,14 @@ pub fn enforce_tool_denial(
|
||||
&& context.owner_agent_id.is_some()
|
||||
&& context.shell_policy == codewhale_agent_contract::worker_profile::ShellPolicy::ReadOnly
|
||||
&& crate::tools::shell::agent_readonly_bash_input(input);
|
||||
if !bounded_child_read && tool_call_denied(Some(&context.disallowed_tools), name, input) {
|
||||
if !bounded_child_read
|
||||
&& tool_call_denied_for(
|
||||
Some(&context.disallowed_tools),
|
||||
name,
|
||||
input,
|
||||
holds_bounded_verification(context),
|
||||
)
|
||||
{
|
||||
return Err(crate::tools::spec::ToolError::permission_denied(format!(
|
||||
"Tool '{name}' or its execution dependency is in the disallowed-tools list"
|
||||
)));
|
||||
|
||||
+604
-45
@@ -47,15 +47,13 @@ pub fn content_hash(bytes: &[u8]) -> String {
|
||||
/// never has the full contents to hash. Digesting through a separate streaming
|
||||
/// pass keeps that memory bound while still producing a hash over the entire
|
||||
/// file — the only value an edit guard can verify against.
|
||||
fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
fn hash_reader_streaming(reader: &mut impl std::io::Read) -> std::io::Result<String> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read as _;
|
||||
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0_u8; 64 * 1024];
|
||||
loop {
|
||||
let read = file.read(&mut buf)?;
|
||||
let read = reader.read(&mut buf)?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
@@ -67,6 +65,11 @@ fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
))
|
||||
}
|
||||
|
||||
fn hash_file_streaming(path: &Path) -> std::io::Result<String> {
|
||||
let mut file = fs::File::open(path)?;
|
||||
hash_reader_streaming(&mut file)
|
||||
}
|
||||
|
||||
/// The one-line header that reports a snapshot hash to the model.
|
||||
///
|
||||
/// This goes in `ToolResult::content`, not in `ToolResult::metadata`, and that
|
||||
@@ -683,6 +686,196 @@ async fn run_blocking_write_atomic(path: &Path, contents: Vec<u8>) -> Result<(),
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ConfinedFileId {
|
||||
#[cfg(unix)]
|
||||
Unix { dev: u64, ino: u64 },
|
||||
#[cfg(windows)]
|
||||
Windows { volume: u32, index: u64 },
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
fn confined_file_id(file: &fs::File) -> std::io::Result<ConfinedFileId> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let meta = file.metadata()?;
|
||||
Ok(ConfinedFileId::Unix {
|
||||
dev: meta.dev(),
|
||||
ino: meta.ino(),
|
||||
})
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let identity = codewhale_util::path_identity::windows_file_identity(file)?;
|
||||
Ok(ConfinedFileId::Windows {
|
||||
volume: identity.volume,
|
||||
index: identity.index,
|
||||
})
|
||||
}
|
||||
#[cfg(all(not(unix), not(windows)))]
|
||||
{
|
||||
let _ = file;
|
||||
Err(std::io::ErrorKind::Unsupported.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn is_confined_policy_error(error: &std::io::Error) -> bool {
|
||||
#[cfg(unix)]
|
||||
if error.raw_os_error() == Some(libc::ELOOP) {
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::InvalidInput
|
||||
| std::io::ErrorKind::InvalidData
|
||||
| std::io::ErrorKind::AlreadyExists
|
||||
| std::io::ErrorKind::PermissionDenied
|
||||
)
|
||||
}
|
||||
|
||||
fn map_confined_io_error(error: std::io::Error, path: &Path, action: &str) -> ToolError {
|
||||
if is_confined_policy_error(&error) {
|
||||
return ToolError::permission_denied(format!(
|
||||
"{action} refused symlink or confined path constraint for {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
ToolError::execution_failed(format!("Failed to {action} {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
enum ConfinedPrior {
|
||||
Missing,
|
||||
Existing { bytes: Vec<u8>, id: ConfinedFileId },
|
||||
}
|
||||
|
||||
fn write_uses_confined_io(context: &ToolContext) -> bool {
|
||||
context.strict_workspace_confinement
|
||||
|| (!context.trust_mode
|
||||
&& !context.follow_symlinks
|
||||
&& context.trusted_external_paths.is_empty())
|
||||
}
|
||||
|
||||
async fn resolve_confined_target_async(
|
||||
context: &ToolContext,
|
||||
raw: &str,
|
||||
allow_root_dot: bool,
|
||||
) -> Result<(PathBuf, PathBuf), ToolError> {
|
||||
let workspace_canonical =
|
||||
tokio::fs::canonicalize(&context.workspace)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to canonicalize workspace {}: {error}",
|
||||
context.workspace.display()
|
||||
))
|
||||
})?;
|
||||
let relative =
|
||||
super::spec::resolve_confined_target(context, &workspace_canonical, raw, allow_root_dot)?;
|
||||
Ok((workspace_canonical, relative))
|
||||
}
|
||||
|
||||
async fn load_confined_prior_file(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
max_bytes: Option<usize>,
|
||||
context: &ToolContext,
|
||||
) -> Result<ConfinedPrior, ToolError> {
|
||||
check_file_operation_cancelled(context)?;
|
||||
let workspace = workspace_canonical.to_path_buf();
|
||||
let relative = relative.to_path_buf();
|
||||
let display = display_path.to_path_buf();
|
||||
let cancel = context.cancel_token.clone();
|
||||
let mut worker = tokio::task::spawn_blocking(move || {
|
||||
use std::io::Read as _;
|
||||
check_contract_cancelled(cancel.as_ref())?;
|
||||
let ws =
|
||||
match codewhale_util::files::WorkspaceFile::open_shared(&workspace, &relative, false) {
|
||||
Ok(ws) => ws,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(ConfinedPrior::Missing);
|
||||
}
|
||||
Err(error) => return Err(map_confined_io_error(error, &display, "inspect")),
|
||||
};
|
||||
let mut file = match ws.open_file() {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(ConfinedPrior::Missing);
|
||||
}
|
||||
Err(error) => return Err(map_confined_io_error(error, &display, "read")),
|
||||
};
|
||||
let id = confined_file_id(&file)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "inspect"))?;
|
||||
let bytes = if let Some(cap) = max_bytes {
|
||||
let meta = file
|
||||
.metadata()
|
||||
.map_err(|error| map_confined_io_error(error, &display, "read"))?;
|
||||
if meta.len() > cap as u64 {
|
||||
check_contract_file_size(CONTRACT_FILE_MAX_BYTES + 1)?;
|
||||
}
|
||||
read_contract_source(&mut file, cancel.as_ref())?
|
||||
} else {
|
||||
let mut bytes = Vec::new();
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "read"))?;
|
||||
bytes
|
||||
};
|
||||
Ok(ConfinedPrior::Existing { bytes, id })
|
||||
});
|
||||
let result = if let Some(cancel) = context.cancel_token.as_ref() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(ToolError::cancelled("Operation aborted")),
|
||||
result = &mut worker => result,
|
||||
}
|
||||
} else {
|
||||
worker.await
|
||||
};
|
||||
result.map_err(|error| ToolError::execution_failed(format!("File read task: {error}")))?
|
||||
}
|
||||
|
||||
async fn commit_confined_write(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
expected_id: Option<ConfinedFileId>,
|
||||
contents: Vec<u8>,
|
||||
) -> Result<(), ToolError> {
|
||||
let workspace = workspace_canonical.to_path_buf();
|
||||
let relative = relative.to_path_buf();
|
||||
let display = display_path.to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let create = expected_id.is_none();
|
||||
let ws = codewhale_util::files::WorkspaceFile::open_shared(&workspace, &relative, create)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
match expected_id {
|
||||
None => ws
|
||||
.publish(&contents)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write")),
|
||||
Some(prior_id) => {
|
||||
let probe = ws
|
||||
.identity_probe()
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
let current_id = confined_file_id(&probe)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))?;
|
||||
if current_id != prior_id {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"write target changed before replacement: {}",
|
||||
display.display()
|
||||
)));
|
||||
}
|
||||
drop(probe);
|
||||
ws.replace(&contents)
|
||||
.map_err(|error| map_confined_io_error(error, &display, "write"))
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|error| ToolError::execution_failed(format!("File write task: {error}")))?
|
||||
}
|
||||
|
||||
fn check_file_operation_cancelled(context: &ToolContext) -> Result<(), ToolError> {
|
||||
check_contract_cancelled(context.cancel_token.as_ref())
|
||||
}
|
||||
@@ -1024,6 +1217,9 @@ impl ToolSpec for ReadFileTool {
|
||||
READ_PARAMS.reject_unknown(&input)?;
|
||||
|
||||
let path_str = required_str(&input, "path")?;
|
||||
if context.strict_workspace_confinement {
|
||||
return execute_confined_read(&input, path_str, context).await;
|
||||
}
|
||||
// S1/F2: raw spelling first, resolved path after — see the matching
|
||||
// comment in `execute_contract_read`. Only the raw-spelling denial can
|
||||
// promise an error that never names the symlink target's location.
|
||||
@@ -1163,7 +1359,7 @@ impl ToolSpec for ReadFileTool {
|
||||
e
|
||||
))
|
||||
})?;
|
||||
let (window, total_lines) = read_window_streaming(file, start_line, max_lines)
|
||||
let (window, total_lines) = read_window_streaming(&file, start_line, max_lines)
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {}",
|
||||
@@ -1221,6 +1417,244 @@ impl ToolSpec for ReadFileTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_read_range(input: &Value) -> Result<(usize, usize), ToolError> {
|
||||
let start_line = match optional_u64(input, "start_line", 1)? {
|
||||
0 => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"start_line must be 1-based and greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
v => usize::try_from(v).map_err(|_| {
|
||||
ToolError::invalid_input("start_line exceeds platform addressable range".to_string())
|
||||
})?,
|
||||
};
|
||||
let max_lines = match optional_u64(input, "max_lines", DEFAULT_READ_LINES as u64)? {
|
||||
0 => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"max_lines must be greater than 0".to_string(),
|
||||
));
|
||||
}
|
||||
v => {
|
||||
let converted = usize::try_from(v).map_err(|_| {
|
||||
ToolError::invalid_input("max_lines exceeds platform addressable range".to_string())
|
||||
})?;
|
||||
std::cmp::min(converted, HARD_MAX_READ_LINES)
|
||||
}
|
||||
};
|
||||
Ok((start_line, max_lines))
|
||||
}
|
||||
|
||||
enum ConfinedReadOutput {
|
||||
Pdf {
|
||||
id: ConfinedFileId,
|
||||
},
|
||||
Image {
|
||||
id: ConfinedFileId,
|
||||
},
|
||||
Small(String),
|
||||
Window {
|
||||
window: Vec<String>,
|
||||
total_lines: usize,
|
||||
start_line: usize,
|
||||
max_lines: usize,
|
||||
hash: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn is_confined_pdf_handle(file: &mut fs::File, path: &Path) -> std::io::Result<bool> {
|
||||
use std::io::{Read as _, Seek as _};
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("pdf"))
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
let mut buf = [0u8; 4];
|
||||
let matched = file.read_exact(&mut buf).is_ok() && &buf == b"%PDF";
|
||||
file.seek(std::io::SeekFrom::Start(0))?;
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
async fn execute_confined_read(
|
||||
input: &Value,
|
||||
path_str: &str,
|
||||
context: &ToolContext,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
enforce_read_denylist(Path::new(path_str), "read_file")?;
|
||||
let (workspace_canonical, relative) =
|
||||
resolve_confined_target_async(context, path_str, false).await?;
|
||||
let file_path = workspace_canonical.join(&relative);
|
||||
if is_codewhale_credential_path(&file_path) {
|
||||
return Err(ToolError::permission_denied(
|
||||
"read_file cannot expose Codewhale configuration or credential-store files; use `codewhale config list` or `codewhale auth status` for safe inspection"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
enforce_read_denylist(&file_path, "read_file")?;
|
||||
let pages = optional_str(input, "pages")?;
|
||||
let explicit_range = input
|
||||
.get("start_line")
|
||||
.or_else(|| input.get("max_lines"))
|
||||
.is_some();
|
||||
|
||||
let output = tokio::task::spawn_blocking({
|
||||
let workspace_canonical = workspace_canonical.clone();
|
||||
let relative = relative.clone();
|
||||
let file_path = file_path.clone();
|
||||
let input_clone = input.clone();
|
||||
move || {
|
||||
use std::io::{Read as _, Seek as _};
|
||||
let ws = codewhale_util::files::WorkspaceFile::open_shared(
|
||||
&workspace_canonical,
|
||||
&relative,
|
||||
false,
|
||||
)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let mut file = ws
|
||||
.open_file_shared()
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let id = confined_file_id(&file)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
if is_confined_pdf_handle(&mut file, &file_path)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?
|
||||
{
|
||||
return Ok::<_, ToolError>(ConfinedReadOutput::Pdf { id });
|
||||
}
|
||||
if is_image_for_ocr(&file_path) {
|
||||
return Ok(ConfinedReadOutput::Image { id });
|
||||
}
|
||||
let file_bytes = file.metadata().map(|meta| meta.len()).unwrap_or(u64::MAX);
|
||||
if !explicit_range && file_bytes <= SMALL_FILE_BYTES as u64 {
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
return Ok(ConfinedReadOutput::Small(contents));
|
||||
}
|
||||
let (start_line, max_lines) = parse_read_range(&input_clone)?;
|
||||
let (window, total_lines) = read_window_streaming(&file, start_line, max_lines)
|
||||
.map_err(|error| map_confined_io_error(error, &file_path, "read"))?;
|
||||
let hash = if file.seek(std::io::SeekFrom::Start(0)).is_ok() {
|
||||
hash_reader_streaming(&mut file).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(ConfinedReadOutput::Window {
|
||||
window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash,
|
||||
})
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("File read task: {e}")))??;
|
||||
|
||||
match output {
|
||||
ConfinedReadOutput::Pdf { id } => {
|
||||
verify_confined_probe(&workspace_canonical, &relative, &file_path, id, "read")?;
|
||||
if let Some(result) = read_pdf_if_detected(
|
||||
&file_path,
|
||||
pages,
|
||||
super::pdf::PdfTextCommand::system(Some(context)),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(result);
|
||||
}
|
||||
Err(ToolError::execution_failed(format!(
|
||||
"Failed to read {}",
|
||||
file_path.display()
|
||||
)))
|
||||
}
|
||||
ConfinedReadOutput::Image { id } => {
|
||||
verify_confined_probe(&workspace_canonical, &relative, &file_path, id, "read")?;
|
||||
read_image_via_ocr(&file_path, path_str, context).await
|
||||
}
|
||||
ConfinedReadOutput::Small(contents) => {
|
||||
context.note_file_read(&file_path);
|
||||
let total_lines = contents.lines().count();
|
||||
if total_lines <= SMALL_FILE_LINES {
|
||||
let hash = content_hash(contents.as_bytes());
|
||||
let body = format!("{}{contents}", content_hash_header(&hash));
|
||||
return Ok(ToolResult::success(body).with_metadata(json!({
|
||||
"evidence_routing": "inline",
|
||||
"content_hash": hash
|
||||
})));
|
||||
}
|
||||
let hash = content_hash(contents.as_bytes());
|
||||
let window: Vec<String> = contents
|
||||
.lines()
|
||||
.take(DEFAULT_READ_LINES)
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
Ok(render_line_window(
|
||||
path_str,
|
||||
&window,
|
||||
total_lines,
|
||||
1,
|
||||
DEFAULT_READ_LINES,
|
||||
Some(hash.as_str()),
|
||||
))
|
||||
}
|
||||
ConfinedReadOutput::Window {
|
||||
window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash,
|
||||
} => {
|
||||
context.note_file_read(&file_path);
|
||||
if start_line > total_lines {
|
||||
let hash_attr = hash
|
||||
.as_deref()
|
||||
.map(|hash| format!(" content_hash=\"{hash}\""))
|
||||
.unwrap_or_default();
|
||||
let output = format!(
|
||||
"<file path=\"{path_str}\" total_lines=\"{total_lines}\" shown_lines=\"none\" truncated=\"false\"{hash_attr}>\n\n[NO CONTENT] start_line {start_line} is beyond total_lines {total_lines}.\n</file>"
|
||||
);
|
||||
return Ok(ToolResult::success(output).with_metadata(json!({
|
||||
"evidence_routing": "inline",
|
||||
"content_hash": hash
|
||||
})));
|
||||
}
|
||||
Ok(render_line_window(
|
||||
path_str,
|
||||
&window,
|
||||
total_lines,
|
||||
start_line,
|
||||
max_lines,
|
||||
hash.as_deref(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify_confined_probe(
|
||||
workspace_canonical: &Path,
|
||||
relative: &Path,
|
||||
display_path: &Path,
|
||||
expected_id: ConfinedFileId,
|
||||
action: &str,
|
||||
) -> Result<(), ToolError> {
|
||||
let ws =
|
||||
codewhale_util::files::WorkspaceFile::open_shared(workspace_canonical, relative, false)
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
let probe = ws
|
||||
.identity_probe()
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
let current_id = confined_file_id(&probe)
|
||||
.map_err(|error| map_confined_io_error(error, display_path, action))?;
|
||||
if current_id != expected_id {
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"{action} target changed before inspection: {}",
|
||||
display_path.display()
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Bounded output for large files. The small-file fast path keeps the
|
||||
// historical "return contents unchanged" behavior so existing flows
|
||||
// (small configs, single source files, etc.) don't suddenly start
|
||||
@@ -1246,7 +1680,7 @@ const SMALL_FILE_BYTES: usize = 16 * 1024;
|
||||
/// Returns the collected window plus the total line count. Only the window
|
||||
/// is ever held in memory.
|
||||
fn read_window_streaming(
|
||||
file: fs::File,
|
||||
file: &fs::File,
|
||||
start_line: usize,
|
||||
max_lines: usize,
|
||||
) -> std::io::Result<(Vec<String>, usize)> {
|
||||
@@ -1565,11 +1999,36 @@ impl WriteFileTool {
|
||||
let path_str = required_str(&input, "path")?;
|
||||
let file_content = required_str(&input, "content")?;
|
||||
check_contract_file_size(file_content.len())?;
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let confined_target = if write_uses_confined_io(context) {
|
||||
Some(resolve_confined_target_async(context, path_str, false).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let file_path = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => workspace_canonical.join(relative),
|
||||
None => context.resolve_path(path_str)?,
|
||||
};
|
||||
let mutation_guard = acquire_file_mutation(&file_path, context).await?;
|
||||
check_file_operation_cancelled(context)?;
|
||||
|
||||
let prior = load_contract_source(&file_path, false, context).await?;
|
||||
let (prior_id, prior) = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => match load_confined_prior_file(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
Some(CONTRACT_FILE_MAX_BYTES),
|
||||
context,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ConfinedPrior::Missing => (None, None),
|
||||
ConfinedPrior::Existing { bytes, id } => (Some(id), Some(bytes)),
|
||||
},
|
||||
None => (
|
||||
None,
|
||||
load_contract_source(&file_path, false, context).await?,
|
||||
),
|
||||
};
|
||||
let existed_before = prior.is_some();
|
||||
if existed_before {
|
||||
context.require_fresh_read_before_write(&file_path, path_str)?;
|
||||
@@ -1598,17 +2057,27 @@ impl WriteFileTool {
|
||||
}
|
||||
check_contract_file_size(written.len())?;
|
||||
check_file_operation_cancelled(context)?;
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
if let Some((workspace_canonical, relative)) = confined_target.as_ref() {
|
||||
commit_confined_write(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
prior_id,
|
||||
written.clone().into_bytes(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
check_file_operation_cancelled(context)?;
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
}
|
||||
check_file_operation_cancelled(context)?;
|
||||
// Once replacement starts, report its actual completion even if cancelled.
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
context.note_file_read(&file_path);
|
||||
drop(mutation_guard);
|
||||
|
||||
@@ -1700,29 +2169,61 @@ impl ToolSpec for WriteFileTool {
|
||||
let expected_hash = optional_str(&input, "expected_hash")?;
|
||||
let line_ending = requested_line_ending(&input)?;
|
||||
|
||||
let file_path = context.resolve_path(path_str)?;
|
||||
let confined_target = if write_uses_confined_io(context) {
|
||||
Some(resolve_confined_target_async(context, path_str, false).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let file_path = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => workspace_canonical.join(relative),
|
||||
None => context.resolve_path(path_str)?,
|
||||
};
|
||||
let mutation_guard = acquire_file_mutation(&file_path, context).await?;
|
||||
|
||||
// Snapshot the existing contents (if any) before we overwrite — used
|
||||
// to render an inline diff in the tool result. Only a genuinely
|
||||
// absent path is "new": a stat that fails for any other reason must
|
||||
// not let an existing file be overwritten as if it were empty.
|
||||
let existed_before = tokio::fs::try_exists(&file_path).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to inspect {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
let prior_contents = if existed_before {
|
||||
tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
let (existed_before, prior_id, prior_contents) = match confined_target.as_ref() {
|
||||
Some((workspace_canonical, relative)) => {
|
||||
match load_confined_prior_file(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ConfinedPrior::Missing => (false, None, String::new()),
|
||||
ConfinedPrior::Existing { bytes, id } => {
|
||||
let contents = String::from_utf8(bytes).map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?;
|
||||
(true, Some(id), contents)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let existed_before = tokio::fs::try_exists(&file_path).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
"Failed to inspect {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
String::new()
|
||||
})?;
|
||||
let prior_contents = if existed_before {
|
||||
tokio::fs::read_to_string(&file_path)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to read {}: {error}",
|
||||
file_path.display()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
(existed_before, None, prior_contents)
|
||||
}
|
||||
};
|
||||
|
||||
// Content-hash guard (#3979), checked against the same snapshot the
|
||||
@@ -1758,16 +2259,28 @@ impl ToolSpec for WriteFileTool {
|
||||
if line_ending.is_some() {
|
||||
written = write_line_endings(&written, "", line_ending, true)?;
|
||||
}
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
if let Some((workspace_canonical, relative)) = confined_target.as_ref() {
|
||||
commit_confined_write(
|
||||
workspace_canonical,
|
||||
relative,
|
||||
&file_path,
|
||||
prior_id,
|
||||
written.clone().into_bytes(),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
if let Some(parent) = file_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.map_err(|error| {
|
||||
ToolError::execution_failed(format!(
|
||||
"Failed to create directory {}: {error}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
}
|
||||
run_blocking_write_atomic(&file_path, written.clone().into_bytes()).await?;
|
||||
context.note_file_read(&file_path);
|
||||
drop(mutation_guard);
|
||||
|
||||
let display = file_path.display().to_string();
|
||||
let diff = make_unified_diff(&display, &prior_contents, &written);
|
||||
@@ -3436,6 +3949,52 @@ impl ToolSpec for ListDirTool {
|
||||
// raw spelling is checked first (F2) so the refusal names the caller's
|
||||
// path, never a symlink target it might resolve to.
|
||||
enforce_read_denylist(Path::new(path_str), "list_dir")?;
|
||||
if context.strict_workspace_confinement {
|
||||
let (workspace_canonical, relative) =
|
||||
resolve_confined_target_async(context, path_str, true).await?;
|
||||
let dir_path = if relative.as_os_str().is_empty() {
|
||||
workspace_canonical.clone()
|
||||
} else {
|
||||
workspace_canonical.join(&relative)
|
||||
};
|
||||
enforce_read_denylist(&dir_path, "list_dir")?;
|
||||
let worker_cancel_token = context.cancel_token.clone();
|
||||
let entries =
|
||||
run_blocking_list_dir(LIST_DIR_TIMEOUT, context.cancel_token.clone(), move || {
|
||||
check_list_dir_cancelled(worker_cancel_token.as_ref())?;
|
||||
let rel = (!relative.as_os_str().is_empty()).then_some(relative.as_path());
|
||||
let listing = codewhale_util::files::read_confined_dir(
|
||||
&workspace_canonical,
|
||||
rel,
|
||||
LIST_DIR_MAX_ENTRIES,
|
||||
)
|
||||
.map_err(|error| map_confined_io_error(error, &dir_path, "list"))?;
|
||||
check_list_dir_cancelled(worker_cancel_token.as_ref())?;
|
||||
let entries = listing
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
json!({
|
||||
"name": entry.name,
|
||||
"is_dir": entry.is_dir,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if listing.total_entries > entries.len() {
|
||||
Ok(json!({
|
||||
"entries": entries,
|
||||
"listed_entries": LIST_DIR_MAX_ENTRIES,
|
||||
"total_entries": listing.total_entries,
|
||||
"truncated": true,
|
||||
}))
|
||||
} else {
|
||||
Ok(Value::Array(entries))
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
return ToolResult::json(&entries)
|
||||
.map_err(|e| ToolError::execution_failed(e.to_string()));
|
||||
}
|
||||
let dir_path = context.resolve_path(path_str)?;
|
||||
enforce_read_denylist(&dir_path, "list_dir")?;
|
||||
|
||||
|
||||
@@ -3317,3 +3317,95 @@ async fn read_file_refusal_names_home_spelling_not_denied_symlink_target() {
|
||||
denied_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn write_file_refuses_symlinks_to_git_hooks_and_strict_trust_escape() {
|
||||
let workspace_dir = tempfile::tempdir().expect("workspace");
|
||||
let outside_dir = tempfile::tempdir().expect("outside");
|
||||
let workspace = workspace_dir.path().canonicalize().expect("canonical ws");
|
||||
let hooks_dir = workspace.join(".git/hooks");
|
||||
fs::create_dir_all(&hooks_dir).expect("hooks dir");
|
||||
fs::create_dir_all(workspace.join("notes")).expect("notes dir");
|
||||
let hook = hooks_dir.join("pre-commit");
|
||||
fs::write(&hook, "clean-hook\n").expect("write hook");
|
||||
let outside_target = outside_dir.path().join("outside.txt");
|
||||
fs::write(&outside_target, "clean-outside\n").expect("write outside");
|
||||
|
||||
let link_leaf = workspace.join("notes/hook_link");
|
||||
std::os::unix::fs::symlink(&hook, &link_leaf).expect("symlink leaf");
|
||||
let link_dir = workspace.join("linked_hooks");
|
||||
std::os::unix::fs::symlink(&hooks_dir, &link_dir).expect("symlink dir");
|
||||
let hardlink_leaf = workspace.join("notes/hook_hard");
|
||||
fs::hard_link(&hook, &hardlink_leaf).expect("hard link leaf");
|
||||
|
||||
let ctx = ToolContext::new(&workspace);
|
||||
for bad in [
|
||||
"notes/hook_link",
|
||||
"linked_hooks/new-hook",
|
||||
"notes/hook_hard",
|
||||
] {
|
||||
let err = WriteFileTool
|
||||
.execute(json!({"path": bad, "content": "pwned\n"}), &ctx)
|
||||
.await
|
||||
.expect_err(bad);
|
||||
assert!(
|
||||
matches!(err, ToolError::PermissionDenied { .. }),
|
||||
"expected permission denied for {bad}, got {err:?}"
|
||||
);
|
||||
let contract_err =
|
||||
WriteFileTool::execute_contract_write(json!({"path": bad, "content": "pwned\n"}), &ctx)
|
||||
.await
|
||||
.expect_err(bad);
|
||||
assert!(
|
||||
matches!(contract_err, ToolError::PermissionDenied { .. }),
|
||||
"expected contract permission denied for {bad}, got {contract_err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let mut strict_trusted =
|
||||
ToolContext::with_options(&workspace, true, workspace.join("notes.md"));
|
||||
strict_trusted.execution.follow_symlinks = true;
|
||||
strict_trusted
|
||||
.execution
|
||||
.trusted_external_paths
|
||||
.push(outside_dir.path().to_path_buf());
|
||||
strict_trusted.execution.strict_workspace_confinement = true;
|
||||
|
||||
let escape_link = workspace.join("notes/escape_link");
|
||||
std::os::unix::fs::symlink(&outside_target, &escape_link).expect("escape symlink");
|
||||
let escape_dir = workspace.join("escape_dir");
|
||||
std::os::unix::fs::symlink(outside_dir.path(), &escape_dir).expect("escape dir");
|
||||
|
||||
for escaped in [
|
||||
"notes/escape_link",
|
||||
"escape_dir/created.txt",
|
||||
outside_target.to_str().expect("utf8 outside"),
|
||||
] {
|
||||
let err = WriteFileTool
|
||||
.execute(
|
||||
json!({"path": escaped, "content": "pwned\n"}),
|
||||
&strict_trusted,
|
||||
)
|
||||
.await
|
||||
.expect_err(escaped);
|
||||
assert!(
|
||||
matches!(
|
||||
err,
|
||||
ToolError::PermissionDenied { .. } | ToolError::PathEscape { .. }
|
||||
),
|
||||
"strict confinement must fail closed for {escaped}, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(&hook).expect("read hook"),
|
||||
"clean-hook\n"
|
||||
);
|
||||
assert!(!hooks_dir.join("new-hook").exists());
|
||||
assert_eq!(
|
||||
fs::read_to_string(&outside_target).expect("read outside"),
|
||||
"clean-outside\n"
|
||||
);
|
||||
assert!(!outside_dir.path().join("created.txt").exists());
|
||||
}
|
||||
@@ -76,6 +76,7 @@ pub mod send_later;
|
||||
pub mod session;
|
||||
pub mod shell;
|
||||
pub mod shell_output;
|
||||
pub mod show_data;
|
||||
pub mod skill;
|
||||
pub mod spec;
|
||||
pub mod speech;
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct UpdatePlanArgs {
|
||||
pub risks_and_unknowns: Option<String>,
|
||||
#[serde(default)]
|
||||
pub handoff_packet: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replan_if: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub plan: Vec<PlanItemArg>,
|
||||
}
|
||||
@@ -122,6 +124,8 @@ pub struct PlanSnapshot {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub handoff_packet: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub replan_if: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub items: Vec<PlanItemArg>,
|
||||
}
|
||||
|
||||
@@ -139,6 +143,7 @@ impl PlanSnapshot {
|
||||
&& self.verification_plan.is_none()
|
||||
&& self.risks_and_unknowns.is_none()
|
||||
&& self.handoff_packet.is_none()
|
||||
&& self.replan_if.is_empty()
|
||||
&& self.items.is_empty()
|
||||
}
|
||||
|
||||
@@ -182,6 +187,7 @@ impl PlanSnapshot {
|
||||
verification_plan: clean_optional(string_field(input, "verification_plan")),
|
||||
risks_and_unknowns: clean_optional(string_field(input, "risks_and_unknowns")),
|
||||
handoff_packet: clean_optional(string_field(input, "handoff_packet")),
|
||||
replan_if: clean_list(string_vec_field(input, "replan_if")),
|
||||
items,
|
||||
}
|
||||
}
|
||||
@@ -201,6 +207,7 @@ pub struct PlanState {
|
||||
verification_plan: Option<String>,
|
||||
risks_and_unknowns: Option<String>,
|
||||
handoff_packet: Option<String>,
|
||||
replan_if: Vec<String>,
|
||||
steps: Vec<PlanStep>,
|
||||
}
|
||||
|
||||
@@ -217,6 +224,7 @@ impl PlanState {
|
||||
self.verification_plan = clean_optional(args.verification_plan);
|
||||
self.risks_and_unknowns = clean_optional(args.risks_and_unknowns);
|
||||
self.handoff_packet = clean_optional(args.handoff_packet);
|
||||
self.replan_if = clean_list(args.replan_if);
|
||||
|
||||
let now = Instant::now();
|
||||
let mut new_steps = Vec::new();
|
||||
@@ -281,6 +289,7 @@ impl PlanState {
|
||||
verification_plan: self.verification_plan.clone(),
|
||||
risks_and_unknowns: self.risks_and_unknowns.clone(),
|
||||
handoff_packet: self.handoff_packet.clone(),
|
||||
replan_if: self.replan_if.clone(),
|
||||
items: self
|
||||
.steps
|
||||
.iter()
|
||||
@@ -309,6 +318,7 @@ impl PlanState {
|
||||
verification_plan: snapshot.verification_plan.clone(),
|
||||
risks_and_unknowns: snapshot.risks_and_unknowns.clone(),
|
||||
handoff_packet: snapshot.handoff_packet.clone(),
|
||||
replan_if: snapshot.replan_if.clone(),
|
||||
plan: snapshot.items.clone(),
|
||||
});
|
||||
state
|
||||
@@ -447,6 +457,11 @@ impl ToolSpec for UpdatePlanTool {
|
||||
"type": "string",
|
||||
"description": "Concise continuation notes for another agent or a later session"
|
||||
},
|
||||
"replan_if": {
|
||||
"type": "array",
|
||||
"description": "Conditions that invalidate the current plan and trigger replanning",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"plan": {
|
||||
"type": "array",
|
||||
"description": "Legacy replay field; new work must use work_update",
|
||||
@@ -510,6 +525,7 @@ impl ToolSpec for UpdatePlanTool {
|
||||
verification_plan: string_field(&input, "verification_plan"),
|
||||
risks_and_unknowns: string_field(&input, "risks_and_unknowns"),
|
||||
handoff_packet: string_field(&input, "handoff_packet"),
|
||||
replan_if: string_vec_field(&input, "replan_if"),
|
||||
plan: plan_args,
|
||||
};
|
||||
|
||||
@@ -536,6 +552,7 @@ impl ToolSpec for UpdatePlanTool {
|
||||
verification_plan: desired.verification_plan.clone(),
|
||||
risks_and_unknowns: desired.risks_and_unknowns.clone(),
|
||||
handoff_packet: desired.handoff_packet.clone(),
|
||||
replan_if: desired.replan_if.clone(),
|
||||
plan: desired.items.clone(),
|
||||
});
|
||||
state.snapshot()
|
||||
@@ -815,4 +832,62 @@ mod tests {
|
||||
vec!["crates/engine/src/tools/plan.rs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replan_if_round_trips_through_schema_state_serde_and_work_graph() {
|
||||
let state = new_shared_plan_state();
|
||||
let todos = crate::tools::todo::new_shared_todo_list();
|
||||
let work = crate::work_graph::new_shared_work_runtime(todos, state.clone());
|
||||
let tool = UpdatePlanTool::new(state.clone());
|
||||
|
||||
let schema = tool.input_schema();
|
||||
assert_eq!(schema["properties"]["replan_if"]["type"], "array");
|
||||
assert_eq!(schema["properties"]["replan_if"]["items"]["type"], "string");
|
||||
|
||||
let empty_json = serde_json::to_value(PlanSnapshot::default()).unwrap();
|
||||
assert!(empty_json.get("replan_if").is_none());
|
||||
|
||||
let from_input = PlanSnapshot::from_tool_input(&json!({
|
||||
"replan_if": [" api schema changes ", " "]
|
||||
}));
|
||||
assert_eq!(from_input.replan_if, vec!["api schema changes"]);
|
||||
assert!(!from_input.is_empty());
|
||||
|
||||
let args = UpdatePlanArgs {
|
||||
replan_if: vec![" auth scope narrows ".to_string(), "".to_string()],
|
||||
..UpdatePlanArgs::default()
|
||||
};
|
||||
let mut isolated = PlanState::default();
|
||||
isolated.update(args);
|
||||
let isolated_snap = isolated.snapshot();
|
||||
assert_eq!(isolated_snap.replan_if, vec!["auth scope narrows"]);
|
||||
assert_eq!(
|
||||
PlanState::from_snapshot(&isolated_snap).snapshot(),
|
||||
isolated_snap
|
||||
);
|
||||
|
||||
let mut context = ToolContext::new(std::env::temp_dir());
|
||||
context.runtime.work = Some(work.clone());
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({
|
||||
"replan_if": [" latency regresses above 1s ", " "]
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect("replan_if update_plan through work_graph should succeed");
|
||||
assert!(result.content.contains("latency regresses above 1s"));
|
||||
|
||||
let captured = work
|
||||
.capture(Some(&context.state_namespace))
|
||||
.expect("capture")
|
||||
.expect("graph state");
|
||||
assert_eq!(captured.plan.replan_if, vec!["latency regresses above 1s"]);
|
||||
let serialized = serde_json::to_value(&captured.plan).unwrap();
|
||||
assert_eq!(
|
||||
serialized["replan_if"],
|
||||
json!(["latency regresses above 1s"])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1043,6 +1043,11 @@ impl ToolRegistryBuilder {
|
||||
self.with_tool(Arc::new(PresentTool))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_show_data_tool(self) -> Self {
|
||||
self.with_tool(Arc::new(super::show_data::ShowDataTool))
|
||||
}
|
||||
|
||||
/// Include shell execution tools.
|
||||
///
|
||||
/// New turns expose lowercase `bash`; uppercase `Bash` remains a hidden
|
||||
@@ -1540,6 +1545,7 @@ impl ToolRegistryBuilder {
|
||||
let builder = self
|
||||
.with_file_tools()
|
||||
.with_present_tool()
|
||||
.with_show_data_tool()
|
||||
.with_note_tool()
|
||||
.with_search_tools()
|
||||
.with_user_input_tool(user_input_limits)
|
||||
|
||||
@@ -1032,6 +1032,7 @@ fn machine_verifier_catalog_and_dispatch_add_only_bounded_run() {
|
||||
"read_media",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"show_data",
|
||||
"todo_write",
|
||||
"tui_help",
|
||||
"validate_data",
|
||||
@@ -1915,6 +1916,7 @@ fn machine_readonly_catalog_is_exactly_the_evidence_profile() {
|
||||
"read_media",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"show_data",
|
||||
"todo_write",
|
||||
"tui_help",
|
||||
"validate_data",
|
||||
|
||||
@@ -103,7 +103,7 @@ impl ToolSpec for SendLaterTool {
|
||||
Actions: \"schedule\" (create a pending trigger; requires approval), \
|
||||
\"list\" (recent triggers), \"read\" (one trigger by trigger_id), \
|
||||
\"cancel\" (cancel a pending trigger before it fires; requires approval). \
|
||||
Use delay_minutes or fire_at (ISO 8601 UTC) — not both. \
|
||||
Specify exactly one of delay_seconds, delay_minutes, or fire_at (ISO 8601 UTC). \
|
||||
Returns trigger_id and resolved fire_at."
|
||||
}
|
||||
}
|
||||
@@ -120,19 +120,27 @@ Returns trigger_id and resolved fire_at."
|
||||
}),
|
||||
);
|
||||
if !self.read_only {
|
||||
properties.insert(
|
||||
"delay_seconds".to_string(),
|
||||
json!({
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Seconds from now to fire (minimum 1). Mutually exclusive with delay_minutes and fire_at. (action=schedule)"
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
"delay_minutes".to_string(),
|
||||
json!({
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"description": "Minutes from now to fire. Mutually exclusive with fire_at. (action=schedule)"
|
||||
"description": "Minutes from now to fire. Mutually exclusive with delay_seconds and fire_at. (action=schedule)"
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
"fire_at".to_string(),
|
||||
json!({
|
||||
"type": "string",
|
||||
"description": "Absolute UTC fire time as an ISO 8601 string, e.g. \"2026-07-08T00:43:00Z\". Mutually exclusive with delay_minutes. (action=schedule)"
|
||||
"description": "Absolute UTC fire time as an ISO 8601 string, e.g. \"2026-07-08T00:43:00Z\". Mutually exclusive with delay_seconds and delay_minutes. (action=schedule)"
|
||||
}),
|
||||
);
|
||||
properties.insert(
|
||||
@@ -247,10 +255,42 @@ Returns trigger_id and resolved fire_at."
|
||||
|
||||
// ── Action handlers ────────────────────────────────────────────────────────
|
||||
|
||||
fn optional_delay_arg(input: &Value, key: &str) -> Result<Option<i64>, ToolError> {
|
||||
let Some(raw) = input.get(key) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if raw.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let value = codewhale_tools::json_nonnegative_integer(raw).ok_or_else(|| {
|
||||
ToolError::invalid_input(format!(
|
||||
"send_later schedule: `{key}` must be a positive integer"
|
||||
))
|
||||
})?;
|
||||
if value == 0 {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"send_later schedule: `{key}` must be >= 1"
|
||||
)));
|
||||
}
|
||||
i64::try_from(value)
|
||||
.map(Some)
|
||||
.map_err(|_| ToolError::invalid_input(format!("send_later schedule: `{key}` is too large")))
|
||||
}
|
||||
|
||||
fn checked_delay_fire_at(delta: Option<Duration>, key: &str) -> Result<DateTime<Utc>, ToolError> {
|
||||
let delta = delta.ok_or_else(|| {
|
||||
ToolError::invalid_input(format!("send_later schedule: `{key}` is too large"))
|
||||
})?;
|
||||
Utc::now().checked_add_signed(delta).ok_or_else(|| {
|
||||
ToolError::invalid_input(format!("send_later schedule: `{key}` is too large"))
|
||||
})
|
||||
}
|
||||
|
||||
async fn execute_schedule(input: &Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let automations = SendLaterTool::automations_from_context(context)?;
|
||||
|
||||
let delay_minutes = input.get("delay_minutes").and_then(Value::as_u64);
|
||||
let delay_seconds = optional_delay_arg(input, "delay_seconds")?;
|
||||
let delay_minutes = optional_delay_arg(input, "delay_minutes")?;
|
||||
let fire_at_str = optional_str(input, "fire_at")?;
|
||||
let message = input
|
||||
.get("message")
|
||||
@@ -263,47 +303,42 @@ async fn execute_schedule(input: &Value, context: &ToolContext) -> Result<ToolRe
|
||||
));
|
||||
}
|
||||
|
||||
// Validate mutual exclusivity and resolve fire_at.
|
||||
let fire_at: DateTime<Utc> = match (delay_minutes, fire_at_str) {
|
||||
(Some(_), Some(_)) => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: `delay_minutes` and `fire_at` are mutually exclusive",
|
||||
));
|
||||
let fire_at: DateTime<Utc> = match (delay_seconds, delay_minutes, fire_at_str) {
|
||||
(Some(seconds), None, None) => {
|
||||
checked_delay_fire_at(Duration::try_seconds(seconds), "delay_seconds")?
|
||||
}
|
||||
(None, None) => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: one of `delay_minutes` or `fire_at` is required",
|
||||
));
|
||||
(None, Some(minutes), None) => {
|
||||
checked_delay_fire_at(Duration::try_minutes(minutes), "delay_minutes")?
|
||||
}
|
||||
(Some(minutes), None) => {
|
||||
if minutes == 0 {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: `delay_minutes` must be >= 1",
|
||||
));
|
||||
}
|
||||
let minutes_i64 = i64::try_from(minutes).map_err(|_| {
|
||||
ToolError::invalid_input("send_later schedule: `delay_minutes` is too large")
|
||||
})?;
|
||||
Utc::now() + Duration::minutes(minutes_i64)
|
||||
}
|
||||
(None, Some(fire_at_s)) => fire_at_s.parse::<DateTime<Utc>>().map_err(|err| {
|
||||
(None, None, Some(fire_at_s)) => fire_at_s.parse::<DateTime<Utc>>().map_err(|err| {
|
||||
ToolError::invalid_input(format!(
|
||||
"send_later schedule: invalid `fire_at` — expected ISO 8601 UTC, e.g. \
|
||||
\"2026-07-08T00:43:00Z\": {err}"
|
||||
))
|
||||
})?,
|
||||
(None, None, None) => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: exactly one of `delay_seconds`, `delay_minutes`, or `fire_at` is required",
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
return Err(ToolError::invalid_input(
|
||||
"send_later schedule: `delay_seconds`, `delay_minutes`, and `fire_at` are mutually exclusive",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let workspace: Option<PathBuf> = optional_str(input, "workspace")?
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
let workspace: Option<PathBuf> = match optional_str(input, "workspace")? {
|
||||
Some(raw) => Some(context.resolve_path(raw)?),
|
||||
None => {
|
||||
let ws = &context.workspace;
|
||||
if ws == std::path::Path::new(".") {
|
||||
None
|
||||
} else {
|
||||
Some(ws.clone())
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let parent_trigger_id = optional_str(input, "parent_trigger_id")?.map(String::from);
|
||||
|
||||
@@ -320,6 +355,7 @@ async fn execute_schedule(input: &Value, context: &ToolContext) -> Result<ToolRe
|
||||
workspace,
|
||||
owner_session_id: Some(context.state_namespace.clone()),
|
||||
parent_trigger_id,
|
||||
disallowed_tools: context.disallowed_tools.clone(),
|
||||
};
|
||||
|
||||
let record = {
|
||||
@@ -501,6 +537,104 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
fn make_context_in_workspace(tmp: &TempDir, workspace: &std::path::Path) -> ToolContext {
|
||||
let manager = AutomationManager::open_for_test(tmp.path().to_path_buf()).unwrap();
|
||||
ToolContext::new(workspace)
|
||||
.with_state_namespace("test-session")
|
||||
.with_runtime_services(RuntimeToolServices {
|
||||
automations: Some(Arc::new(Mutex::new(manager))),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn stored_triggers(tmp: &TempDir) -> Vec<crate::automation_manager::DelayedTriggerRecord> {
|
||||
AutomationManager::open_for_test(tmp.path().to_path_buf())
|
||||
.unwrap()
|
||||
.list_triggers(None, None)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_refuses_a_workspace_the_session_cannot_reach() {
|
||||
let store = TempDir::new().unwrap();
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let outside = TempDir::new().unwrap();
|
||||
let ctx = make_context_in_workspace(&store, workspace.path());
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_minutes": 5,
|
||||
"message": "look around",
|
||||
"workspace": outside.path().to_string_lossy()
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a directory outside the session workspace must be refused"
|
||||
);
|
||||
assert!(stored_triggers(&store).is_empty(), "nothing is scheduled");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_stores_a_reachable_workspace_resolved() {
|
||||
let store = TempDir::new().unwrap();
|
||||
let workspace = TempDir::new().unwrap();
|
||||
std::fs::create_dir(workspace.path().join("sub")).unwrap();
|
||||
let ctx = make_context_in_workspace(&store, workspace.path());
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
tool.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_minutes": 5,
|
||||
"message": "work in sub",
|
||||
"workspace": "sub"
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let triggers = stored_triggers(&store);
|
||||
assert_eq!(triggers.len(), 1);
|
||||
assert_eq!(
|
||||
triggers[0]
|
||||
.workspace
|
||||
.as_deref()
|
||||
.map(|p| p.canonicalize().unwrap()),
|
||||
Some(workspace.path().join("sub").canonicalize().unwrap())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_allows_any_workspace_in_trust_mode() {
|
||||
let store = TempDir::new().unwrap();
|
||||
let workspace = TempDir::new().unwrap();
|
||||
let elsewhere = TempDir::new().unwrap();
|
||||
let ctx = make_context_in_workspace(&store, workspace.path()).with_trust_mode(true);
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
tool.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_minutes": 5,
|
||||
"message": "trusted",
|
||||
"workspace": elsewhere.path().to_string_lossy()
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored_triggers(&store).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_with_delay_minutes_succeeds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -906,6 +1040,7 @@ mod tests {
|
||||
workspace: None,
|
||||
owner_session_id: None,
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -985,6 +1120,7 @@ mod tests {
|
||||
workspace: None,
|
||||
owner_session_id: Some("session-a".to_string()),
|
||||
parent_trigger_id: None,
|
||||
disallowed_tools: Vec::new(),
|
||||
};
|
||||
let record = manager.create_trigger(req).unwrap();
|
||||
let due = manager.collect_due_triggers(chrono::Utc::now()).unwrap();
|
||||
@@ -1045,4 +1181,119 @@ mod tests {
|
||||
listed.content
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_with_delay_seconds_succeeds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = make_context(&tmp);
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
let before = chrono::Utc::now();
|
||||
let result = tool
|
||||
.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": 15,
|
||||
"message": "Sub-minute check."
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let after = chrono::Utc::now();
|
||||
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result.content).unwrap();
|
||||
assert!(parsed["trigger_id"].as_str().unwrap().starts_with("trig_"));
|
||||
let fire_at: chrono::DateTime<chrono::Utc> =
|
||||
parsed["fire_at"].as_str().unwrap().parse().unwrap();
|
||||
assert!(fire_at >= before + Duration::seconds(14));
|
||||
assert!(fire_at <= after + Duration::seconds(16));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_rejects_invalid_and_overflow_delay_seconds() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = make_context(&tmp);
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
|
||||
for bad in [
|
||||
json!(0),
|
||||
json!(-5),
|
||||
json!(1.5),
|
||||
json!("10"),
|
||||
json!(u64::MAX),
|
||||
json!(i64::MAX),
|
||||
] {
|
||||
let err = tool
|
||||
.execute(
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": bad,
|
||||
"message": "Invalid delay."
|
||||
}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("delay_seconds"),
|
||||
"unexpected error for {bad}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn schedule_enforces_three_way_timing_mutual_exclusion() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ctx = make_context(&tmp);
|
||||
let tool = SendLaterTool::new("send_later");
|
||||
let fire_at = (chrono::Utc::now() + Duration::minutes(10)).to_rfc3339();
|
||||
|
||||
for payload in [
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": 30,
|
||||
"delay_minutes": 1,
|
||||
"message": "Conflict"
|
||||
}),
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": 30,
|
||||
"fire_at": fire_at,
|
||||
"message": "Conflict"
|
||||
}),
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_minutes": 1,
|
||||
"fire_at": fire_at,
|
||||
"message": "Conflict"
|
||||
}),
|
||||
json!({
|
||||
"action": "schedule",
|
||||
"delay_seconds": 30,
|
||||
"delay_minutes": 1,
|
||||
"fire_at": fire_at,
|
||||
"message": "Conflict"
|
||||
}),
|
||||
] {
|
||||
let err = tool.execute(payload, &ctx).await.unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("mutually exclusive"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_and_description_expose_delay_seconds_when_writable() {
|
||||
let writable = SendLaterTool::new("send_later");
|
||||
let schema = writable.input_schema();
|
||||
let prop = &schema["properties"]["delay_seconds"];
|
||||
assert_eq!(prop["type"], "integer");
|
||||
assert_eq!(prop["minimum"], 1);
|
||||
assert!(writable.description().contains("delay_seconds"));
|
||||
|
||||
let readonly = SendLaterTool::read_only("send_later_read");
|
||||
assert!(readonly.input_schema()["properties"]["delay_seconds"].is_null());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use async_trait::async_trait;
|
||||
use codewhale_ratatui::{DATA_DISPLAY_SCHEMA, DataDisplay, DataWords};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec};
|
||||
|
||||
pub struct ShowDataTool;
|
||||
|
||||
pub fn parse_data_display(input: &Value) -> Result<DataDisplay, ToolError> {
|
||||
DataDisplay::from_json(&input.to_string())
|
||||
.map_err(|error| ToolError::invalid_input(error.to_string()))
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for ShowDataTool {
|
||||
fn name(&self) -> &'static str {
|
||||
"show_data"
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Show the person numbers as a small terminal chart: `sparkline` (one row, newest value \
|
||||
on the right), `bars` (up to 24 labelled bars) or `trend` (a multi-row line chart). \
|
||||
Read-only: nothing is written or sent. Values must be finite numbers, oldest first. \
|
||||
The result repeats the same facts as plain text, so say what matters in words too."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
serde_json::from_str(DATA_DISPLAY_SCHEMA).expect("kit DATA_DISPLAY_SCHEMA is valid JSON")
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, _context: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let display = parse_data_display(&input)?;
|
||||
let summary = format!("Shown: {}", display.describe(&DataWords::default()));
|
||||
Ok(ToolResult::success(summary).with_metadata(json!({ "data_display": display })))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn schema_parses_and_bad_input_names_the_broken_limit() {
|
||||
assert_eq!(ShowDataTool.input_schema()["type"], "object");
|
||||
|
||||
let shown = parse_data_display(&json!({
|
||||
"kind": "bars",
|
||||
"label": "Tokens",
|
||||
"unit": "tok",
|
||||
"items": [{ "label": "opus", "value": 100.0 }]
|
||||
}))
|
||||
.expect("valid bars");
|
||||
let words = shown.describe(&DataWords::default());
|
||||
assert!(words.starts_with("Tokens: opus 100"), "{words}");
|
||||
|
||||
let context = ToolContext::new(".");
|
||||
let result = ShowDataTool
|
||||
.execute(serde_json::to_value(&shown).unwrap(), &context)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.success);
|
||||
assert_eq!(result.content, format!("Shown: {words}"));
|
||||
assert_eq!(
|
||||
result.metadata.as_ref().unwrap()["data_display"],
|
||||
serde_json::to_value(shown).unwrap()
|
||||
);
|
||||
|
||||
let too_many: Vec<Value> = (0..25)
|
||||
.map(|i| json!({ "label": format!("b{i}"), "value": i }))
|
||||
.collect();
|
||||
let error = ShowDataTool
|
||||
.execute(
|
||||
json!({
|
||||
"kind": "bars",
|
||||
"label": "Too many",
|
||||
"items": too_many
|
||||
}),
|
||||
&context,
|
||||
)
|
||||
.await
|
||||
.expect_err("25 bars is over the limit");
|
||||
assert!(error.to_string().contains("25 bars; at most 24"), "{error}");
|
||||
|
||||
let error =
|
||||
parse_data_display(&json!({ "kind": "pie", "label": "x" })).expect_err("unknown kind");
|
||||
assert!(error.to_string().contains("not a data display"), "{error}");
|
||||
}
|
||||
}
|
||||
@@ -538,7 +538,9 @@ pub fn resolve_strict_authority_path(
|
||||
for segment in normalized.split('/') {
|
||||
current.push(segment);
|
||||
match fs::symlink_metadata(¤t) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
Ok(metadata)
|
||||
if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) =>
|
||||
{
|
||||
return Err(ToolError::permission_denied(format!(
|
||||
"machine-readable authority paths must not traverse symlinks: {}",
|
||||
current.display()
|
||||
@@ -732,6 +734,7 @@ pub struct ToolExecutionState {
|
||||
pub live_posture: Option<crate::core::engine::LivePosture>,
|
||||
pub browser_tool: Option<String>,
|
||||
pub child_activation: Option<Arc<crate::tools::subagent::ChildActivationRequest>>,
|
||||
pub strict_workspace_confinement: bool,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ToolContext {
|
||||
@@ -837,6 +840,7 @@ impl ToolContext {
|
||||
live_posture: None,
|
||||
browser_tool: None,
|
||||
child_activation: None,
|
||||
strict_workspace_confinement: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1475,6 +1479,302 @@ pub fn normalize_path(path: &Path) -> PathBuf {
|
||||
normalized
|
||||
}
|
||||
|
||||
pub(crate) fn has_forbidden_windows_colon(raw: &str, windows: bool) -> bool {
|
||||
if !windows {
|
||||
return false;
|
||||
}
|
||||
let bytes = raw.as_bytes();
|
||||
let rest = if raw.starts_with(r"\\?\")
|
||||
|| raw.starts_with("//?/")
|
||||
|| raw.starts_with(r"\\.\")
|
||||
|| raw.starts_with("//./")
|
||||
{
|
||||
if bytes.get(4).is_some_and(u8::is_ascii_alphabetic)
|
||||
&& bytes.get(5) == Some(&b':')
|
||||
&& matches!(bytes.get(6), Some(b'\\') | Some(b'/'))
|
||||
{
|
||||
&raw[6..]
|
||||
} else {
|
||||
raw
|
||||
}
|
||||
} else if bytes.first().is_some_and(u8::is_ascii_alphabetic)
|
||||
&& bytes.get(1) == Some(&b':')
|
||||
&& matches!(bytes.get(2), Some(b'\\') | Some(b'/'))
|
||||
{
|
||||
&raw[2..]
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
rest.contains(':')
|
||||
}
|
||||
|
||||
pub(crate) fn is_windows_short_name_segment(segment: &str) -> bool {
|
||||
for (idx, _) in segment.match_indices('~') {
|
||||
let after = &segment[idx + 1..];
|
||||
let digits = after.split('.').next().unwrap_or(after);
|
||||
if !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn has_windows_trailing_dot_or_space(segment: &str, windows: bool) -> bool {
|
||||
windows && segment != "." && segment != ".." && segment.ends_with(['.', ' '])
|
||||
}
|
||||
|
||||
pub(crate) fn is_protected_workspace_metadata_segment(segment: &str, windows: bool) -> bool {
|
||||
if codewhale_util::git_metadata::git_metadata_name(segment, windows) {
|
||||
return true;
|
||||
}
|
||||
let trimmed = if windows {
|
||||
segment
|
||||
.split(':')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim_end_matches(['.', ' '])
|
||||
} else {
|
||||
segment
|
||||
};
|
||||
if trimmed.eq_ignore_ascii_case(".codewhale") {
|
||||
return true;
|
||||
}
|
||||
if windows {
|
||||
let stem = trimmed.split('.').next().unwrap_or(trimmed);
|
||||
if let Some((prefix, digits)) = stem.rsplit_once('~')
|
||||
&& !digits.is_empty()
|
||||
&& digits.bytes().all(|byte| byte.is_ascii_digit())
|
||||
{
|
||||
let prefix = prefix.strip_prefix('.').unwrap_or(prefix);
|
||||
let upper = prefix.to_ascii_uppercase();
|
||||
if !upper.is_empty() && "CODEWHALE".starts_with(&upper) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn validate_windows_relative_segments(
|
||||
relative: &str,
|
||||
windows: bool,
|
||||
) -> Result<(), String> {
|
||||
if !windows {
|
||||
return Ok(());
|
||||
}
|
||||
if has_forbidden_windows_colon(relative, true) {
|
||||
return Err("windows colons and alternate data streams are not allowed".to_string());
|
||||
}
|
||||
for segment in relative.replace('\\', "/").split('/') {
|
||||
if segment.is_empty() || segment == "." {
|
||||
continue;
|
||||
}
|
||||
if has_windows_trailing_dot_or_space(segment, true) {
|
||||
return Err(format!(
|
||||
"trailing dots or spaces are not allowed: {segment}"
|
||||
));
|
||||
}
|
||||
if is_windows_short_name_segment(segment) {
|
||||
return Err(format!(
|
||||
"windows short-name aliases are not allowed: {segment}"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn strip_windows_verbatim_slashes(raw: &str) -> String {
|
||||
let mut normalized = raw.replace('\\', "/");
|
||||
if let Some(rest) = normalized
|
||||
.strip_prefix("//?/UNC/")
|
||||
.or_else(|| normalized.strip_prefix("//?/unc/"))
|
||||
{
|
||||
normalized = format!("//{rest}");
|
||||
} else if let Some(rest) = normalized
|
||||
.strip_prefix("//?/")
|
||||
.or_else(|| normalized.strip_prefix("//./"))
|
||||
{
|
||||
normalized = rest.to_string();
|
||||
}
|
||||
while normalized.len() > 1 && normalized.ends_with('/') {
|
||||
if normalized.len() == 3 && normalized.as_bytes().get(1) == Some(&b':') {
|
||||
break;
|
||||
}
|
||||
normalized.pop();
|
||||
}
|
||||
normalized
|
||||
}
|
||||
|
||||
pub(crate) fn has_windows_drive_or_unc_prefix(raw: &str) -> bool {
|
||||
raw.starts_with(r"\\")
|
||||
|| raw.starts_with("//")
|
||||
|| (raw.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
|
||||
&& raw.as_bytes().get(1) == Some(&b':'))
|
||||
}
|
||||
|
||||
pub(crate) fn strip_workspace_root_str(
|
||||
workspace_raw: &str,
|
||||
workspace_canonical: Option<&str>,
|
||||
raw: &str,
|
||||
windows: bool,
|
||||
) -> Option<String> {
|
||||
if !windows {
|
||||
let candidate = Path::new(raw);
|
||||
let rest = candidate
|
||||
.strip_prefix(Path::new(workspace_raw))
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
workspace_canonical.and_then(|root| candidate.strip_prefix(Path::new(root)).ok())
|
||||
})?;
|
||||
let mut clean = PathBuf::new();
|
||||
for component in rest.components() {
|
||||
match component {
|
||||
Component::Normal(part) => clean.push(part),
|
||||
Component::CurDir => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let shown = clean.to_str()?;
|
||||
return Some(if shown.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
shown.to_string()
|
||||
});
|
||||
}
|
||||
|
||||
let cand = strip_windows_verbatim_slashes(raw);
|
||||
for root in std::iter::once(workspace_raw).chain(workspace_canonical) {
|
||||
let root_norm = strip_windows_verbatim_slashes(root);
|
||||
if cand.eq_ignore_ascii_case(&root_norm) {
|
||||
return Some(".".to_string());
|
||||
}
|
||||
let cand_bytes = cand.as_bytes();
|
||||
let root_len = root_norm.len();
|
||||
if cand_bytes.len() > root_len
|
||||
&& cand[..root_len].eq_ignore_ascii_case(&root_norm)
|
||||
&& (root_norm.ends_with('/') || cand_bytes[root_len] == b'/')
|
||||
{
|
||||
let tail = &cand[if root_norm.ends_with('/') {
|
||||
root_len
|
||||
} else {
|
||||
root_len + 1
|
||||
}..];
|
||||
let mut segments = Vec::new();
|
||||
for segment in tail.split('/') {
|
||||
match segment {
|
||||
"" | "." => {}
|
||||
".." => return None,
|
||||
part => segments.push(part),
|
||||
}
|
||||
}
|
||||
return Some(if segments.is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
segments.join("/")
|
||||
});
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_confined_target(
|
||||
context: &ToolContext,
|
||||
workspace_canonical: &Path,
|
||||
raw: &str,
|
||||
allow_root_dot: bool,
|
||||
) -> Result<PathBuf, ToolError> {
|
||||
if context.workspace.as_os_str().is_empty() || raw.is_empty() {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if has_forbidden_windows_colon(raw, cfg!(windows)) {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot contain a colon outside a Windows drive prefix".to_string(),
|
||||
));
|
||||
}
|
||||
if context.strict_workspace_confinement
|
||||
&& (raw == "~" || raw.starts_with("~/") || (cfg!(windows) && raw.starts_with("~\\")))
|
||||
{
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path cannot use home shorthand".to_string(),
|
||||
));
|
||||
}
|
||||
let home_resolved = if !context.strict_workspace_confinement {
|
||||
resolve_home_path(raw)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let check_path = home_resolved.as_deref().unwrap_or_else(|| Path::new(raw));
|
||||
if check_path
|
||||
.components()
|
||||
.any(|component| matches!(component, Component::ParentDir))
|
||||
{
|
||||
return Err(ToolError::PathEscape {
|
||||
path: normalize_path(&context.workspace.join(check_path)),
|
||||
});
|
||||
}
|
||||
let relative = if let Some(home_path) = home_resolved.as_deref() {
|
||||
let home_str = home_path.to_str().ok_or_else(|| {
|
||||
ToolError::permission_denied("workspace path must be valid UTF-8".to_string())
|
||||
})?;
|
||||
let ws_plain = normalize_path(&context.workspace);
|
||||
let ws_str = ws_plain.to_string_lossy();
|
||||
let can_str = workspace_canonical.to_string_lossy();
|
||||
let rel = strip_workspace_root_str(&ws_str, Some(&can_str), home_str, cfg!(windows))
|
||||
.ok_or_else(|| ToolError::PathEscape {
|
||||
path: normalize_path(home_path),
|
||||
})?;
|
||||
if rel == "." {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
PathBuf::from(rel)
|
||||
}
|
||||
} else if check_path.is_absolute()
|
||||
|| check_path.has_root()
|
||||
|| (cfg!(windows) && has_windows_drive_or_unc_prefix(raw))
|
||||
{
|
||||
let ws_str = context.workspace.to_string_lossy();
|
||||
let can_str = workspace_canonical.to_string_lossy();
|
||||
let rel = strip_workspace_root_str(&ws_str, Some(&can_str), raw, cfg!(windows))
|
||||
.ok_or_else(|| ToolError::PathEscape {
|
||||
path: PathBuf::from(raw),
|
||||
})?;
|
||||
if rel == "." {
|
||||
PathBuf::new()
|
||||
} else {
|
||||
PathBuf::from(rel)
|
||||
}
|
||||
} else {
|
||||
let mut rel = PathBuf::new();
|
||||
for component in check_path.components() {
|
||||
match component {
|
||||
Component::Normal(part) => rel.push(part),
|
||||
Component::CurDir => {}
|
||||
_ => {
|
||||
return Err(ToolError::PathEscape {
|
||||
path: PathBuf::from(raw),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
rel
|
||||
};
|
||||
|
||||
if relative.as_os_str().is_empty() {
|
||||
if !allow_root_dot {
|
||||
return Err(ToolError::permission_denied(
|
||||
"workspace path must name an entry inside the workspace".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
validate_windows_relative_segments(&relative.to_string_lossy(), cfg!(windows))
|
||||
.map_err(ToolError::permission_denied)?;
|
||||
}
|
||||
|
||||
Ok(relative)
|
||||
}
|
||||
|
||||
/// Resolve an exact `~` or `~/` path prefix to the current user's home directory.
|
||||
///
|
||||
/// Only exact `~` and `~/` (or `~\` on Windows) prefixes are resolved. Prefixes like
|
||||
|
||||
Loaded 100 of 363 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user