feat: consolidate 0.10.1 Engine, reviewed TS mods and Ratatui integration

Use the existing Engine for ACP, children and recursive RLM; retain captured
approval, deadline, history/checkpoint, delivery and one-bill usage authority.
Move legacy thread controls to the authenticated canonical owner with complete
history and durable operation receipts. Keep Rust wire/policy authority and
opt-in extension/Native execution; adopt the pinned Ratatui kit and source/docs.

Exact V22 local validation: Rust 1351 passed/0 failed/0 ignored; additional
localization 54 passed/0 failed/0 ignored; workspace/all-target/all-feature
Clippy passed under existing CI policy.
CLI: 3 actual processes / 6 checks / 6 local fixture requests / 0 paid calls; approval, resume, cancellation and EOF effects passed.
npm test + check:web: 1180 passed / 0 failed / 7 explicit skips (wrapper101, SDK19, host378, website682); strict TypeScript and production web passed.

No hosted/platform/install/live ChatGPT/DO adapter/publication/release claim.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-10-03 05:11:01 -07:00
1 parent cf03333740
commit 5cf09da776
999 files changed
+243889 -97113

No files matched your search

+1
View File
@@ -34,6 +34,7 @@
cargo fmt --all -- --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo build -p codewhale-cli --bin codewhale --all-features --locked
# Use the shared test HOME boundary while retaining the real toolchain.
RUST_MIN_STACK=16777216 sh scripts/with-hermetic-test-home.sh cargo test --workspace --all-features --locked
# Parity gates as first-class steps so drift surfaces as a named failure,
+11
View File
@@ -100,3 +100,14 @@ slow-timeout = { period = "60s", terminate-after = 10 }
fail-fast = false
final-status-level = "slow"
failure-output = "immediate-final"
# Exact compiled-image Native delivery receipt comes from this same full run.
# Only the fixed containment and memory marker/output is retained on success; the rest
# of the workspace keeps its existing output policy.
[profile.ci.junit]
path = "junit.xml"
report-name = "codewhale-native-host"
[[profile.ci.overrides]]
filter = 'package(codewhale-tui) & kind(lib) & (test(/^extension_host::tests::compiled_native_host_cannot_read_secrets_or_write_outside_its_data_dir$/) | test(/^extension_host::tests::compiled_native_host_memory_cap_is_enforced$/))'
junit.store-success-output = true
+83
View File
@@ -0,0 +1,83 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const { test } = require("node:test");
const sourceRoot = path.resolve(__dirname, "../..");
function fixture(t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "cw-d8-launch-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const repo = path.join(root, "source");
fs.mkdirSync(path.join(repo, "scripts"), { recursive: true });
for (const name of ["dev-test.sh", "dev-cache.sh", "dev-cargo.sh", "with-hermetic-test-home.sh", "build-lock.py"]) {
fs.copyFileSync(path.join(sourceRoot, "scripts", name), path.join(repo, "scripts", name));
fs.chmodSync(path.join(repo, "scripts", name), 0o755);
}
fs.writeFileSync(path.join(repo, "Cargo.toml"), '[workspace]\nmembers = []\n');
const bin = path.join(root, "bin");
const home = path.join(root, "ambient home");
fs.mkdirSync(bin); fs.mkdirSync(home);
const log = path.join(root, "cargo-invocations.jsonl");
fs.writeFileSync(path.join(bin, "cargo"), `#!${process.execPath}
const fs = require("node:fs");
const args = process.argv.slice(2);
if (args.includes("--version")) { console.log("cargo 1.97.0 (source fixture)"); process.exit(0); }
fs.appendFileSync(process.env.D8_INVOCATIONS, JSON.stringify({args, home: process.env.HOME, cache: process.env.CODEWHALE_CACHE_ROOT}) + "\\n");
if (args.includes("build") && process.env.D8_BUILD_FAILURE) process.exit(Number(process.env.D8_BUILD_FAILURE));
console.log("test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s");
`);
fs.writeFileSync(path.join(bin, "rustc"), `#!/bin/sh\nprintf '%s\n' 'rustc 1.97.0 (source fixture)' 'commit-hash: source-fixture'\n`);
fs.writeFileSync(path.join(bin, "rustup"), `#!/bin/sh\nif [ "$1" = which ] && [ "$2" = rustc ]; then printf '%s\n' "$D8_TOOLCHAIN/rustc"; else exit 99; fi\n`);
for (const name of ["cargo", "rustc", "rustup"]) fs.chmodSync(path.join(bin, name), 0o755);
return {
home, log,
run(area, failure) {
const result = spawnSync("sh", [path.join(repo, "scripts/dev-test.sh"), area, "filter with spaces"], {
cwd: repo, encoding: "utf8",
env: { ...process.env, PATH: `${bin}${path.delimiter}${process.env.PATH}`, HOME: home,
USERPROFILE: home, CARGO_HOME: path.join(root, "cargo-home"), RUSTUP_HOME: path.join(root, "rustup-home"),
CODEWHALE_DEV_NEXTEST: "0", CODEWHALE_BUILD_LOCK: "0", CODEWHALE_BUILD_LOCK_HELD: "",
CODEWHALE_CACHE_ROOT: path.join(root, "cache"), D8_INVOCATIONS: log, D8_TOOLCHAIN: bin,
D8_BUILD_FAILURE: failure ? String(failure) : "" },
});
const calls = fs.existsSync(log) ? fs.readFileSync(log, "utf8").trim().split("\n").map(JSON.parse) : [];
return { result, calls };
},
};
}
for (const area of ["tui-integration", "tui-cucumber"]) {
test(`${area} builds the canonical executable before isolated acceptance`, (t) => {
const f = fixture(t); const { result, calls } = f.run(area);
assert.equal(result.status, 0, result.stderr + result.stdout);
assert.equal(calls.length, 2);
const build = calls[0].args.slice(calls[0].args.indexOf("build"));
assert.deepEqual(build, ["build", "-p", "codewhale-cli", "--bin", "codewhale", "--locked"]);
const check = calls[1].args.slice(calls[1].args.indexOf("test"));
assert.deepEqual(check, ["test", "-p", "codewhale-tui", "--test", area === "tui-integration" ? "integration" : "cucumber", "--locked", "filter with spaces"]);
for (const call of calls) {
assert.notEqual(call.home, f.home);
assert.equal(fs.existsSync(call.home), false, "temporary test HOME is removed");
assert.ok(call.cache.endsWith("cache"));
}
});
}
test("canonical build failure prevents acceptance and preserves its exit status", (t) => {
const f = fixture(t); const { result, calls } = f.run("tui-integration", 39);
assert.equal(result.status, 39, result.stderr + result.stdout);
assert.equal(calls.length, 1);
assert.ok(calls[0].args.includes("build"));
});
test("library-only verification does not build an executable", (t) => {
const f = fixture(t); const { result, calls } = f.run("config");
assert.equal(result.status, 0, result.stderr + result.stdout);
assert.equal(calls.length, 1);
assert.ok(calls[0].args.includes("test"));
assert.ok(calls[0].args.includes("--lib"));
});
+7 -2
View File
@@ -63,11 +63,16 @@ assert.match(
"the non-PR CNB fallback must be named explicitly",
);
assert.match(
namedStep(ciTestJob, "Build canonical executable for acceptance tests"),
/cargo build -p codewhale-cli --bin codewhale --all-features --locked/,
"Engine acceptance must build the canonical executable first",
);
const npmSmokeJob = ci.match(/^ npm-wrapper-smoke:\n([\s\S]*?)(?=^ \S)/m)?.[1];
assert.ok(npmSmokeJob, "CI must retain the required npm-wrapper job");
assert.match(
namedStep(npmSmokeJob, "Build wrapper binaries"),
/run: cargo build --release --locked -p codewhale-cli -p codewhale-tui/,
/run: cargo build --release --locked -p codewhale-cli --bin codewhale/,
);
assert.match(
namedStep(npmSmokeJob, "Smoke wrapper install and delegated entrypoints"),
@@ -154,7 +159,7 @@ function classify(paths) {
}
const rustSources = execFileSync("git", ["ls-files", "-z", "--", "*.rs"], { cwd: repoRoot, encoding: "utf8" })
.split("\0")
.filter(Boolean);
.filter((file) => file && fs.existsSync(path.join(repoRoot, file)));
const includeTargets = new Set();
const includePattern =
/include_(?:str|bytes)!\s*\(\s*(?:concat!\s*\(\s*(?:env!\s*\(\s*"(\w+)"\s*\)\s*,\s*)?)?"([^"]+)"/g;
+114 -7
View File
@@ -267,6 +267,7 @@ jobs:
sh scripts/with-hermetic-test-home.test.sh
bash .github/scripts/update-homebrew-tap.test.sh
node .github/scripts/release-workflows.test.js
node --test .github/scripts/d8-executable.test.js
node --test scripts/release/assemble-release-assets.test.js
node --test scripts/release/ensure-release-assets-absent.test.js
node --test scripts/release/verify-release-inventory.test.js
@@ -339,14 +340,14 @@ jobs:
- name: Run extension host suites and check the committed bundle
# The TypeScript extension host (experimental, [features]
# extension_host). Its tests import the committed dist/ only, so
# `npm test` needs no install; the rebuild then fails if dist/ drifted
# from src/ or the lockfile. Node 22 matches the host's engines floor.
# extension_host). Tests exercise both the committed bundle and
# source services backed by locked Cordis dependencies. Install before
# testing; then reject any bundle drift. Node 22 matches the host floor.
run: |
set -euo pipefail
cd crates/tui/extension-host
npm test
npm ci --ignore-scripts
npm test
npm run typecheck
npm run build
git diff --exit-code -- dist
@@ -364,6 +365,46 @@ jobs:
cd crates/tui/extension-host
npm run test:bun
extension-host-runtimes:
name: Extension host runtimes (${{ matrix.os }})
needs: changes
if: needs.changes.outputs.heavy == 'true'
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: crates/tui/extension-host
shell: bash
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/setup-node@v7
with:
node-version: 22
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2
with:
bun-version: 1.4.0
- run: npm ci
- run: npm run typecheck && npm run build
- name: Required Node and Bun source-host receipts
run: npm test && npm run test:bun
- name: Compile the same entry with the installed runtime and test it
run: |
bun_binary="$(bun -p 'process.execPath')"
host_binary="$(node -p "require('path').join(process.env.RUNNER_TEMP, 'codewhale-extension-host' + (process.platform === 'win32' ? '.exe' : ''))")"
node compile-host.mjs --bun "$bun_binary" --output "$host_binary"
CODEWHALE_COMPILED_HOST_TEST_BINARY="$host_binary" CODEWHALE_BUN_TEST_BINARY="$bun_binary" node --test test/compiled-host.test.mjs
- name: Keep runtime receipts on failure
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: extension-host-runtime-${{ matrix.os }}
path: crates/tui/extension-host/dist/builtin-modules.json
if-no-files-found: error
vscode-extension:
name: VS Code extension
timeout-minutes: 15
@@ -871,6 +912,35 @@ jobs:
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
with:
node-version: 22
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
with:
bun-version: 1.4.0
- name: Build matching compiled-host input for required Native containment receipts
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
shell: bash
run: |
set -euo pipefail
bun_binary="$(bun -p 'process.execPath')"
host_binary="$(node -p "require('path').join(process.env.RUNNER_TEMP, 'codewhale-extension-host' + (process.platform === 'win32' ? '.exe' : ''))")"
compiled_info="$(node crates/tui/extension-host/compile-host.mjs --bun "$bun_binary" --output "$host_binary")"
printf 'CODEWHALE_COMPILED_HOST_TEST_BINARY=%s\n' "$host_binary" >> "$GITHUB_ENV"
node - "$host_binary" "$bun_binary" "$compiled_info" >> "$GITHUB_ENV" <<'JS'
const fs = require('node:fs'), crypto = require('node:crypto');
const digest = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
const info = JSON.parse(process.argv[4]);
if (!info.platform || !info.arch || info.platform !== info.compiler?.platform || info.arch !== info.compiler?.arch) throw new Error('image/compiler identity mismatch');
console.log('CODEWHALE_COMPILED_HOST_TEST_PLATFORM=' + info.platform);
console.log('CODEWHALE_COMPILED_HOST_TEST_ARCH=' + info.arch);
console.log('CODEWHALE_COMPILED_HOST_TEST_SHA256=' + digest(process.argv[2]));
console.log('CODEWHALE_COMPILED_HOST_TEST_BUN=' + process.argv[3]);
console.log('CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256=' + digest(process.argv[3]));
console.log('CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256=' + digest('crates/tui/extension-host/dist/codewhale-extension-host.mjs'));
JS
# The required hermetic full invocation executes the shared actual
# compiled Native secret/history/write scenario on each OS; Windows
# additionally requires Node/Bun/compiled LPAC owner/reparse probes.
# Missing requested compiled input or a failed sandbox is red.
- name: Hold this machine's build lock (self-hosted)
# The self-hosted Mac is also a developer machine: local agents build
# through scripts/dev-cargo.sh, which holds this lock, and two Cargo
@@ -905,6 +975,12 @@ jobs:
cat "$log"
# Scripts inside this job already run under the lock.
echo "CODEWHALE_BUILD_LOCK_HELD=1" >> "$GITHUB_ENV"
- name: Build canonical executable for acceptance tests
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
shell: bash
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
env:
RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
- name: Run tests
# Same test binaries as `cargo test`, run by cargo-nextest: one
# process per test, all runner cores busy, slow tests named instead
@@ -930,6 +1006,7 @@ jobs:
exit "$status"
env:
CODEWHALE_EXT_HOST_TESTS: '1'
CODEWHALE_EXT_HOST_BUN_TESTS: '1'
# sccache 0.17 panics resolving its config directory under the
# isolated Windows home before Cargo can compile or run any test.
# Bypass only that optional cache; keep the full suite and isolation.
@@ -957,6 +1034,7 @@ jobs:
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
CODEWHALE_EXT_HOST_TESTS: '1'
CODEWHALE_EXT_HOST_BUN_TESTS: '1'
RUST_MIN_STACK: '16777216'
RUSTFLAGS: ''
CARGO_ENCODED_RUSTFLAGS: ''
@@ -1059,6 +1137,35 @@ jobs:
path: ${{ runner.temp }}/shared-process-workspace/
if-no-files-found: error
retention-days: 30
- name: Export exact compiled-image Native containment receipt
# JUnit is from the same full invocation: no second compile/test run.
# The official entire CI run must be green before release can consume
# this artifact. Missing/changed inputs or silent test skips are red.
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
shell: bash
run: |
set -euo pipefail
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
git diff --exit-code
git diff --cached --exit-code
python3 scripts/compiled-host-native-receipt.py \
--junit target/nextest/ci/junit.xml \
--compiled-image "$CODEWHALE_COMPILED_HOST_TEST_BINARY" \
--bundle crates/tui/extension-host/dist/codewhale-extension-host.mjs \
--bun "$CODEWHALE_COMPILED_HOST_TEST_BUN" --source-sha "$GITHUB_SHA" \
--image-platform "$CODEWHALE_COMPILED_HOST_TEST_PLATFORM" --image-arch "$CODEWHALE_COMPILED_HOST_TEST_ARCH" \
--host-sha256 "$CODEWHALE_COMPILED_HOST_TEST_SHA256" \
--runtime-sha256 "$CODEWHALE_COMPILED_HOST_TEST_RUNTIME_SHA256" \
--bundle-sha256 "$CODEWHALE_COMPILED_HOST_TEST_BUNDLE_SHA256" \
--output "$RUNNER_TEMP/native-compiled-host"
- name: Retain exact compiled image and Native receipt
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: native-compiled-host-${{ runner.os }}-${{ runner.arch }}
path: ${{ runner.temp }}/native-compiled-host/
if-no-files-found: error
retention-days: 30
- name: Run doctests
# The shared-process command already includes every workspace doctest.
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && !(github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice')
@@ -1087,7 +1194,7 @@ jobs:
# ~2min off the Windows critical path. Self-hosted Mac only; hosted
# Mac legs run it in `macos-budget` (see the RSS step above).
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest' && needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true'
run: cargo run -p codewhale-tui --all-features -- eval
run: cargo run -p codewhale-cli --bin codewhale --all-features -- eval
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') && steps.sccache.outcome == 'success'
continue-on-error: true
@@ -1134,7 +1241,7 @@ jobs:
- name: Check persistence-backlog RSS budget
run: python3 scripts/check-persistence-backlog-budget.py
- name: Run Offline Eval Harness
run: cargo run -p codewhale-tui --all-features -- eval
run: cargo run -p codewhale-cli --bin codewhale --all-features -- eval
npm-wrapper-smoke:
name: npm wrapper smoke
@@ -1203,7 +1310,7 @@ jobs:
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: cargo build --release --locked -p codewhale-cli -p codewhale-tui
run: cargo build --release --locked -p codewhale-cli --bin codewhale
- name: Smoke wrapper install and delegated entrypoints
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request')
timeout-minutes: 5
+3 -3
View File
@@ -75,11 +75,11 @@ jobs:
- name: Build the standalone shared owner and verify its transport
if: runner.os == 'macOS'
run: |
cargo build --locked -j 2 -p codewhale-tui --bin codewhale-tui
python3 pet/scripts/check-shared.py target/debug/codewhale-tui
cargo build --locked -j 2 -p codewhale-cli --bin codewhale
python3 pet/scripts/check-shared.py target/debug/codewhale
- name: Build and exercise the Apple host and PCM boundary
if: runner.os == 'macOS'
run: PET_OWNER_BINARY="$PWD/target/debug/codewhale-tui" ./pet/scripts/check-apple.sh
run: PET_OWNER_BINARY="$PWD/target/debug/codewhale" ./pet/scripts/check-apple.sh
- name: Package the locally signed macOS app
if: runner.os == 'macOS'
run: ditto -c -k --keepParent pet/macos/CodewhalePet.app codewhale-pet-macos.zip
+93 -2
View File
@@ -11,6 +11,21 @@ on:
description: Workspace version without a v prefix
required: true
type: string
compiled_host_delivery:
description: Explicitly include qualified optional companions; Node stays default
required: false
default: false
type: boolean
compiled_host_run_id:
description: Green official CI run at this exact SHA containing Native image proof
required: false
default: ''
type: string
compiled_host_targets:
description: Comma-separated matching-native targets with qualified CI receipts
required: false
default: linux-x64,macos-arm64,windows-x64
type: string
retention_days:
description: Retention for Actions-only intermediate and assembled artifacts
required: false
@@ -19,6 +34,7 @@ on:
permissions:
contents: read
actions: read
env:
CARGO_TERM_COLOR: always
@@ -49,6 +65,22 @@ jobs:
echo "::error::Reusable workflow SHA ${actual} does not match source_sha ${SOURCE_SHA}." >&2
exit 1
fi
- name: Require explicit matching-native qualification selection
if: inputs.compiled_host_delivery
env:
QUALIFICATION_RUN: ${{ inputs.compiled_host_run_id }}
HOST_TARGETS: ${{ inputs.compiled_host_targets }}
run: |
set -euo pipefail
[[ "${QUALIFICATION_RUN}" =~ ^[0-9]+$ ]] || { echo 'Enabled host delivery needs an exact green CI run id' >&2; exit 1; }
[[ -n "${HOST_TARGETS}" ]] || { echo 'No compiled host targets selected' >&2; exit 1; }
declare -A seen
IFS=',' read -ra selected <<< "${HOST_TARGETS}"
for target in "${selected[@]}"; do
case "${target}" in linux-x64|linux-arm64|macos-x64|macos-arm64|windows-x64|windows-arm64) ;; *) echo "Unsupported compiled host target ${target}" >&2; exit 1 ;; esac
[[ -z "${seen[${target}]:-}" ]] || { echo "Duplicate compiled host target ${target}" >&2; exit 1; }
seen["${target}"]=1
done
build:
name: Build ${{ matrix.platform }}
@@ -257,6 +289,38 @@ jobs:
# completeness/updater contract. This is the same runtime, not a
# separately compiled or installed TUI command.
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.compat_tui_artifact }}"
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2
if: inputs.compiled_host_delivery && contains(format(',{0},', inputs.compiled_host_targets), format(',{0},', matrix.platform))
with:
bun-version: 1.4.0
- name: Stage exact qualified optional host image
if: inputs.compiled_host_delivery && contains(format(',{0},', inputs.compiled_host_targets), format(',{0},', matrix.platform))
shell: bash
env:
GH_TOKEN: ${{ github.token }}
QUALIFICATION_RUN: ${{ inputs.compiled_host_run_id }}
TARGET_PLATFORM: ${{ matrix.platform }}
RELEASE_VERSION: ${{ inputs.version }}
RUNTIME_CLOSURE_DIR: ${{ vars.CODEWHALE_COMPILED_HOST_CLOSURE_DIR }}
run: |
set -euo pipefail
proof_dir="${RUNNER_TEMP}/compiled-host-proof-${TARGET_PLATFORM}"
node scripts/release/fetch-compiled-host-proof.js --repo "${GITHUB_REPOSITORY}" --run-id "${QUALIFICATION_RUN}" --source-sha "${GITHUB_SHA}" --target "${TARGET_PLATFORM}" --output "${proof_dir}"
test -n "${RUNTIME_CLOSURE_DIR}" || { echo 'Enabled compiled-host delivery requires reviewed complete local runtime notices/relink-source closure' >&2; exit 1; }
bun_binary="$(bun -p 'process.execPath')"
extension=""
if [[ "${TARGET_PLATFORM}" == windows-* ]]; then extension=.exe; fi
node scripts/release/stage-compiled-host.mjs --bun "${bun_binary}" --compiled-image "${proof_dir}/codewhale-extension-host${extension}" --native-receipt "${proof_dir}/native-receipt.json" --runtime-closure "${RUNTIME_CLOSURE_DIR}/${TARGET_PLATFORM}.json" --target "${TARGET_PLATFORM}" --version "${RELEASE_VERSION}" --source-sha "${GITHUB_SHA}" --output-dir "compiled-host-${TARGET_PLATFORM}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: inputs.compiled_host_delivery && contains(format(',{0},', inputs.compiled_host_targets), format(',{0},', matrix.platform))
with:
name: codewhale-compiled-host-${{ matrix.platform }}
path: |
compiled-host-${{ matrix.platform }}/codewhale-extension-host-*
compiled-host-${{ matrix.platform }}/codewhale-extension-hosts.json
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.cli_artifact }}
@@ -290,6 +354,10 @@ jobs:
with:
path: artifacts
pattern: '*'
- name: Collect only qualified requested compiled hosts
if: inputs.compiled_host_delivery
shell: bash
run: node scripts/release/stage-compiled-host.mjs --collect artifacts --output-dir artifacts/codewhale-compiled-hosts
- name: Create and checksum platform archives
shell: bash
env:
@@ -313,6 +381,14 @@ jobs:
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: inputs.compiled_host_delivery
with:
name: codewhale-compiled-hosts
path: artifacts/codewhale-compiled-hosts/*
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
windows-installer:
timeout-minutes: 15
@@ -324,7 +400,7 @@ jobs:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
pattern: '*windows-x64.exe'
pattern: '*windows-x64*'
- name: Install NSIS
shell: pwsh
run: choco install nsis -y --no-progress
@@ -341,8 +417,23 @@ jobs:
if (!(Test-Path $makensis)) {
throw "makensis.exe not found after NSIS install"
}
$hostDefine = @()
$hostCatalog = "artifacts\codewhale-compiled-host-windows-x64\codewhale-extension-hosts.json"
if (Test-Path $hostCatalog) {
node -e "const h=require('./npm/codewhale/scripts/compiled-hosts'); const fs=require('fs'); const p=require('path'); const f=process.argv[1]; h.verifyDirectory(p.dirname(f), h.parseCatalog(fs.readFileSync(f),'${{ inputs.version }}'));" $hostCatalog
if ($LASTEXITCODE -ne 0) { throw 'Compiled host payload/catalog verification failed' }
$catalog = Get-Content -Raw $hostCatalog | ConvertFrom-Json
$hostEntry = @($catalog.hosts | Where-Object { $_.target -eq 'windows-x64' })
if ($hostEntry.Count -ne 1) { throw 'No unique qualified Windows host' }
$hostDir = Split-Path $hostCatalog
Copy-Item "$hostDir\$($hostEntry[0].asset)" "scripts\installer\codewhale-extension-host.exe"
Copy-Item "$hostDir\$($hostEntry[0].notices_asset)" "scripts\installer\codewhale-extension-host.LICENSES.txt"
Copy-Item "$hostDir\$($hostEntry[0].source_asset)" "scripts\installer\codewhale-extension-host.relink-source.tar.gz"
Copy-Item $hostCatalog "scripts\installer\codewhale-extension-host.release.json"
$hostDefine = @('/DCOMPILED_HOST=1')
}
Push-Location scripts\installer
& $makensis "/DVERSION=${{ inputs.version }}" "codewhale.nsi"
& $makensis "/DVERSION=${{ inputs.version }}" @hostDefine "codewhale.nsi"
Pop-Location
if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) {
throw "CodeWhaleSetup.exe was not produced"
+6
View File
@@ -124,10 +124,16 @@ jobs:
uses: ./.github/workflows/release-parity.yml
artifacts:
permissions:
contents: read
actions: read
needs: [resolve, web]
if: ${{ !cancelled() && needs.resolve.result == 'success' && needs.web.result == 'success' }}
uses: ./.github/workflows/release-artifacts.yml
with:
source_sha: ${{ needs.resolve.outputs.sha }}
version: ${{ needs.resolve.outputs.version }}
compiled_host_delivery: ${{ vars.CODEWHALE_COMPILED_HOST_DELIVERY == '1' }}
compiled_host_run_id: ${{ vars.CODEWHALE_COMPILED_HOST_QUALIFICATION_RUN_ID || '' }}
compiled_host_targets: ${{ vars.CODEWHALE_COMPILED_HOST_TARGETS || 'linux-x64,macos-arm64,windows-x64' }}
retention_days: 7
+2
View File
@@ -98,6 +98,8 @@ jobs:
echo "::warning::Could not add swap; continuing with the runner default"
fi
df -h / /mnt; free -m
- name: Build canonical executable for acceptance tests
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
- name: Workspace tests
# Same test binaries as `cargo test`, run by cargo-nextest: one process
# per test. This gate used libtest, where every test shares one process,
+6
View File
@@ -133,12 +133,18 @@ jobs:
uses: ./.github/workflows/release-parity.yml
artifacts:
permissions:
contents: read
actions: read
needs: [parity, resolve]
if: ${{ !cancelled() && needs.resolve.result == 'success' && needs.parity.result == 'success' }}
uses: ./.github/workflows/release-artifacts.yml
with:
source_sha: ${{ needs.resolve.outputs.sha }}
version: ${{ needs.resolve.outputs.version }}
compiled_host_delivery: ${{ vars.CODEWHALE_COMPILED_HOST_DELIVERY == '1' }}
compiled_host_run_id: ${{ vars.CODEWHALE_COMPILED_HOST_QUALIFICATION_RUN_ID || '' }}
compiled_host_targets: ${{ vars.CODEWHALE_COMPILED_HOST_TARGETS || 'linux-x64,macos-arm64,windows-x64' }}
retention_days: 14
docker-build:
+10 -6
View File
@@ -21,8 +21,8 @@ note below before upgrading.
default: turn it on with `[features] extension_host = true`. A plugin that
declares a `native` TypeScript or JavaScript entry (the Cordis / DeepSeek
Harness plugin model) can contribute tools, slash commands, pre-execution
policy hooks, additive prompt sections and plugin-local JSON state. Skills
and MCP are not host services yet. Its code runs in a separate
policy hooks, additive prompt sections, reviewed skill roots and plugin-local
JSON state. Its code runs in a separate
Node process, never inside Codewhale (Bun is an opt-in through
`[extension_host] runtime`), and every call goes through Codewhale's own gate:
an extension tool is never treated as read-only, so it always meets the
@@ -72,9 +72,12 @@ note below before upgrading.
- Extension host identity is checked at startup: its reported trust tier and
built-in module digests must match the process Rust launched. Built-in host
code and third-party plugins have separate process and owner namespaces;
the built-in module table is still empty. MCP remains in Rust in 0.10.1;
moving it to the TypeScript host requires recorded parity and a rollout
release before the Rust implementation can be removed.
the table pins the MCP protocol module and the execution harness. The
optional Host MCP backend uses the official TypeScript SDK for framing,
with Rust retaining credentials, network/process access, approval and exact
operation tickets. All 30 existing recorded comparisons pass locally.
The Rust backend stays the default until platform and rollout gates pass;
its protocol adapters remain through the compatibility window.
- A plugin can declare several `native` entries (`native.paths`, up to 64). They
activate in order as one plugin, so one disable, review change or crash takes
all of them down, and if one entry fails to activate, nothing from the entries
@@ -1144,9 +1147,10 @@ note below before upgrading.
### Contributors
Fifteen contributors and issue reporters are credited below, including
Sixteen contributors and issue reporters are credited below, including
@cenab's provider report.
- **[@Guan0923](https://github.com/Guan0923)** — accepted case-insensitive HTTP(S) schemes in `config doctor` without rewriting the configured URL ([#6819](https://github.com/Hmbown/Codewhale/pull/6819)).
- **[@harryvgiunta](https://github.com/harryvgiunta)** — added Yolo-Auto as a bundled OpenAI-compatible host, starting on the vendor's recommended `qwen3.8-flash` model ([#6408](https://github.com/Hmbown/Codewhale/pull/6408)).
- **[@asto18089](https://github.com/asto18089)** — contributed the integrated runtime liveness, context, search, JavaScript execution, stopship scout and pet repairs, preserving their original contributor commits ([#6799](https://github.com/Hmbown/Codewhale/pull/6799)); made context rule and chain-segment source labels repository-relative ([#6739](https://github.com/Hmbown/Codewhale/pull/6739)).
- **[@qiuYliangM](https://github.com/qiuYliangM)** — made provider-bound project instruction and constitution labels stable across directory moves and kept their absolute paths in operator reports ([#6799](https://github.com/Hmbown/Codewhale/pull/6799)).
Generated
+5 -5
View File
@@ -815,6 +815,7 @@ version = "0.10.1"
dependencies = [
"anyhow",
"axum",
"chrono",
"codewhale-agent",
"codewhale-config",
"codewhale-core",
@@ -824,9 +825,11 @@ dependencies = [
"codewhale-release",
"codewhale-state",
"reqwest",
"rusqlite",
"rustls",
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.20",
"tokio",
@@ -834,6 +837,7 @@ dependencies = [
"tower-http 0.7.1",
"tracing",
"uuid",
"windows-sys 0.61.2",
]
[[package]]
@@ -1042,8 +1046,6 @@ dependencies = [
name = "codewhale-models"
version = "0.10.1"
dependencies = [
"anyhow",
"chrono",
"codewhale-config",
"codewhale-core",
"serde",
@@ -1245,7 +1247,7 @@ dependencies = [
"base64 0.23.1",
"chrono",
"clap",
"clap_complete",
"codewhale-app-server",
"codewhale-build-support",
"codewhale-cloud-facts",
"codewhale-command-contract",
@@ -1289,7 +1291,6 @@ dependencies = [
"libc",
"liblzma",
"lru",
"mimalloc",
"oauth2",
"objc2",
"objc2-foundation",
@@ -1307,7 +1308,6 @@ dependencies = [
"rquickjs",
"rusqlite",
"rustls",
"rusty_alloc-api",
"schemars",
"semver",
"serde",
+2 -2
View File
@@ -83,8 +83,8 @@ or shell execution, and `/mode work` when you want it to make changes. Press
## Terminal, apps, and Computer Use
The terminal and graphical clients connect to the Codewhale Runtime, which runs
the agent and its tools:
The terminal and graphical clients connect to the
[Codewhale Engine](docs/ARCHITECTURE.md), which runs the agent and its tools:
- **Terminal:** `codewhale` opens the interactive interface; `codewhale exec`
runs a task from a script or CI job.
+781
View File
@@ -139,3 +139,784 @@ SOFTWARE.
The host bundle's own copy of these notices is `dist/LICENSES.txt`, generated
from the bundler's metafile; Codewhale writes it beside the bundle it
materialises (`~/.codewhale/extension-host/<sha256>/LICENSES.txt`).
## Model Context Protocol SDK and bundled dependencies
The pinned builtin MCP bundle (`crates/tui/extension-host/dist/builtin/mcp.mjs`)
contains the following packages. The package metadata labels each as MIT; the
MCP packages also carry the upstream licensing-transition statement and full
Apache-2.0 text reproduced below. Their exact distributed notices are preserved.
### `@modelcontextprotocol/client` 2.0.0
```text
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
No rights beyond those granted by the applicable original license are conveyed for such contributions.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
---
MIT License
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Creative Commons Attribution 4.0 International (CC-BY-4.0)
Documentation in this project (excluding specifications) is licensed under
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.
```
### `@modelcontextprotocol/core` 2.0.0
```text
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
No rights beyond those granted by the applicable original license are conveyed for such contributions.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
---
MIT License
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Creative Commons Attribution 4.0 International (CC-BY-4.0)
Documentation in this project (excluding specifications) is licensed under
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.
```
### `eventsource` 3.0.7
```text
The MIT License
Copyright (c) EventSource GitHub organisation
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
```
### `eventsource-parser` 3.1.1
```text
MIT License
Copyright (c) 2026 Espen Hovlandsdal <[email protected]>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `jose` 6.2.12
```text
The MIT License (MIT)
Copyright (c) 2018 Filip Skokan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `pkce-challenge` 5.0.1
```text
MIT License
Copyright (c) 2019
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `zod` 4.6.5
```text
MIT License
Copyright (c) 2025 Colin McDonnell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Native composition and YAML dependencies
The host and its offline composition reviewer also contain these packages.
Vendored composition adaptations are recorded in
`crates/tui/extension-host/src/dsh/upstream/UPSTREAM.json`.
### `@deepseek-ai/cordis-plugin-group` 1.0.2
```text
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `@deepseek-ai/cordis-plugin-include` 1.0.7
```text
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `@deepseek-ai/cordis-plugin-loader` 1.0.3
```text
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `js-yaml` 4.3.2
```text
MIT License)
Copyright (C) 2011-2015 by Vitaly Puzrin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
### `acorn` 8.18.0
Used for nonexecuting syntax preparation of reviewed Bun composition imports.
```text
MIT License
Copyright (C) 2012-2022 by various contributors (see AUTHORS)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
```
## DeepSeek Harness presets, hooks and persona packages — MIT
The embedded extension host includes these pinned DeepSeek Harness packages:
- `@deepseek-ai/dsh-agent-presets` 0.1.6-alpha.1
- `@deepseek-ai/dsh-hook-protocol` 0.1.6-alpha.1
- `@deepseek-ai/dsh-hooks-claude-code` 0.1.6-alpha.1
- `@deepseek-ai/dsh-hooks-codex` 0.1.6-alpha.1
- `@deepseek-ai/dsh-persona` 0.1.6-alpha.1
- `@deepseek-ai/dsh-system-prompt` 0.1.6-alpha.1
Upstream: <https://github.com/deepseek-ai/deepseek-harness>, commit
`0d1f50007f9bca3f52b06e1c3074fa14d5fb0720`.
Preset discovery and metadata, hook matching and dialect codecs, and additive
persona templates run through receipt-backed inputs. Core retains selection,
prompt assembly, sessions, approvals and writable roots. Original persona source
hashes are recorded in `crates/tui/extension-host/src/dsh/persona.UPSTREAM.json`.
The identical upstream license text for all six packages is also carried in
`crates/tui/extension-host/dist/LICENSES.txt`.
```
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## Optional compiled extension host
Node remains the default extension-host runtime. An explicitly installed
`codewhale-extension-host` compiled Bun image carries its exact-build runtime
notices in `codewhale-extension-host.LICENSES.txt` and its corresponding source
and relinking inputs in `codewhale-extension-host.relink-source.tar.gz`. These
are required companions, separate from this repository's Rust/JavaScript
dependency notices. No image is eligible for release delivery until both the
matching-native containment evidence and the exact runtime notice/source
closure are complete.
Bun's matching source license identifies statically linked LGPL JavaScriptCore,
WebCore, and TinyCC, as well as other linked and embedded dependencies. The
release preparation contract is documented in
`scripts/release/compiled-host-licenses/README.md`. This file does not replace
the full notices carried with an eligible compiled image.
+14 -1389
View File
File diff suppressed because it is too large. Load diff
+6
View File
@@ -15,6 +15,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
chrono.workspace = true
codewhale-agent = { path = "../agent", version = "0.10.1" }
codewhale-config = { path = "../config", version = "0.10.1" }
codewhale-core = { path = "../core", version = "0.10.1" }
@@ -34,5 +35,10 @@ reqwest = { workspace = true, features = ["json"] }
uuid.workspace = true
[dev-dependencies]
rusqlite.workspace = true
tempfile.workspace = true
tower = "0.5"
[target.'cfg(windows)'.dependencies]
sha2.workspace = true
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading", "Win32_System_Pipes"] }
+409
View File
@@ -0,0 +1,409 @@
//! Authenticated local control attachment to the already held Runtime owner.
//! The private receipt routes; the connected kernel process authenticates it.
//! This forwards the existing bounded dispatcher protocol, never HTTP bearers.
use anyhow::Result;
use std::path::PathBuf;
#[cfg(any(unix, windows))]
mod platform {
use super::*;
use crate::daemon_socket::AuthorizedPeer;
use crate::daemon_socket::{default_socket_path, owner_work};
use crate::{BoundedLines, ParsedStdioLine, parse_stdio_line, write_stdio_line};
use anyhow::{Context as _, bail};
use codewhale_config::private_directory::PrivateDirectory;
use codewhale_protocol::RuntimeOwnerReceipt;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt as _, BufReader};
#[cfg(unix)]
use tokio::net::UnixStream;
#[cfg(unix)]
type Connection = UnixStream;
#[cfg(windows)]
type Connection = tokio::net::windows::named_pipe::NamedPipeClient;
pub struct OwnerClient {
read: BoundedLines<BufReader<tokio::io::ReadHalf<Connection>>>,
write: tokio::io::WriteHalf<Connection>,
peer: Arc<AuthorizedPeer>,
receipt: RuntimeOwnerReceipt,
routing: Option<crate::RuntimeOwnerRouting>,
frontend: crate::daemon_socket::AttachFrontend,
}
pub async fn connect(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
) -> Result<OwnerClient> {
connect_if_published(config_path, selected).await?.context(
"the selected Runtime has no authenticated live owner; start its canonical host",
)
}
pub async fn connect_if_published(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
) -> Result<Option<OwnerClient>> {
connect_frontend(
config_path,
selected,
crate::daemon_socket::AttachFrontend::Control,
None,
None,
None,
None,
)
.await
}
pub async fn connect_acp_if_published(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
) -> Result<Option<OwnerClient>> {
connect_frontend(
config_path,
selected,
crate::daemon_socket::AttachFrontend::Acp,
None,
None,
None,
None,
)
.await
}
pub async fn connect_listener_if_published(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
listener: crate::RuntimeListenerSelection,
expected_owner: RuntimeOwnerReceipt,
) -> Result<Option<OwnerClient>> {
listener.validate_bounds()?;
connect_frontend(
config_path,
selected,
crate::daemon_socket::AttachFrontend::Listener,
Some(listener),
Some(expected_owner),
None,
None,
)
.await
}
pub async fn connect_scoped_control_if_published(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
scope: crate::RuntimeFrontendScope,
owner: RuntimeOwnerReceipt,
) -> Result<Option<OwnerClient>> {
scope.validate_bounds()?;
connect_frontend(
config_path,
selected,
crate::daemon_socket::AttachFrontend::Control,
None,
Some(owner),
Some(scope),
None,
)
.await
}
pub async fn connect_selected_acp_if_published(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
scope: crate::RuntimeFrontendScope,
model: String,
owner: RuntimeOwnerReceipt,
) -> Result<Option<OwnerClient>> {
scope.validate_bounds()?;
anyhow::ensure!(
!model.trim().is_empty() && model.len() <= 1024,
"invalid selected ACP model"
);
connect_frontend(
config_path,
selected,
crate::daemon_socket::AttachFrontend::Acp,
None,
Some(owner),
Some(scope),
Some(model),
)
.await
}
async fn connect_frontend(
config_path: Option<PathBuf>,
selected: Option<PathBuf>,
frontend: crate::daemon_socket::AttachFrontend,
listener: Option<crate::RuntimeListenerSelection>,
expected_owner: Option<RuntimeOwnerReceipt>,
scope: Option<crate::RuntimeFrontendScope>,
acp_model: Option<String>,
) -> Result<Option<OwnerClient>> {
let socket_path = owner_work(move || match selected {
Some(path) => Ok(path),
None => default_socket_path().map_err(Into::into),
})
.await?;
#[cfg(unix)]
let directory = socket_path
.parent()
.context("owner endpoint has no private parent")?
.to_path_buf();
#[cfg(windows)]
let directory = owner_work(crate::daemon_windows::owner_directory).await?;
#[cfg(unix)]
let name = format!(
"{}.owner.json",
socket_path
.file_name()
.and_then(|name| name.to_str())
.context("invalid owner endpoint basename")?
);
#[cfg(windows)]
let name = "daemon.owner.json".to_string();
let receipt_name = name.clone();
let found = owner_work(move || {
let parent = match PrivateDirectory::inspect(&directory) {
Ok(parent) => Arc::new(parent),
Err(error)
if error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
{
return Ok(None);
}
Err(error) => return Err(error),
};
let Some((bytes, file)) = parent.read_private_receipt(&name, 16384)? else {
return Ok(None);
};
let receipt: RuntimeOwnerReceipt = serde_json::from_slice(&bytes)?;
Ok(Some((parent, receipt, file)))
})
.await?;
let Some((parent, receipt, file)) = found else {
return Ok(None);
};
anyhow::ensure!(
receipt.version == 1
&& receipt.pid > 0
&& receipt.socket_path == socket_path
&& !receipt.lease_generation.is_empty(),
"selected owner receipt is invalid"
);
anyhow::ensure!(
receipt.config_path == config_path,
"selected owner config does not match; refusing another store"
);
#[cfg(unix)]
let (stream, peer) = {
let stream =
tokio::time::timeout(Duration::from_secs(5), UnixStream::connect(&socket_path))
.await
.context("owner connect deadline expired")??;
let credential = stream
.peer_cred()
.context("owner peer credentials unavailable")?;
let pid = credential
.pid()
.and_then(|pid| u32::try_from(pid).ok())
.context("owner peer PID unavailable")?;
anyhow::ensure!(
credential.uid() == PrivateDirectory::current_user_id()
&& receipt.principal == credential.uid().to_string()
&& pid == receipt.pid,
"connected process is not the selected Runtime owner"
);
(
stream,
Arc::new(AuthorizedPeer {
pid,
start: receipt.process_start.clone(),
}),
)
};
#[cfg(windows)]
let (stream, peer) = {
let (stream, process) = crate::daemon_windows::connect_owner(&receipt).await?;
let peer = AuthorizedPeer {
pid: process.pid(),
start: process.start().to_string(),
process,
};
(stream, Arc::new(peer))
};
let held_parent = parent.clone();
let held_receipt = receipt.clone();
let check = peer.clone();
owner_work(move || {
check.check()?;
anyhow::ensure!(
held_parent.is_at_selected_path()?,
"selected owner parent changed"
);
let (bytes, current) = held_parent
.read_private_receipt(&receipt_name, 16384)?
.context("selected owner receipt withdrawn")?;
anyhow::ensure!(
serde_json::from_slice::<RuntimeOwnerReceipt>(&bytes)? == held_receipt
&& PrivateDirectory::same_file_identity(&file, &current)?,
"selected owner receipt changed before attach"
);
Ok(())
})
.await?;
anyhow::ensure!(
expected_owner
.as_ref()
.is_none_or(|expected| expected == &receipt),
"selected owner changed before frontend admission"
);
let (read, mut write) = tokio::io::split(stream);
let mut replies = BoundedLines::new(BufReader::new(read));
let attach_id = uuid::Uuid::new_v4().to_string();
let attach = json!({"jsonrpc":"2.0","id":attach_id,"method":"daemon/attach","params":{
"client":{"name":"codewhale-stdio","version":env!("CARGO_PKG_VERSION"),"pid":std::process::id()},
"mode":"attach","frontend":frontend,"listener":listener,"scope":scope,"acp_model":acp_model,"expect_daemon_version":env!("CARGO_PKG_VERSION"),"expect_owner":receipt
}});
tokio::time::timeout(
Duration::from_secs(5),
write_stdio_line(&mut write, &attach),
)
.await
.context("owner attach write deadline expired; outcome uncertain")??;
let line = tokio::time::timeout(Duration::from_secs(5), replies.next_line())
.await
.context("owner attach response deadline expired")??
.context("owner closed before attach")?;
anyhow::ensure!(line.len() <= 16384, "oversized owner attach response");
let response: Value = serde_json::from_str(&line)?;
anyhow::ensure!(
response["id"] == attach_id
&& response["error"].is_null()
&& response["result"]["role"] == "attached",
"selected owner refused guest attachment"
);
let returned: RuntimeOwnerReceipt =
serde_json::from_value(response["result"]["owner_receipt"].clone())
.context("owner attach receipt unavailable")?;
anyhow::ensure!(
returned == receipt,
"connected owner did not confirm the exact captured generation/store"
);
let routing = response["result"]
.get("runtime_routing")
.filter(|value| !value.is_null())
.map(|value| serde_json::from_value(value.clone()))
.transpose()?;
Ok(Some(OwnerClient {
read: replies,
write,
peer,
receipt,
routing,
frontend,
}))
}
impl OwnerClient {
pub fn routing(&self) -> Option<&crate::RuntimeOwnerRouting> {
self.routing.as_ref()
}
pub fn receipt(&self) -> &RuntimeOwnerReceipt {
&self.receipt
}
pub async fn send(&mut self, id: Value, method: &str, params: Value) -> Result<()> {
let frame = json!({"jsonrpc":"2.0","id":id,"method":method,"params":params});
let bytes = serde_json::to_vec(&frame)?;
anyhow::ensure!(
bytes.len() <= crate::MAX_RUNTIME_IMAGE_BODY_BYTES,
"owner request exceeds transport bound"
);
let check = self.peer.clone();
owner_work(move || check.check()).await?;
tokio::time::timeout(
Duration::from_secs(5),
write_stdio_line(&mut self.write, &frame),
)
.await
.context("owner write deadline expired; outcome uncertain, not replayed")??;
Ok(())
}
pub async fn recv(&mut self) -> Result<Option<Value>> {
let Some(line) = self.read.next_line().await? else {
return Ok(None);
};
let check = self.peer.clone();
owner_work(move || check.check()).await?;
serde_json::from_str(&line).map(Some).map_err(Into::into)
}
pub async fn forward<I, O>(mut self, input: I, output: O) -> Result<()>
where
I: tokio::io::AsyncRead + Unpin,
O: tokio::io::AsyncWrite + Unpin,
{
let mut input = BoundedLines::new(BufReader::new(input));
let mut output = tokio::io::BufWriter::new(output);
let mut input_open = true;
loop {
tokio::select! {
line = input.next_line(), if input_open => match line? {
None => {
input_open=false;
tokio::time::timeout(Duration::from_secs(5),write_stdio_line(&mut self.write,&json!({"jsonrpc":"2.0","method":"daemon/input_closed","params":{}}))).await.context("owner input-close write outcome uncertain, not replayed")??;
self.write.shutdown().await?;
}
Some(line) if self.frontend==crate::daemon_socket::AttachFrontend::Acp => {
let check=self.peer.clone();
owner_work(move || check.check()).await?;
let value:Value=serde_json::from_str(&line)?;
anyhow::ensure!(value.is_object() && value["jsonrpc"]=="2.0","ACP frame must be a JSON-RPC object");
// Preserve response IDs/permission replies verbatim.
// Actual ACP authority/parser stays in AcpServer.
tokio::time::timeout(Duration::from_secs(5),async {self.write.write_all(line.as_bytes()).await?;self.write.write_all(b"\n").await?;self.write.flush().await}).await.context("ACP write outcome uncertain; not replayed")??;
}
Some(line) => match parse_stdio_line(&line) {
ParsedStdioLine::Blank => {},
ParsedStdioLine::Rejected(response) => write_stdio_line(&mut output, &response).await?,
ParsedStdioLine::Request(request) => {
self.send(request.id.unwrap_or(Value::Null),&request.method,request.params).await?;
}
}
},
line = self.read.next_line() => match line? {
None if !input_open => return Ok(()),
None => bail!("selected owner connection closed; pending operation outcomes may be uncertain"),
Some(line) => {
let _: Value = serde_json::from_str(&line).context("invalid owner response")?;
output.write_all(line.as_bytes()).await?;
output.write_all(b"\n").await?;
output.flush().await?;
}
}
}
}
}
}
pub async fn forward_stdio(config_path: Option<PathBuf>) -> Result<()> {
connect(config_path, None)
.await?
.forward(tokio::io::stdin(), tokio::io::stdout())
.await
}
}
#[cfg(any(unix, windows))]
pub use platform::{
OwnerClient, connect, connect_acp_if_published, connect_if_published,
connect_listener_if_published, connect_scoped_control_if_published,
connect_selected_acp_if_published, forward_stdio,
};
#[cfg(not(any(unix, windows)))]
pub async fn forward_stdio(_config_path: Option<PathBuf>) -> Result<()> {
anyhow::bail!("authenticated live-owner control is not implemented on this platform")
}
File diff suppressed because it is too large. Load diff
+368
View File
@@ -0,0 +1,368 @@
//! Windows kernel pipe frontend for the existing authenticated control loop.
//! Pipe names route; private receipts plus held kernel process/token identity
//! select the actual owner. No bearer or Engine authority crosses this wire.
use crate::daemon_socket::{
AuthorizedPeer, ConnectionTasks, DaemonShutdownHandle, DaemonSocketError, DaemonSocketOptions,
SocketPathInputs, connection_context, owner_work, run_authorized_connection,
};
use crate::{AppState, AppTransport, build_state_off_runtime};
use anyhow::{Context as _, Result};
use codewhale_config::private_directory::PrivateDirectory;
use codewhale_config::windows_identity::{CurrentWindowsUser, OwnerOnlyAcl, WindowsPeerProcess};
use codewhale_protocol::RuntimeOwnerReceipt;
use std::fs::File;
use std::os::windows::io::AsRawHandle as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeServer, ServerOptions};
use tokio::sync::watch;
pub(crate) fn owner_directory() -> Result<PathBuf> {
let input = SocketPathInputs::from_environment(None)?;
let home = input
.codewhale_home_override
.or_else(|| {
input
.user_home
.map(|p| p.join(codewhale_paths::CODEWHALE_APP_DIR))
})
.context("selected Windows owner home unavailable")?;
anyhow::ensure!(
home.is_absolute(),
"selected Windows owner home must be absolute"
);
Ok(home.join("run"))
}
pub(crate) fn selected_pipe_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
if let Some(path) = explicit {
let text = path.to_str().context("invalid Windows pipe name")?;
anyhow::ensure!(
text.starts_with(r"\\.\pipe\")
&& text.len() < 240
&& !text[9..].contains(['\\', '/', '\0']),
"control pipe must be one local named-pipe component"
);
return Ok(path);
}
use sha2::{Digest, Sha256};
let home = owner_directory()?;
let principal = CurrentWindowsUser::open()?.sid_string()?;
let mut digest = Sha256::new();
digest.update(b"Codewhale/owner-pipe/v1\0");
digest.update(home.as_os_str().as_encoded_bytes());
digest.update(b"\0");
digest.update(principal.as_bytes());
let suffix: String = digest
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
Ok(PathBuf::from(format!(r"\\.\pipe\codewhale-owner-{suffix}")))
}
fn create_pipe(path: &Path, first: bool) -> Result<NamedPipeServer> {
use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
let acl = OwnerOnlyAcl::new(FILE_ALL_ACCESS, false)?;
let mut options = ServerOptions::new();
options
.first_pipe_instance(first)
.reject_remote_clients(true)
.max_instances(65)
.in_buffer_size(16384)
.out_buffer_size(16384);
// Security is supplied at creation. There is never a default-DACL window.
acl.with_security_attributes(|attributes| unsafe {
options
.create_with_security_attributes_raw(path, attributes)
.map_err(Into::into)
})
}
struct ReceiptGuard {
parent: Arc<PrivateDirectory>,
file: Option<File>,
}
impl ReceiptGuard {
async fn retire(&mut self) -> Result<()> {
if let Some(file) = self.file.take() {
let parent = self.parent.clone();
tokio::spawn(async move {
owner_work(move || {
parent.retire_private_receipt("daemon.owner.json", &file)?;
Ok(())
})
.await
})
.await??;
}
Ok(())
}
}
impl Drop for ReceiptGuard {
fn drop(&mut self) {
let Some(file) = self.file.take() else { return };
let parent = self.parent.clone();
let retire = move || -> Result<()> {
parent.retire_private_receipt("daemon.owner.json", &file)?;
Ok(())
};
if let Ok(runtime) = tokio::runtime::Handle::try_current() {
runtime.spawn(async move {
if let Err(error) = owner_work(retire).await {
tracing::warn!(%error,"private Windows owner retirement uncertain");
}
});
} else if let Err(error) = retire() {
tracing::warn!(%error,"private Windows owner retirement uncertain");
}
}
}
pub struct DaemonSocket {
pipe: NamedPipeServer,
path: PathBuf,
state: AppState,
shutdown: Arc<watch::Sender<bool>>,
owner: Option<RuntimeOwnerReceipt>,
receipt: Option<ReceiptGuard>,
}
impl DaemonSocket {
pub fn local_path(&self) -> &Path {
&self.path
}
pub fn shutdown_handle(&self) -> DaemonShutdownHandle {
DaemonShutdownHandle(self.shutdown.clone())
}
pub async fn serve(self) -> Result<(), DaemonSocketError> {
let Self {
mut pipe,
path,
state,
shutdown,
owner,
mut receipt,
} = self;
let context = connection_context(
state,
path.clone(),
DaemonShutdownHandle(shutdown.clone()),
owner,
)
.await
.map_err(DaemonSocketError::State)?;
let mut stopping = shutdown.subscribe();
let mut connections = ConnectionTasks::default();
loop {
if *stopping.borrow() {
break;
}
tokio::select! {
result=pipe.connect()=>{
result.map_err(|error|DaemonSocketError::State(error.into()))?;
if connections.at_capacity() {pipe.disconnect().map_err(|error|DaemonSocketError::State(error.into()))?;continue;}
// Retain the connected instance until the successor exists;
// the named endpoint is never released between accepts.
let successor_path=path.clone();
let successor=owner_work(move ||create_pipe(&successor_path,false)).await.map_err(DaemonSocketError::State)?;
let accepted=std::mem::replace(&mut pipe,successor);
let context=context.clone();
connections.spawn(async move {
use windows_sys::Win32::System::Pipes::GetNamedPipeClientProcessId;
let mut pid=0;
if unsafe {GetNamedPipeClientProcessId(accepted.as_raw_handle(),&mut pid)}==0 {return}
let process=match owner_work(move ||WindowsPeerProcess::open_current_user(pid)).await {Ok(process)=>process,Err(_)=>return};
let start=process.start().to_string();let (read,write)=tokio::io::split(accepted);
run_authorized_connection(context,read,write,AuthorizedPeer {pid,start,process}).await;
});
}
changed=stopping.changed()=>{if changed.is_err() || *stopping.borrow() {break}}
}
}
connections.shutdown().await;
drop(pipe);
if let Some(receipt) = receipt.as_mut() {
receipt.retire().await.map_err(DaemonSocketError::State)?;
}
Ok(())
}
}
pub async fn bind_daemon_socket(
options: DaemonSocketOptions,
) -> Result<DaemonSocket, DaemonSocketError> {
bind(options, None).await
}
pub(crate) async fn bind_captured_owner(
state: AppState,
owner: RuntimeOwnerReceipt,
) -> Result<DaemonSocket, DaemonSocketError> {
bind(
DaemonSocketOptions {
socket_path: Some(owner.socket_path.clone()),
config_path: None,
},
Some((state, owner)),
)
.await
}
async fn bind(
options: DaemonSocketOptions,
captured: Option<(AppState, RuntimeOwnerReceipt)>,
) -> Result<DaemonSocket, DaemonSocketError> {
let explicit = options.socket_path;
let path = owner_work(move || selected_pipe_path(explicit))
.await
.map_err(DaemonSocketError::State)?;
let selected = path.clone();
let pipe = owner_work(move || create_pipe(&selected, true))
.await
.map_err(DaemonSocketError::State)?;
let (state, owner) = match captured {
Some((state, owner)) => (state, Some(owner)),
None => (
build_state_off_runtime(options.config_path, None, AppTransport::Socket)
.await
.map_err(DaemonSocketError::State)?,
None,
),
};
let receipt = if let Some(owner) = owner.as_ref() {
let owner = owner.clone();
Some(
owner_work(move || {
let parent = Arc::new(PrivateDirectory::admit(&owner_directory()?)?);
let mut guard = ReceiptGuard {
parent: parent.clone(),
file: None,
};
anyhow::ensure!(
parent.is_at_selected_path()?,
"selected Windows owner parent changed"
);
if let Some((bytes, file)) =
parent.read_private_receipt("daemon.owner.json", 16384)?
{
let previous: RuntimeOwnerReceipt = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
previous.version == owner.version
&& previous.data_dir == owner.data_dir
&& previous.execution_scope == owner.execution_scope
&& previous.socket_path == owner.socket_path,
"old Windows owner receipt belongs to another selected store"
);
anyhow::ensure!(
parent.retire_private_receipt("daemon.owner.json", &file)?,
"Windows owner receipt changed"
);
drop(file);
}
let bytes = serde_json::to_vec(&owner)?;
anyhow::ensure!(bytes.len() <= 16384, "Windows owner receipt exceeds bound");
parent.write_owned_file("daemon.owner.json", &bytes, false)?;
guard.file = Some(
parent
.read_private_receipt("daemon.owner.json", 16384)?
.context("Windows owner publication unavailable")?
.1,
);
anyhow::ensure!(
parent.is_at_selected_path()?,
"selected Windows owner parent changed during publication"
);
Ok(guard)
})
.await
.map_err(DaemonSocketError::State)?,
)
} else {
None
};
let (shutdown, _) = watch::channel(false);
Ok(DaemonSocket {
pipe,
path,
state,
shutdown: Arc::new(shutdown),
owner,
receipt,
})
}
pub(crate) async fn connect_owner(
owner: &RuntimeOwnerReceipt,
) -> Result<(
tokio::net::windows::named_pipe::NamedPipeClient,
WindowsPeerProcess,
)> {
let path = owner.socket_path.clone();
let expected = owner.clone();
owner_work(move || {
let client = ClientOptions::new()
.open(&path)
.context("opening selected Windows owner; refusing fallback")?;
use windows_sys::Win32::System::Pipes::GetNamedPipeServerProcessId;
let mut pid = 0;
anyhow::ensure!(
unsafe { GetNamedPipeServerProcessId(client.as_raw_handle(), &mut pid) } != 0
&& pid == expected.pid,
"connected Windows server is not the selected owner"
);
let process = WindowsPeerProcess::open_current_user(pid)?;
anyhow::ensure!(
process.start() == expected.process_start && process.principal()? == expected.principal,
"selected Windows server generation/principal changed"
);
Ok((client, process))
})
.await
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn first_instance_pipe_refuses_competing_listener_and_reopens_after_close() {
let path = PathBuf::from(format!(
r"\\.\pipe\codewhale-owner-test-{}",
uuid::Uuid::new_v4()
));
let first = create_pipe(&path, true).expect("current-user protected first pipe");
assert!(
create_pipe(&path, true).is_err(),
"never join/substitute another selected owner instance"
);
drop(first);
let reopened =
create_pipe(&path, true).expect("closed instance releases exact kernel endpoint");
drop(reopened);
}
#[test]
fn local_pipe_selector_refuses_remote_and_nested_names() {
for name in [
r"\\remote\pipe\codewhale",
r"\\.\pipe\nested\owner",
r"\\.\pipe\owner/name",
] {
assert!(selected_pipe_path(Some(PathBuf::from(name))).is_err());
}
}
#[test]
fn held_peer_captures_actual_current_user_process_generation() {
assert!(WindowsPeerProcess::open_current_user(0).is_err());
let peer =
WindowsPeerProcess::open_current_user(std::process::id()).expect("held process/token");
assert_eq!(peer.pid(), std::process::id());
assert!(peer.start().starts_with("windows:"));
assert_eq!(
peer.principal().unwrap(),
CurrentWindowsUser::open().unwrap().sid_string().unwrap()
);
peer.check_current_user()
.expect("held generation remains current");
}
}
+1330 -841
View File
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
+479 -1
View File
@@ -32,7 +32,10 @@ fn short_socket_root(label: &str) -> PathBuf {
% 1_000_000;
let nonce = NONCE.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
let root = PathBuf::from("/tmp").join(format!("cw-ds-{label}-{pid}-{nonce}-{millis}"));
let root = PathBuf::from("/tmp")
.canonicalize()
.expect("selected temporary parent")
.join(format!("cw-ds-{label}-{pid}-{nonce}-{millis}"));
assert!(
root.as_os_str().len() < 60,
"socket root too long for a unix socket test: {}",
@@ -361,6 +364,11 @@ async fn version_skew_is_refused_at_attach() {
async fn stale_socket_is_cleaned_up_and_foreign_files_are_refused() {
let harness = Harness::new("stale");
std::fs::create_dir_all(harness.socket_path.parent().expect("parent")).expect("mkdir");
std::fs::set_permissions(
harness.socket_path.parent().unwrap(),
std::fs::Permissions::from_mode(0o700),
)
.expect("private stale fixture parent");
// A socket file whose listener is gone: bind must reclaim it.
{
@@ -399,3 +407,473 @@ async fn stale_socket_is_cleaned_up_and_foreign_files_are_refused() {
b"not a socket"
);
}
#[tokio::test]
async fn binding_refuses_public_parent_and_drop_before_serve_retires_exact_socket() {
let harness = Harness::new("private");
let parent = harness.socket_path.parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(bind_daemon_socket(harness.options()).await.is_err());
assert_eq!(
std::fs::metadata(parent).unwrap().permissions().mode() & 0o777,
0o755
);
assert!(!harness.socket_path.exists());
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).unwrap();
let daemon = bind_daemon_socket(harness.options()).await.unwrap();
assert!(harness.socket_path.exists());
drop(daemon);
tokio::time::timeout(Duration::from_secs(5), async {
while harness.socket_path.exists() {
tokio::task::yield_now().await;
}
})
.await
.expect("cancelled binder retirement finishes off-runtime");
assert!(!harness.socket_path.exists());
}
#[tokio::test]
async fn dropping_bound_daemon_preserves_a_replacement_at_the_selected_path() {
let harness = Harness::new("replaced");
let daemon = bind_daemon_socket(harness.options()).await.unwrap();
let captured = harness.socket_path.with_extension("captured");
std::fs::rename(&harness.socket_path, &captured).unwrap();
std::fs::write(&harness.socket_path, b"operator replacement").unwrap();
drop(daemon);
assert_eq!(
std::fs::read(&harness.socket_path).unwrap(),
b"operator replacement"
);
assert!(captured.exists());
}
#[tokio::test]
async fn captured_owner_frontend_authenticates_generation_without_publishing_bearer() {
let harness = Harness::new("owner");
let owner = codewhale_protocol::RuntimeOwnerReceipt {
version: 1,
data_dir: harness.root.join("runtime"),
execution_scope: "captured-test-store".into(),
lease_generation: "captured-test-generation".into(),
pid: std::process::id(),
process_start: codewhale_app_server::daemon_socket::capture_process_start(
std::process::id(),
)
.await
.unwrap(),
principal: codewhale_config::private_directory::PrivateDirectory::current_user_id()
.to_string(),
socket_path: harness.socket_path.clone(),
config_path: harness.options().config_path,
};
// Transport acceptance: the captured owner is a fixture, not an Engine/store proof.
let daemon = codewhale_app_server::bind_runtime_owner(
owner.config_path.clone(),
"127.0.0.1:1".parse().unwrap(),
Some("private-fixture-bearer".into()),
owner.clone(),
)
.await
.unwrap();
let receipt_path = harness.socket_path.with_file_name("daemon.sock.owner.json");
let bytes = std::fs::read(&receipt_path).unwrap();
assert_eq!(
serde_json::from_slice::<codewhale_protocol::RuntimeOwnerReceipt>(&bytes).unwrap(),
owner
);
assert!(
!String::from_utf8(bytes)
.unwrap()
.contains("private-fixture-bearer")
);
assert_eq!(
std::fs::metadata(&receipt_path)
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
let handle = daemon.shutdown_handle();
let server = tokio::spawn(daemon.serve());
let mut guest = Client::connect(&harness.socket_path).await;
let missing = guest.attach(1, "guest", "attach").await;
assert!(missing["error"].is_object());
let mut stale = owner.clone();
stale.lease_generation = "other-generation".into();
let rejected = guest
.call(
2,
"daemon/attach",
json!({"client":{"name":"guest"},"mode":"attach","expect_owner":stale}),
)
.await;
assert!(rejected["error"].is_object());
let claim = guest
.call(
3,
"daemon/attach",
json!({"client":{"name":"guest"},"mode":"claim","expect_owner":owner}),
)
.await;
assert!(claim["error"].is_object());
let attached = guest
.call(
4,
"daemon/attach",
json!({"client":{"name":"guest","pid":1},"mode":"attach","expect_owner":owner}),
)
.await;
assert_eq!(attached["result"]["role"], "attached");
assert_eq!(attached["result"]["owner_receipt"], json!(owner));
// The display PID is deliberately false: authorization uses kernel peer credentials.
let denied = guest.call(5, "shutdown", json!({})).await;
assert!(denied["error"].is_object());
assert!(guest.call(6, "healthz", json!({})).await["result"].is_object());
handle.trigger();
tokio::time::timeout(Duration::from_secs(5), server)
.await
.unwrap()
.unwrap()
.unwrap();
assert!(!harness.socket_path.exists());
assert!(
!receipt_path.exists(),
"normal shutdown awaits exact receipt retirement"
);
}
/// Native IPC/HTTP transport proof with a fake owned Runtime; no Engine or
/// provider acceptance is inferred from this source fixture.
#[tokio::test]
async fn canonical_cli_control_uses_authenticated_owner_and_same_dispatcher() {
use axum::{Json, Router, routing::get};
let harness = Harness::new("thread-control");
let workspace = harness._config_dir.path().to_path_buf();
let thread = serde_json::json!({"id":"canonical-transport","created_at":"2026-10-02T00:00:00Z",
"updated_at":"2026-10-02T00:00:00Z","model":"fixture-model","model_provider":"custom",
"model_provider_id":"fixture-owner","workspace":workspace,"archived":false});
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = listener.local_addr().unwrap();
let app = Router::new()
.route(
"/v1/threads",
get(move || {
let thread = thread.clone();
async move { Json(serde_json::json!([thread])) }
}),
)
.route(
"/v1/threads/running",
get(|| async { Json(serde_json::json!([])) }),
);
let fake_http = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let owner = codewhale_protocol::RuntimeOwnerReceipt {
version: 1,
data_dir: harness.root.join("runtime"),
execution_scope: "control-fixture".into(),
lease_generation: "control-generation".into(),
pid: std::process::id(),
process_start: codewhale_app_server::daemon_socket::capture_process_start(
std::process::id(),
)
.await
.unwrap(),
principal: codewhale_config::private_directory::PrivateDirectory::current_user_id()
.to_string(),
socket_path: harness.socket_path.clone(),
config_path: harness.options().config_path,
};
let daemon = codewhale_app_server::bind_runtime_owner(
owner.config_path.clone(),
endpoint,
Some("private-fixture-control".into()),
owner.clone(),
)
.await
.unwrap();
let shutdown = daemon.shutdown_handle();
let socket = tokio::spawn(daemon.serve());
let result = codewhale_app_server::request_thread_control(
owner.config_path.clone(),
Some(harness.socket_path.clone()),
None,
codewhale_protocol::ThreadRequest::List(codewhale_protocol::ThreadListParams {
include_archived: false,
limit: None,
}),
)
.await
.unwrap();
assert_eq!(result.threads.len(), 1);
assert_eq!(result.threads[0].id, "canonical-transport");
assert_eq!(result.threads[0].model_provider, "fixture-owner");
assert!(
!serde_json::to_string(&result)
.unwrap()
.contains("private-fixture-control")
);
let error = codewhale_app_server::request_thread_control(
owner.config_path.clone(),
Some(harness.socket_path.clone()),
Some(codewhale_app_server::ThreadControlSelection {
workspace: Some(workspace),
config_profile: None,
config_source: None,
}),
codewhale_protocol::ThreadRequest::List(codewhale_protocol::ThreadListParams {
include_archived: false,
limit: None,
}),
)
.await
.unwrap_err();
assert!(format!("{error:#}").contains("no captured worker setting"));
shutdown.trigger();
tokio::time::timeout(Duration::from_secs(5), socket)
.await
.unwrap()
.unwrap()
.unwrap();
fake_http.abort();
}
/// This only validates the adapter's captured scope transport. Actual Runtime
/// policy/Config admission is covered by the owning Runtime acceptance suite.
struct ScopedControlFixture {
workers: usize,
config: PathBuf,
scopes: std::sync::Arc<tokio::sync::Mutex<Vec<codewhale_app_server::RuntimeFrontendScope>>>,
}
impl codewhale_app_server::RuntimeOwnerFrontend for ScopedControlFixture {
fn validate_selection<'a>(
&'a self,
selection: &'a codewhale_app_server::RuntimeOwnerFrontendSelection,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + 'a>> {
Box::pin(async move {
let codewhale_app_server::RuntimeOwnerFrontendSelection::Control(scope) = selection
else {
anyhow::bail!("fixture expects the existing control frontend");
};
anyhow::ensure!(
scope.workers == self.workers,
"captured scheduler setting changed"
);
anyhow::ensure!(
scope.workspace.is_absolute() && scope.workspace.is_dir(),
"fixture scope is not selected"
);
anyhow::ensure!(
scope
.config_profile
.as_deref()
.is_none_or(|profile| profile == "reviewed"),
"profile is not admitted by held owner"
);
anyhow::ensure!(
scope
.config_source
.as_ref()
.is_none_or(|source| source == &self.config),
"config is not admitted by held owner"
);
self.scopes.lock().await.push(scope.clone());
Ok(())
})
}
fn serve(
&self,
selection: codewhale_app_server::RuntimeOwnerFrontendSelection,
compatibility: codewhale_app_server::AppState,
input: Box<dyn tokio::io::AsyncBufRead + Send + Unpin>,
output: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = anyhow::Result<()>> + Send + '_>> {
Box::pin(async move {
let codewhale_app_server::RuntimeOwnerFrontendSelection::Control(scope) = selection
else {
anyhow::bail!("fixture expects control");
};
codewhale_app_server::run_guest_control(compatibility, scope.workspace, input, output)
.await
})
}
}
#[tokio::test]
async fn canonical_cli_scoped_resume_fork_keep_exact_owner_workers_and_refuse_wrong_selection() {
use axum::{Json, Router, response::IntoResponse as _};
use std::sync::Arc;
let harness = Harness::new("scoped-control");
let workspace = harness._config_dir.path().to_path_buf();
let selected = workspace.join("explicit-target");
std::fs::create_dir(&selected).unwrap();
let owner = codewhale_protocol::RuntimeOwnerReceipt {
version: 1,
data_dir: harness.root.join("runtime"),
execution_scope: "scoped-control-store".into(),
lease_generation: "scoped-control-generation".into(),
pid: std::process::id(),
process_start: codewhale_app_server::daemon_socket::capture_process_start(
std::process::id(),
)
.await
.unwrap(),
principal: codewhale_config::private_directory::PrivateDirectory::current_user_id()
.to_string(),
socket_path: harness.socket_path.clone(),
config_path: harness.options().config_path,
};
let calls = Arc::new(tokio::sync::Mutex::new(Vec::<(String, Value)>::new()));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = listener.local_addr().unwrap();
let captured = owner.clone();
let http_calls = calls.clone();
let target = selected.clone();
let app = Router::new().fallback(move |request: axum::extract::Request| {
let owner = captured.clone(); let calls = http_calls.clone(); let target = target.clone();
async move {
let path = request.uri().path().to_string();
let bytes = axum::body::to_bytes(request.into_body(), 8 * 1024 * 1024).await.unwrap();
let body = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap() };
calls.lock().await.push((path.clone(), body.clone()));
let record = |id: &str, root: &Path| json!({"id":id,"created_at":"2026-10-02T00:00:00Z",
"updated_at":"2026-10-02T00:00:00Z","model":"fixture-model","model_provider":"custom",
"model_provider_id":"fixture-owner","workspace":root,"archived":false});
let value = if path.ends_with("/operations/lookup") { json!({"state":"absent"}) }
else if path.ends_with("/mutate") {
assert_eq!(body["expected_data_dir"], json!(owner.data_dir));
assert_eq!(body["expected_execution_scope"], owner.execution_scope);
assert_eq!(body["workspace"], json!(target));
assert_eq!(body["mutation"]["options"]["overrides"], json!({"model":"explicit-model", "model_provider":"fixture-owner", "approval_policy":"on-request", "sandbox":"workspace-write"}));
let fork = body["mutation"]["action"] == "fork";
json!({"version":1,"data_dir":owner.data_dir,"execution_scope":owner.execution_scope,
"operation_key":body["operation_key"],"request_digest":"1".repeat(64),"history_digest":"2".repeat(64),
"runtime_thread_id":if fork {"canonical-scoped-fork"} else {"canonical-source"},
"session_id":if fork {"session-scoped-fork"} else {"session-source"}})
} else if path.ends_with("/history") {
json!({"version":1,"data_dir":owner.data_dir,"execution_scope":owner.execution_scope,
"runtime_thread_id":"canonical-source","saved_session_id":"session-source",
"saved_document_digest":"a".repeat(64),"session_goal_digest":"74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b",
"document_digest":"b".repeat(64),"session":{"metadata":{"id":"session-source"},"messages":[],"journal":{"entries":[]}}})
} else if path == "/v1/threads/running" { json!([]) }
else if path == "/v1/threads" { json!([record("canonical-source", &target), record("other-workspace", Path::new("/recorded-other-workspace"))]) }
else if path == "/v1/threads/canonical-scoped-fork" { record("canonical-scoped-fork", &target) }
else if path == "/v1/threads/canonical-source" { record("canonical-source", &target) }
else { return (axum::http::StatusCode::NOT_FOUND, Json(json!({"error":"fixture route missing"}))).into_response(); };
Json(value).into_response()
}
});
let fake_http = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let scopes = Arc::new(tokio::sync::Mutex::new(Vec::new()));
let frontend = Arc::new(ScopedControlFixture {
workers: 7,
config: owner.config_path.clone().unwrap(),
scopes: scopes.clone(),
});
let (daemon, _) = codewhale_app_server::bind_runtime_frontends(
owner.config_path.clone(),
Some("fixture-private-scope-token".into()),
owner.clone(),
codewhale_app_server::RuntimeOwnerRouting {
endpoint,
workspace: Some(workspace),
workers: Some(7),
mobile: false,
web: false,
acp: true,
acp_only: false,
},
Some(frontend),
)
.await
.unwrap();
let shutdown = daemon.shutdown_handle();
let socket = tokio::spawn(daemon.serve());
let selection = codewhale_app_server::ThreadControlSelection {
workspace: Some(selected.clone()),
config_profile: Some("reviewed".into()),
config_source: owner.config_path.clone(),
};
for (kind, operation, expected_id) in [
("resume", "scoped-resume", "canonical-source"),
("fork", "scoped-fork", "canonical-scoped-fork"),
] {
let request = serde_json::from_value(
json!({"kind":kind,"thread_id":"canonical-source","operation_key":operation,
"model":"explicit-model", "model_provider":"fixture-owner", "approval_policy":"on-request", "sandbox":"workspace-write"}),
)
.unwrap();
let response = codewhale_app_server::request_thread_control(
owner.config_path.clone(),
Some(harness.socket_path.clone()),
Some(selection.clone()),
request,
)
.await
.unwrap();
assert_eq!(response.data["receipt"]["operation_key"], operation);
assert_eq!(response.data["receipt"]["runtime_thread_id"], expected_id);
assert_eq!(response.thread.unwrap().id, expected_id);
}
let list = codewhale_app_server::request_thread_control(
owner.config_path.clone(),
Some(harness.socket_path.clone()),
Some(selection.clone()),
codewhale_protocol::ThreadRequest::List(codewhale_protocol::ThreadListParams {
include_archived: false,
limit: None,
}),
)
.await
.unwrap();
assert!(
list.threads
.iter()
.any(|thread| thread.id == "other-workspace"),
"scoped execution does not filter owner-store-wide read-only list"
);
assert!(scopes.lock().await.iter().all(|scope| scope.workers == 7
&& scope.workspace == selected
&& scope.config_profile.as_deref() == Some("reviewed")));
let before = calls.lock().await.len();
for wrong in [
codewhale_app_server::ThreadControlSelection {
config_profile: Some("wrong".into()),
..selection.clone()
},
codewhale_app_server::ThreadControlSelection {
config_source: Some(selected.join("other.toml")),
..selection.clone()
},
] {
let request = serde_json::from_value(json!({"kind":"resume","thread_id":"canonical-source","operation_key":"never-dispatched"})).unwrap();
assert!(
codewhale_app_server::request_thread_control(
owner.config_path.clone(),
Some(harness.socket_path.clone()),
Some(wrong),
request
)
.await
.is_err()
);
}
assert_eq!(
calls.lock().await.len(),
before,
"selection refusal happens before HTTP effect dispatch"
);
shutdown.trigger();
tokio::time::timeout(Duration::from_secs(5), socket)
.await
.unwrap()
.unwrap()
.unwrap();
fake_http.abort();
}
+1 -1
View File
@@ -17,7 +17,7 @@ mimalloc-allocator = ["dep:mimalloc"]
# mimalloc v2.4.5 remake — no C compiler, no build script) instead of the
# default mimalloc. Off by default; the default build is unchanged.
# Build with: cargo build -p codewhale-cli --no-default-features --features rusty-alloc
rusty-alloc = ["dep:rusty_alloc-api", "codewhale-tui/rusty-alloc"]
rusty-alloc = ["dep:rusty_alloc-api"]
[[bin]]
name = "codewhale"
+709 -413
View File
File diff suppressed because it is too large. Load diff
+2 -17
View File
@@ -55,22 +55,7 @@ fn main() -> std::process::ExitCode {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
// Single-binary argv0 dispatch: `codew` is now an alias for `codewhale`
// without a second compiled artifact. Checking the binary basename keeps
// the install surface at one file while preserving the six-keystroke save.
let _ = std::env::args().next().and_then(|argv0| {
let base = std::path::Path::new(&argv0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
let trimmed = base
.strip_suffix(std::env::consts::EXE_SUFFIX)
.unwrap_or(base);
if trimmed == "codew" {
// No-op: the single `codewhale` binary handles both names.
}
None::<()>
});
// Published codew/codewhale-tui compatibility names are copies or links
// to this executable and use the same CLI parser and Engine entrypoint.
codewhale_cli::run_cli()
}
+73 -19
View File
@@ -26,6 +26,8 @@ use std::io::Write;
use std::sync::Arc;
use std::time::Duration;
mod compiled_host;
const GITHUB_LATEST_RELEASE_PAGE_URL: &str = "https://github.com/Hmbown/CodeWhale/releases/latest";
const GITHUB_RELEASE_DOWNLOAD_BASE_URL: &str =
"https://github.com/Hmbown/CodeWhale/releases/download";
@@ -151,7 +153,12 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
// Step 2: Prefer GitHub, then a supported mirror if its manifest is
// unavailable. Keep the manifest and binary locked to the same source.
let download = resolve_download_plan(&fetched, &plan.asset_stem, proxy.as_ref())?;
let download = resolve_download_plan(
&fetched,
&plan.asset_stem,
proxy.as_ref(),
compiled_host::required_for(&current_exe)?,
)?;
println!("Release source: {}", download.source.describe());
// Step 3: Download and verify the sole implementation binary once. The
@@ -179,10 +186,38 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
// Step 4: Replace command paths only after the download and the running
// executable identity verify. The preflight happens before a colocated
// compatibility path can change, then the identity is checked just in time.
replace_verified_downloads(&plan.target_paths, &bytes, |target| {
let mut host_update = compiled_host::prepare(
&download,
latest_tag,
plan.asset_stem.trim_start_matches("codewhale-"),
&current_exe,
proxy.as_ref(),
)?;
let replaced = (|| {
validate_primary_update_identity(&executable_identity)?;
validate_update_target(target, &executable_identity)
})?;
if let Some(host) = &mut host_update {
host.publish()?;
}
replace_verified_downloads(&plan.target_paths, &bytes, |target| {
validate_primary_update_identity(&executable_identity)?;
validate_update_target(target, &executable_identity)
})
})();
if let Err(error) = replaced {
if let Some(host) = &mut host_update {
host.rollback()
.context("compiled-host rollback failed; retained backup path is reported below")?;
}
return Err(error);
}
if let Some(host) = &host_update {
println!(
"Updated the qualified compiled image, notices and relink source beside this CLI; Node remains default."
);
for path in host.recovery_paths() {
println!("Previous companion bytes retained at {}", path.display());
}
}
println!(
"\n✅ Successfully updated to {latest_tag}!\n\
@@ -207,21 +242,20 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
/// never a reason to retry against the source that lost the probe: the two
/// build their own artifacts, so their checksums are not interchangeable.
fn verify_downloaded_asset(download: &DownloadPlan, bytes: &[u8]) -> Result<()> {
let expected = download
.checksums
.get(&download.binary_name)
.with_context(|| {
format!(
"{CHECKSUM_MANIFEST_ASSET} from {} is missing {}",
download.source.describe(),
download.binary_name
)
})?;
verify_manifest_asset(download, &download.binary_name, bytes)
}
fn verify_manifest_asset(download: &DownloadPlan, name: &str, bytes: &[u8]) -> Result<()> {
let expected = download.checksums.get(name).with_context(|| {
format!(
"{CHECKSUM_MANIFEST_ASSET} from {} is missing {name}",
download.source.describe()
)
})?;
let actual = sha256_hex(bytes);
if !actual.eq_ignore_ascii_case(expected) {
bail!(
"SHA256 mismatch for {} from {}!\n expected: {expected}\n actual: {actual}",
download.binary_name,
"SHA256 mismatch for {name} from {}!\n expected: {expected}\n actual: {actual}",
download.source.describe()
);
}
@@ -838,6 +872,7 @@ fn resolve_download_plan(
fetched: &FetchedRelease,
asset_stem: &str,
proxy: Option<&Proxy>,
require_compiled_host: bool,
) -> Result<DownloadPlan> {
match proactive_source_candidates(
fetched,
@@ -851,10 +886,29 @@ fn resolve_download_plan(
fetched.release.tag_name,
candidate_labels(&candidates)
);
select_release_source(candidates, manifest_probe_fetcher(proxy))
.with_context(update_network_fallback_hint)
let fetch = manifest_probe_fetcher(proxy);
let qualified: Arc<ManifestFetcher> = Arc::new(move |candidate| {
let bytes = fetch(candidate)?;
if require_compiled_host {
compiled_host::require_catalog_manifest(&bytes)?;
}
Ok(bytes)
});
select_release_source(candidates, qualified).with_context(update_network_fallback_hint)
}
None => {
let plan = single_source_download_plan(fetched, asset_stem, proxy)?;
if require_compiled_host
&& !plan
.checksums
.contains_key("codewhale-extension-hosts.json")
{
bail!(
"selected release source has no qualified compiled-host catalog; no files changed"
);
}
Ok(plan)
}
None => single_source_download_plan(fetched, asset_stem, proxy),
}
}
+623
View File
@@ -0,0 +1,623 @@
//! Optional companion delivery through the existing updater's locked source.
//! This is installation provenance, never a runtime or extension authority.
use super::*;
use codewhale_tui::delivery_files::GuardedFile;
use std::collections::HashSet;
const CATALOG: &str = "codewhale-extension-hosts.json";
const RECEIPT: &str = "codewhale-extension-host.release.json";
#[derive(serde::Deserialize)]
struct Catalog {
schema: u64,
version: String,
source_sha: String,
bundle_sha256: String,
hosts: Vec<Host>,
}
#[derive(serde::Deserialize)]
struct Host {
target: String,
asset: String,
sha256: String,
notices_asset: String,
notices_sha256: String,
source_asset: String,
source_sha256: String,
runtime_version: String,
runtime_revision: String,
runtime_sha256: String,
webkit_revision: String,
bundle_sha256: String,
source_commit: String,
native_platform: String,
native_arch: String,
libc: String,
passed: u64,
failed: u64,
skipped: u64,
test_log_sha256: String,
native_passed: u64,
native_failed: u64,
native_skipped: u64,
native_log_sha256: String,
license_closure: String,
relink_source: String,
}
fn hex(value: &str, length: usize) -> bool {
value.len() == length
&& value
.bytes()
.all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
fn version(value: &str) -> bool {
let (core, suffix) = value
.split_once('-')
.map_or((value, None), |(a, b)| (a, Some(b)));
let parts = core.split('.').collect::<Vec<_>>();
parts.len() == 3
&& parts
.iter()
.all(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
&& suffix.is_none_or(|s| {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
})
}
fn native_target(target: &str) -> Option<(&'static str, &'static str)> {
Some(match target {
"linux-x64" => ("linux", "x64"),
"linux-arm64" => ("linux", "arm64"),
"macos-x64" => ("darwin", "x64"),
"macos-arm64" => ("darwin", "arm64"),
"windows-x64" => ("win32", "x64"),
"windows-arm64" => ("win32", "arm64"),
_ => return None,
})
}
fn parse(bytes: &[u8], expected_version: &str) -> Result<Catalog> {
if bytes.len() > 64 * 1024 {
bail!("compiled host catalog exceeds 64 KiB");
}
let catalog: Catalog =
serde_json::from_slice(bytes).context("malformed compiled host catalog")?;
if catalog.schema != 1
|| !version(&catalog.version)
|| catalog.version != expected_version.trim_start_matches('v')
|| !hex(&catalog.source_sha, 40)
|| !hex(&catalog.bundle_sha256, 64)
|| catalog.hosts.len() > 6
{
bail!("compiled host catalog does not identify this exact release");
}
let mut targets = HashSet::new();
for host in &catalog.hosts {
let (platform, arch) = native_target(&host.target)
.context("unsupported compiled host target; Android is not a qualified Codewhale delivery target")?;
let minimum_native = if platform == "win32" { 8 } else { 1 };
let stem = format!("codewhale-extension-host-{}", host.target);
let binary = format!("{stem}{}", if platform == "win32" { ".exe" } else { "" });
if !targets.insert(&host.target)
|| host.asset != binary
|| host.notices_asset != format!("{stem}-LICENSES.txt")
|| host.source_asset != format!("{stem}-relink-source.tar.gz")
|| [
&host.sha256,
&host.notices_sha256,
&host.source_sha256,
&host.runtime_sha256,
&host.test_log_sha256,
&host.native_log_sha256,
]
.iter()
.any(|digest| !hex(digest, 64))
|| !version(&host.runtime_version)
|| !hex(&host.runtime_revision, 40)
|| !hex(&host.webkit_revision, 40)
|| host.bundle_sha256 != catalog.bundle_sha256
|| host.source_commit != catalog.source_sha
|| host.native_platform != platform
|| host.native_arch != arch
|| !(6..=9_007_199_254_740_991).contains(&host.passed)
|| host.failed != 0
|| host.skipped != 0
|| !(minimum_native..=9_007_199_254_740_991).contains(&host.native_passed)
|| host.native_failed != 0
|| host.native_skipped != 0
|| host.license_closure != "complete"
|| host.relink_source != "complete"
|| if platform == "linux" {
!matches!(host.libc.as_str(), "glibc" | "musl")
} else {
host.libc != "none"
}
{
bail!("compiled host is not a complete matching-native qualified payload");
}
}
Ok(catalog)
}
struct Replacement {
name: String,
staged: GuardedFile,
old: Option<GuardedFile>,
backup_name: String,
retired: bool,
published: bool,
}
pub(super) struct Prepared {
directory: PathBuf,
replacements: Vec<Replacement>,
}
impl Prepared {
fn verify_target(directory: &Path, replacement: &mut Replacement) -> Result<()> {
if let Some(old) = &mut replacement.old {
old.verify()?;
} else if GuardedFile::open(directory, &replacement.name)?.is_some() {
bail!(
"compiled host destination appeared: {}",
directory.join(&replacement.name).display()
);
}
replacement.staged.verify()?;
Ok(())
}
pub(super) fn recovery_paths(&self) -> Vec<PathBuf> {
self.replacements
.iter()
.filter(|replacement| replacement.retired)
.map(|replacement| self.directory.join(&replacement.backup_name))
.collect()
}
pub(super) fn publish(&mut self) -> Result<()> {
for replacement in &mut self.replacements {
Self::verify_target(&self.directory, replacement)?;
}
for replacement in &mut self.replacements {
Self::verify_target(&self.directory, replacement)?;
if let Some(old) = &mut replacement.old {
let result = old.move_to_vacant(&replacement.backup_name);
// A Unix name can change between comparison and rename. Record
// the move even when post-move identity validation rejects it.
replacement.retired = old.name() == replacement.backup_name;
result.with_context(|| {
format!(
"failed to retire {}; recovery at {}",
replacement.name,
self.directory.join(&replacement.backup_name).display()
)
})?;
}
let result = replacement.staged.move_to_vacant(&replacement.name);
replacement.published = replacement.staged.name() == replacement.name;
result.with_context(|| {
format!(
"failed to publish {}; no concurrent destination was overwritten",
replacement.name
)
})?;
}
Ok(())
}
pub(super) fn rollback(&mut self) -> Result<()> {
let mut failures = Vec::new();
for replacement in self.replacements.iter_mut().rev() {
let restored = (|| {
if replacement.published {
// The retained staged handle, not equal bytes, proves this
// entry is the one we published. Preserve changed entries.
replacement.staged.verify()?;
let recovery = GuardedFile::recovery_name();
let result = replacement.staged.move_to_vacant(&recovery);
replacement.published = replacement.staged.name() == replacement.name;
result?;
}
if replacement.retired {
// Restore the entry actually moved, even if a racing Unix
// replacement made retirement refuse its identity. A fresh
// protected handle retains that recovery object's identity.
let original = replacement.old.as_mut().context("retired handle missing")?;
if original.verify().is_ok() {
original.move_to_vacant(&replacement.name)?;
} else {
// On Unix the name moved may be a racing replacement;
// on either platform an observed writer may have changed
// the recovery bytes. Capture that actual recovery entry
// and restore it only if the old destination is vacant.
let mut recovery =
GuardedFile::open(&self.directory, &replacement.backup_name)?
.context("retired entry disappeared")?;
recovery.move_to_vacant(&replacement.name)?;
}
replacement.retired = false;
}
Ok::<_, anyhow::Error>(())
})();
if let Err(error) = restored {
failures.push(format!(
"{}: {} (retired entry {}; staged/published entry {})",
replacement.name,
error,
self.directory.join(&replacement.backup_name).display(),
self.directory.join(replacement.staged.name()).display()
));
}
}
if !failures.is_empty() {
bail!(
"compiled host rollback needs attention; recovery bytes preserved: {}",
failures.join("; ")
);
}
Ok(())
}
}
pub(super) fn required_for(current_exe: &Path) -> Result<bool> {
let directory = current_exe
.parent()
.context("updater executable has no parent")?;
Ok(
std::env::var("CODEWHALE_INSTALL_COMPILED_HOST").as_deref() == Ok("1")
|| GuardedFile::open_bounded(directory, RECEIPT, 64 * 1024)?.is_some(),
)
}
pub(super) fn require_catalog_manifest(bytes: &[u8]) -> Result<()> {
let text = std::str::from_utf8(bytes).context("compiled host manifest is not UTF-8")?;
if !parse_checksum_manifest(text)?.contains_key(CATALOG) {
bail!("release source has no qualified compiled-host catalog");
}
Ok(())
}
pub(super) fn prepare(
download: &DownloadPlan,
release_version: &str,
target: &str,
current_exe: &Path,
proxy: Option<&Proxy>,
) -> Result<Option<Prepared>> {
let directory = current_exe
.parent()
.context("updater executable has no parent")?;
let host_path = directory.join(format!(
"codewhale-extension-host{}",
std::env::consts::EXE_SUFFIX
));
let receipt_path = directory.join(RECEIPT);
let mut receipt_file = GuardedFile::open_bounded(directory, RECEIPT, 64 * 1024)?;
let requested = std::env::var("CODEWHALE_INSTALL_COMPILED_HOST").as_deref() == Ok("1");
if receipt_file.is_none() && !requested {
return Ok(None);
}
let mut ownership = HashMap::new();
if let Some(mut receipt) = receipt_file.take() {
let old = parse(&receipt.read_bounded(64 * 1024)?, env!("CARGO_PKG_VERSION"))?;
let entry = old
.hosts
.iter()
.find(|host| host.target == target)
.context("installed host receipt has no matching target")?;
for (name, expected) in [
(
host_path
.file_name()
.and_then(|name| name.to_str())
.context("invalid host basename")?,
entry.sha256.as_str(),
),
(
"codewhale-extension-host.LICENSES.txt",
entry.notices_sha256.as_str(),
),
(
"codewhale-extension-host.relink-source.tar.gz",
entry.source_sha256.as_str(),
),
] {
let file = GuardedFile::open(directory, name)?
.with_context(|| format!("owned compiled host file missing: {name}"))?;
if file.sha256() != expected {
bail!("compiled host ownership changed: {name}");
}
ownership.insert(directory.join(name), file);
}
ownership.insert(receipt_path.clone(), receipt);
} else if GuardedFile::open(
directory,
host_path
.file_name()
.and_then(|name| name.to_str())
.context("invalid host basename")?,
)?
.is_some()
{
bail!(
"compiled host beside this CLI has no installation receipt; it was not replaced. Install into a fresh prefix or use Node"
);
}
let fetch = |name: &str| -> Result<Vec<u8>> {
let url = reqwest::Url::parse(&download.binary_url)?.join(name)?;
let bytes = download_url(url.as_str(), proxy)?;
verify_manifest_asset(download, name, &bytes)?;
Ok(bytes)
};
if !download.checksums.contains_key(CATALOG) {
bail!(
"compiled host requested/installed but the selected release source has no qualified catalog; no files were changed. Node remains available"
);
}
let catalog_bytes = fetch(CATALOG)?;
let catalog = parse(&catalog_bytes, release_version)?;
let host = catalog
.hosts
.iter()
.find(|host| host.target == target)
.context("release has no qualified compiled image for this target; use Node")?;
let payloads = [
(host.asset.as_str(), host_path, host.sha256.as_str(), true),
(
host.notices_asset.as_str(),
directory.join("codewhale-extension-host.LICENSES.txt"),
host.notices_sha256.as_str(),
false,
),
(
host.source_asset.as_str(),
directory.join("codewhale-extension-host.relink-source.tar.gz"),
host.source_sha256.as_str(),
false,
),
(
CATALOG,
receipt_path,
download
.checksums
.get(CATALOG)
.context("catalog checksum missing")?
.as_str(),
false,
),
];
let mut replacements = Vec::new();
for (name, path, expected, executable) in payloads {
let bytes = if name == CATALOG {
catalog_bytes.clone()
} else {
fetch(name)?
};
if sha256_hex(&bytes) != expected {
bail!("compiled host catalog and manifest disagree for {name}");
}
if executable && cfg!(target_os = "linux") {
let required = highest_required_glibc(&bytes);
let available = detect_host_glibc();
if (host.libc == "glibc" && available.is_none())
|| required
.is_some_and(|required| available.is_none_or(|available| available < required))
|| (host.libc == "musl" && required.is_some())
{
bail!(
"optional compiled Bun image has an incompatible libc floor; the CLI remains static musl. Use Node"
);
}
}
let old = ownership.remove(&path);
let filename = path
.file_name()
.and_then(|name| name.to_str())
.context("invalid compiled payload basename")?
.to_owned();
if old.is_none() && GuardedFile::open(directory, &filename)?.is_some() {
bail!("refusing unowned compiled host file {}", path.display());
}
let staged = GuardedFile::stage(directory, &bytes, executable)?;
if staged.sha256() != expected {
bail!("compiled host stage changed: {}", staged.name());
}
replacements.push(Replacement {
name: filename,
staged,
old,
backup_name: GuardedFile::recovery_name(),
retired: false,
published: false,
});
}
Ok(Some(Prepared {
directory: directory.to_path_buf(),
replacements,
}))
}
#[cfg(test)]
mod tests {
use super::*;
fn receipt() -> serde_json::Value {
serde_json::json!({
"schema":1,"version":"0.10.1","source_sha":"b".repeat(40),"bundle_sha256":"a".repeat(64),
"hosts":[{
"target":"linux-x64","asset":"codewhale-extension-host-linux-x64","sha256":"c".repeat(64),
"notices_asset":"codewhale-extension-host-linux-x64-LICENSES.txt","notices_sha256":"d".repeat(64),
"source_asset":"codewhale-extension-host-linux-x64-relink-source.tar.gz","source_sha256":"e".repeat(64),
"runtime_version":"1.4.0","runtime_revision":"f".repeat(40),"runtime_sha256":"1".repeat(64),"webkit_revision":"2".repeat(40),
"bundle_sha256":"a".repeat(64),"source_commit":"b".repeat(40),"native_platform":"linux","native_arch":"x64","libc":"glibc",
"passed":6,"failed":0,"skipped":0,"test_log_sha256":"3".repeat(64),"native_passed":1,"native_failed":0,"native_skipped":0,"native_log_sha256":"4".repeat(64),"license_closure":"complete","relink_source":"complete"
}]
})
}
#[test]
fn catalog_refuses_cross_native_or_skipped_containment_and_mixed_release() {
let valid = receipt();
assert!(parse(&serde_json::to_vec(&valid).unwrap(), "v0.10.1").is_ok());
for (key, value) in [
("native_skipped", serde_json::json!(1)),
("native_arch", serde_json::json!("arm64")),
("native_passed", serde_json::json!(0)),
("source_asset", serde_json::json!("../source.tar.gz")),
("relink_source", serde_json::json!("pending")),
] {
let mut invalid = valid.clone();
invalid["hosts"][0][key] = value;
assert!(
parse(&serde_json::to_vec(&invalid).unwrap(), "0.10.1").is_err(),
"{key}"
);
}
assert!(parse(&serde_json::to_vec(&valid).unwrap(), "0.10.2").is_err());
}
#[test]
fn catalog_requires_all_eight_current_windows_native_cases() {
let mut value = receipt();
let host = &mut value["hosts"][0];
for (key, field) in [
("target", "windows-x64"),
("asset", "codewhale-extension-host-windows-x64.exe"),
(
"notices_asset",
"codewhale-extension-host-windows-x64-LICENSES.txt",
),
(
"source_asset",
"codewhale-extension-host-windows-x64-relink-source.tar.gz",
),
("native_platform", "win32"),
("libc", "none"),
] {
host[key] = serde_json::json!(field);
}
host["native_passed"] = serde_json::json!(7);
assert!(parse(&serde_json::to_vec(&value).unwrap(), "0.10.1").is_err());
value["hosts"][0]["native_passed"] = serde_json::json!(8);
assert!(parse(&serde_json::to_vec(&value).unwrap(), "0.10.1").is_ok());
}
fn prepared(directory: &Path, name: &str, old: Option<&[u8]>) -> Prepared {
if let Some(bytes) = old {
std::fs::write(directory.join(name), bytes).unwrap();
}
Prepared {
directory: directory.to_path_buf(),
replacements: vec![Replacement {
name: name.into(),
staged: GuardedFile::stage(directory, b"qualified", true).unwrap(),
old: GuardedFile::open(directory, name).unwrap(),
backup_name: GuardedFile::recovery_name(),
retired: false,
published: false,
}],
}
}
#[test]
fn publish_retires_no_companion_if_a_destination_changed() {
let directory = tempfile::tempdir().unwrap();
let mut prepared = prepared(directory.path(), "host", None);
std::fs::write(directory.path().join("host"), b"foreign").unwrap();
assert!(prepared.publish().is_err());
assert_eq!(
std::fs::read(directory.path().join("host")).unwrap(),
b"foreign"
);
}
#[cfg(unix)]
#[test]
fn same_byte_inode_swap_cannot_establish_ownership() {
let directory = tempfile::tempdir().unwrap();
let mut prepared = prepared(directory.path(), "host", Some(b"old"));
std::fs::rename(
directory.path().join("host"),
directory.path().join("original"),
)
.unwrap();
std::fs::write(directory.path().join("host"), b"old").unwrap();
assert!(prepared.publish().is_err());
assert_eq!(
std::fs::read(directory.path().join("host")).unwrap(),
b"old"
);
assert_eq!(
std::fs::read(directory.path().join("original")).unwrap(),
b"old"
);
}
#[cfg(unix)]
#[test]
fn changed_writer_refuses_retirement_and_preserves_original() {
let directory = tempfile::tempdir().unwrap();
let mut prepared = prepared(directory.path(), "host", Some(b"old"));
std::fs::write(directory.path().join("host"), b"concurrent").unwrap();
assert!(prepared.publish().is_err());
assert_eq!(
std::fs::read(directory.path().join("host")).unwrap(),
b"concurrent"
);
}
#[test]
fn successful_publication_and_rollback_preserve_both_versions() {
let directory = tempfile::tempdir().unwrap();
let mut prepared = prepared(directory.path(), "host", Some(b"old"));
prepared.publish().unwrap();
assert_eq!(
std::fs::read(directory.path().join("host")).unwrap(),
b"qualified"
);
prepared.rollback().unwrap();
assert_eq!(
std::fs::read(directory.path().join("host")).unwrap(),
b"old"
);
let recovered = directory
.path()
.join(prepared.replacements[0].staged.name());
assert_eq!(std::fs::read(recovered).unwrap(), b"qualified");
}
#[cfg(unix)]
#[test]
fn partial_rollback_preserves_changed_published_entry_and_recovers_other_slot() {
let directory = tempfile::tempdir().unwrap();
let mut first = prepared(directory.path(), "host", Some(b"old-host"));
let mut second = prepared(directory.path(), "notices", Some(b"old-notices"));
first.replacements.append(&mut second.replacements);
first.publish().unwrap();
let backup = directory.path().join(&first.replacements[0].backup_name);
std::fs::rename(
directory.path().join("host"),
directory.path().join("published-original"),
)
.unwrap();
// Same bytes must still fail identity; a digest-only rollback deletes it.
std::fs::write(directory.path().join("host"), b"qualified").unwrap();
let error = first.rollback().unwrap_err().to_string();
assert!(error.contains("recovery bytes preserved"));
assert_eq!(
std::fs::read(directory.path().join("host")).unwrap(),
b"qualified"
);
assert_eq!(std::fs::read(backup).unwrap(), b"old-host");
assert_eq!(
std::fs::read(directory.path().join("notices")).unwrap(),
b"old-notices"
);
}
}
@@ -106,6 +106,7 @@ pub struct DebugTurnStopDiagnostics {
pub step_budget_source: String,
pub model_step_index: u32,
pub model_requests_started: u32,
pub transport_retries: u32,
pub transparent_stream_retries: u32,
pub stream_resumes: u32,
pub reasoning_only_reprompts: u32,
+3
View File
@@ -31,3 +31,6 @@ tracing.workspace = true
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Threading"] }
[build-dependencies]
serde_json.workspace = true
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
+585
View File
@@ -0,0 +1,585 @@
//! Compile immutable provider metadata from its one committed data owner.
use std::{
collections::{BTreeMap, BTreeSet},
env, fs,
path::PathBuf,
};
use serde_json::Value;
fn text<'a>(row: &'a Value, key: &str) -> &'a str {
row[key]
.as_str()
.unwrap_or_else(|| panic!("missing string {key}"))
}
fn quoted(value: &str) -> String {
format!("{value:?}")
}
fn optional(row: &Value, key: &str) -> String {
match row.get(key) {
Some(Value::String(value)) => format!("Some({})", quoted(value)),
Some(Value::Null) => "None".into(),
_ => panic!("missing or malformed optional string {key}"),
}
}
fn strings(row: &Value, key: &str) -> String {
let values = row[key]
.as_array()
.unwrap_or_else(|| panic!("missing array {key}"));
format!(
"&[{}]",
values
.iter()
.map(|value| quoted(value.as_str().expect("string array")))
.collect::<Vec<_>>()
.join(", ")
)
}
fn credential(row: &Value) -> String {
let acquisition = match text(row, "acquisition") {
"api_key" => "ApiKey",
"api_key_or_oauth" => "ApiKeyOrOAuth",
"local_optional" => "LocalOptional",
"oauth" => "OAuth",
"configuration" => "Configuration",
other => panic!("unknown credential classification {other}"),
};
format!(
"crate::provider::CredentialHelp {{ acquisition: crate::provider::CredentialAcquisition::{acquisition}, credential_url: {}, docs_url: {}, guidance: {} }}",
optional(row, "credential_url"),
optional(row, "docs_url"),
quoted(text(row, "guidance"))
)
}
fn wire(row: &Value) -> &'static str {
match text(row, "wire_policy") {
"chat_completions" => {
"crate::provider::WirePolicy::Fixed(crate::provider::WireFormat::ChatCompletions)"
}
"responses" => "crate::provider::WirePolicy::Fixed(crate::provider::WireFormat::Responses)",
"anthropic_messages" => {
"crate::provider::WirePolicy::Fixed(crate::provider::WireFormat::AnthropicMessages)"
}
"model_aware" => "crate::provider::WirePolicy::ModelAware",
other => panic!("unknown wire classification {other}"),
}
}
fn main() {
println!("cargo:rerun-if-changed=assets/provider_descriptors.json");
let file: Value = serde_json::from_str(
&fs::read_to_string("assets/provider_descriptors.json").expect("provider data"),
)
.expect("provider data JSON");
assert_eq!(
file["schema_version"].as_u64(),
Some(3),
"unsupported provider schema"
);
let rows = file["providers"].as_array().expect("built-in rows");
let mut ids = BTreeSet::new();
let mut kinds = BTreeSet::new();
let mut output = String::from("// Generated from provider_descriptors.json. Do not edit.\n");
let mut selected = Vec::new();
output.push_str("pub(crate) static BUILTIN_DESCRIPTORS: &[BuiltinProviderDescriptor] = &[\n");
for row in rows {
let id = text(row, "id");
let kind = text(row, "kind");
assert!(!id.is_empty() && !kind.is_empty(), "empty identity");
assert!(ids.insert(id), "duplicate provider id {id}");
assert!(kinds.insert(kind), "duplicate provider kind {kind}");
assert!(
kind.chars().all(|c| c.is_ascii_alphanumeric()),
"invalid Rust kind"
);
let selectable = row["selectable"].as_bool().expect("selectable status");
let retired = row["retired"].as_bool().expect("retired status");
assert!(
!retired || !selectable,
"retired provider cannot be selectable"
);
if kind == "Antigravity" {
assert!(retired && !selectable, "Antigravity remains retired");
}
if selectable {
selected.push((
row["selection_order"].as_u64().expect("selection order"),
format!("crate::ProviderKind::{kind}"),
));
} else {
assert!(row["selection_order"].is_null(), "non-selectable order");
}
output.push_str(&format!("BuiltinProviderDescriptor {{ kind: crate::ProviderKind::{kind}, id: {}, label: {}, base_url: {}, default_model: {}, env_vars: {}, aliases: {}, config_key: {}, secret_store_slot: {}, family: {}, selectable: {selectable}, retired: {retired}, wire_policy: {}, credential_help: {} }},\n",
quoted(id),quoted(text(row,"label")),quoted(text(row,"base_url")),quoted(text(row,"default_model")),strings(row,"env_vars"),strings(row,"aliases"),quoted(text(row,"config_key")),quoted(text(row,"secret_store_slot")),quoted(text(row,"family")),wire(row),credential(&row["credential_help"])));
}
output.push_str("];\n");
// Generated exhaustive projection makes a new/missing enum variant a
// compiler error, rather than a delayed registry panic.
output.push_str("pub(crate) const fn builtin_provider_descriptor(kind: crate::ProviderKind) -> &'static BuiltinProviderDescriptor { match kind {\n");
for (index, row) in rows.iter().enumerate() {
output.push_str(&format!(
"crate::ProviderKind::{} => &BUILTIN_DESCRIPTORS[{index}],\n",
text(row, "kind")
));
}
output.push_str("} }\n");
selected.sort_by_key(|(order, _)| *order);
for (index, (order, _)) in selected.iter().enumerate() {
assert_eq!(
*order,
u64::try_from(index).expect("selection index"),
"selection order must be unique and contiguous"
);
}
let selected: Vec<_> = selected.into_iter().map(|(_, kind)| kind).collect();
output.push_str(&format!(
"pub(crate) const SELECTABLE_PROVIDER_KINDS: [crate::ProviderKind; {}] = [{}];\n",
selected.len(),
selected.join(", ")
));
for row in file["descriptors"].as_array().expect("compatible rows") {
assert!(
ids.insert(text(row, "id")),
"descriptor shadows built-in identity"
);
for field in ["id", "label", "base_url", "api_key_env", "default_model"] {
assert!(!text(row, field).is_empty(), "empty compatible-host field");
}
assert!(
matches!(
text(row, "wire"),
"openai-compatible" | "anthropic-messages"
),
"unknown compatible wire"
);
assert!(
matches!(text(row, "discovery"), "models_endpoint" | "none"),
"unknown discovery"
);
if row.get("aliases").is_some() {
let _ = strings(row, "aliases");
}
for field in ["docs_url", "credential_url", "guidance"] {
if row.get(field).is_some() {
let _ = optional(row, field);
}
}
}
let legacy = &file["legacy_tui"];
output.push_str(&format!("/// Exact retained TUI-only compatibility metadata.\npub const LEGACY_DEEPSEEK_CN: LegacyProviderDescriptor = LegacyProviderDescriptor {{ id: {}, label: {}, base_url: {}, default_model: {}, config_key: {}, secret_store_slot: {} }};\n",
quoted(text(legacy,"id")),quoted(text(legacy,"label")),quoted(text(legacy,"base_url")),quoted(text(legacy,"default_model")),quoted(text(legacy,"config_key")),quoted(text(legacy,"secret_store_slot"))));
// Existing descriptor data owns the released presentation tags and typed
// table projection. A missing field or duplicate tag refuses the build.
let mut compatibility: Vec<_> = rows.iter().chain(std::iter::once(legacy)).collect();
compatibility.sort_by_key(|row| row["tui_order"].as_u64().expect("compatibility order"));
let mut tags = BTreeSet::new();
let mut compatibility_ids = BTreeSet::new();
output.push_str("static PROVIDER_COMPATIBILITY: &[ProviderCompatibility] = &[\n");
for (index, row) in compatibility.iter().enumerate() {
assert_eq!(
row["tui_order"].as_u64(),
Some(index as u64),
"compatibility order must be contiguous"
);
let id = text(row, "id");
let tag = text(row, "tui_wire_tag");
assert!(
!tag.is_empty() && tags.insert(tag),
"duplicate/empty compatibility tag"
);
assert!(
compatibility_ids.insert(id),
"duplicate compatibility identity"
);
for field in ["catalog_id", "catalog_source_id"] {
assert!(
rows.iter()
.any(|primary| text(primary, "id") == text(row, field))
|| text(row, field) == id,
"unknown catalog projection"
);
}
let field = text(row, "table_field");
assert!(
field.chars().next().is_some_and(|c| c.is_ascii_lowercase())
&& field
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
"invalid Rust table field"
);
if let Some(key) = row.get("base_url_config_key") {
let key = key.as_str().expect("endpoint table key");
assert!(
rows.iter().any(|row| text(row, "config_key") == key),
"unknown endpoint config table"
);
}
output.push_str(&format!("ProviderCompatibility {{ kind: crate::ProviderKind::{}, id: {}, tui_wire_tag: {}, config_key: {}, base_url_config_key: {}, catalog_id: {}, catalog_source_id: {}, subagent_aliases: {}, selector_aliases: {}, label: {}, base_url: {}, default_model: {} }},\n", text(row,"kind"),quoted(id),quoted(tag),quoted(text(row,"config_key")),quoted(row.get("base_url_config_key").map_or_else(|| text(row,"config_key"), |value| value.as_str().expect("endpoint table key"))),quoted(text(row,"catalog_id")),quoted(text(row,"catalog_source_id")),strings(row,"subagent_aliases"),strings(row,"selector_aliases"),quoted(text(row,"label")),quoted(text(row,"base_url")),quoted(text(row,"default_model"))));
}
output.push_str("];\n");
// Expand against the consumer's released typed configuration struct. The
// field inventory remains generated; custom/legacy provenance is admitted
// by that consumer before this pure projection is called.
output.push_str("#[macro_export]\nmacro_rules! provider_config_table {\n");
for (mode, borrow) in [("read", "&"), ("write", "&mut ")] {
output.push_str(&format!(
"(@{mode} $providers:expr, $id:expr) => {{ match $id {{\n"
));
for row in &compatibility {
if text(row, "kind") != "Custom" {
output.push_str(&format!(
"{} => Some({borrow}$providers.{}),\n",
quoted(text(row, "id")),
text(row, "table_field")
));
}
}
output.push_str("_ => None } };\n");
}
output.push_str("}\n");
let mut public_orders = BTreeSet::new();
for row in rows {
if row["retired"] == false && text(row, "kind") != "Custom" {
let order = row["web"]["order"]
.as_u64()
.expect("public presentation order");
assert!(
public_orders.insert(order),
"duplicate public presentation order"
);
assert!(
row["web"].get("variant").is_none(),
"retired enum presentation identity"
);
}
}
assert!(
public_orders
.iter()
.enumerate()
.all(|(index, order)| *order == index as u64),
"public order must be contiguous"
);
for (key, row) in file["route_credential_help"]
.as_object()
.expect("route credential help")
.iter()
.collect::<BTreeMap<_, _>>()
{
let name = key.replace('-', "_").to_ascii_uppercase();
assert!(
name.chars().all(|c| c.is_ascii_uppercase() || c == '_'),
"invalid route help key"
);
output.push_str(&format!(
"pub(crate) const {name}_CREDENTIAL_HELP: crate::provider::CredentialHelp = {};\n",
credential(row)
));
}
let mut defaults = String::from("// Generated data projections. Do not edit.\n");
let refs = file["constant_refs"]
.as_object()
.expect("constant references");
let constants = file["compatibility_constants"]
.as_object()
.expect("compatibility constants");
for (name, value) in refs.iter().collect::<BTreeMap<_, _>>() {
assert!(!constants.contains_key(name), "duplicate constant");
assert!(
name.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
"invalid constant name"
);
let row = rows
.iter()
.find(|row| row["id"] == value["provider"])
.expect("constant row");
let field = text(value, "field");
assert!(
matches!(field, "base_url" | "default_model" | "credential_url"),
"unknown constant field"
);
let source = if field == "credential_url" {
&row["credential_help"][field]
} else {
&row[field]
};
defaults.push_str(&format!(
"/// Projection of the committed provider descriptor.\npub const {name}: &str = {};\n",
quoted(source.as_str().expect("constant field string"))
));
}
for (name, value) in constants.iter().collect::<BTreeMap<_, _>>() {
assert!(
name.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
"invalid constant name"
);
defaults.push_str(&format!(
"/// Retained data-owned compatibility seed.\npub const {name}: &str = {};\n",
quoted(value.as_str().expect("compatibility string"))
));
}
let directory = PathBuf::from(env::var_os("OUT_DIR").expect("build output"));
fs::write(directory.join("provider_descriptors.rs"), output).expect("write descriptors");
fs::write(directory.join("provider_defaults.rs"), defaults).expect("write defaults");
println!("cargo:rerun-if-changed=assets/catalog_corrections.json");
let catalog: Value = serde_json::from_str(
&fs::read_to_string("assets/catalog_corrections.json").expect("catalog data"),
)
.expect("catalog JSON");
let reviewed = &catalog["reviewed"];
assert!(!text(reviewed, "revision").is_empty(), "reviewed revision");
let mut catalog_constants =
String::from("// Generated from catalog_corrections.json.reviewed. Do not edit.\n");
for (name, value) in reviewed["constants"]
.as_object()
.expect("model constants")
.iter()
.collect::<BTreeMap<_, _>>()
{
assert!(
name.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
"invalid catalog constant"
);
catalog_constants.push_str(&format!(
"pub const {name}: &str = {};\n",
quoted(value.as_str().expect("model constant string"))
));
}
for (name, reference) in reviewed["numeric_refs"]
.as_object()
.expect("numeric contract references")
{
assert!(
name.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
"invalid numeric contract name"
);
let field = text(reference, "field");
assert!(
matches!(
field,
"context_window" | "max_output" | "generation_default"
),
"unknown numeric contract field"
);
let value = reviewed["intrinsic"][text(reference, "model")][field]
.as_u64()
.expect("numeric intrinsic fact");
assert!(
value > 0 && value <= u64::from(u32::MAX),
"numeric intrinsic bound"
);
catalog_constants.push_str(&format!("pub const {name}: u32 = {value};\n"));
}
for (name, value) in reviewed["groups"]
.as_object()
.expect("model rosters")
.iter()
.collect::<BTreeMap<_, _>>()
{
assert!(
name.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_'),
"invalid catalog group"
);
let values = value
.as_array()
.expect("model roster array")
.iter()
.map(|v| quoted(v.as_str().expect("model roster string")))
.collect::<Vec<_>>()
.join(", ");
catalog_constants.push_str(&format!("pub const {name}: &[&str] = &[{values}];\n"));
}
for (name, provider, endpoint) in [
(
"OPENCODE_ZEN_RESPONSES_MODELS",
"opencode-zen",
Some("responses"),
),
(
"OPENCODE_ZEN_MESSAGES_MODELS",
"opencode-zen",
Some("messages"),
),
("OPENCODE_ZEN_CHAT_MODELS", "opencode-zen", Some("chat")),
("OPENCODE_GO_MODELS", "opencode-go", None),
("MODELSTUDIO_TEXT_MODELS", "modelstudio-token-plan", None),
("CODEWHALE_FALLBACK_MODELS", "codewhale", None),
] {
let values = reviewed["transports"]
.as_array()
.expect("transport rows")
.iter()
.filter(|row| {
row["provider"] == provider
&& endpoint.is_none_or(|endpoint| row["endpoint_key"] == endpoint)
})
.map(|row| quoted(text(row, "id")))
.collect::<Vec<_>>()
.join(", ");
catalog_constants.push_str(&format!("pub const {name}: &[&str] = &[{values}];\n"));
}
// Alias/completion entries refer to existing defaults or model groups;
// the source owner never repeats a descriptor-owned default value.
let mut values = BTreeMap::new();
for (name, value) in constants {
values.insert(name.as_str(), value.as_str().expect("compatibility value"));
}
for (name, reference) in refs {
let provider = rows
.iter()
.find(|row| row["id"] == reference["provider"])
.expect("constant provider");
let field = text(reference, "field");
let value = if field == "credential_url" {
&provider["credential_help"][field]
} else {
&provider[field]
};
values.insert(name.as_str(), value.as_str().expect("constant value"));
}
for (name, value) in reviewed["constants"]
.as_object()
.expect("catalog constants")
{
assert!(
values
.insert(name, value.as_str().expect("catalog value"))
.is_none(),
"duplicate data constant"
);
}
let resolve = |value: &str| -> String {
match value.strip_prefix('$') {
Some(name) => values
.get(name)
.unwrap_or_else(|| panic!("missing constant {name}"))
.to_string(),
None => value.to_string(),
}
};
let mut groups: BTreeMap<String, Vec<String>> = reviewed["groups"]
.as_object()
.expect("groups")
.iter()
.map(|(name, value)| {
(
name.clone(),
value
.as_array()
.expect("group")
.iter()
.map(|v| resolve(v.as_str().expect("group entry")))
.collect(),
)
})
.collect();
for (name, provider, endpoint) in [
(
"OPENCODE_ZEN_RESPONSES_MODELS",
"opencode-zen",
Some("responses"),
),
(
"OPENCODE_ZEN_MESSAGES_MODELS",
"opencode-zen",
Some("messages"),
),
("OPENCODE_ZEN_CHAT_MODELS", "opencode-zen", Some("chat")),
("OPENCODE_GO_MODELS", "opencode-go", None),
("MODELSTUDIO_TEXT_MODELS", "modelstudio-token-plan", None),
("CODEWHALE_FALLBACK_MODELS", "codewhale", None),
] {
groups.insert(
name.into(),
reviewed["transports"]
.as_array()
.expect("transports")
.iter()
.filter(|row| {
row["provider"] == provider
&& endpoint.is_none_or(|endpoint| row["endpoint_key"] == endpoint)
})
.map(|row| text(row, "id").to_string())
.collect(),
);
}
catalog_constants.push_str("#[must_use]\npub fn compatibility_alias(group: &str, model: &str) -> Option<&'static str> { match (group, model) {\n");
for (group, aliases) in reviewed["compatibility_aliases"]
.as_object()
.expect("aliases")
{
let mut selectors = BTreeSet::new();
for (alias, target) in aliases.as_object().expect("alias map") {
let alias = resolve(alias);
let target = resolve(target.as_str().expect("alias target"));
assert!(
selectors.insert(alias.clone()),
"duplicate resolved selector {alias}"
);
catalog_constants.push_str(&format!(
"({}, {}) => Some({}),\n",
quoted(group),
quoted(&alias),
quoted(&target)
));
}
}
catalog_constants.push_str("_ => None, } }\n#[must_use]\npub fn completion_names(provider: &str) -> &'static [&'static str] { match provider {\n");
for (provider, roster) in reviewed["completion_rosters"]
.as_object()
.expect("completion rosters")
{
assert!(
rows.iter().any(|row| row["id"] == provider.as_str()) || provider == text(legacy, "id"),
"unknown completion provider"
);
let mut list = Vec::new();
for entry in roster.as_array().expect("completion roster") {
let entry = entry.as_str().expect("completion entry");
if let Some(group) = entry.strip_prefix('@') {
list.extend(
groups
.get(group)
.unwrap_or_else(|| panic!("missing group {group}"))
.iter()
.cloned(),
);
} else {
list.push(resolve(entry));
}
}
// Zen's logical default can be an alias of a documented row. Preserve
// its original stable, case-insensitive first-occurrence projection.
if provider == "opencode-zen" {
let mut seen = BTreeSet::new();
list.retain(|id| seen.insert(id.to_ascii_lowercase()));
}
assert!(
!matches!(provider.as_str(), "antigravity" | "custom") || list.is_empty(),
"retired/custom completion rows"
);
catalog_constants.push_str(&format!(
"{} => &[{}],\n",
quoted(provider),
list.iter()
.map(|v| quoted(v))
.collect::<Vec<_>>()
.join(", ")
));
}
catalog_constants.push_str("_ => &[], } }\n");
fs::write(directory.join("catalog_constants.rs"), catalog_constants)
.expect("write catalog constants");
}
+8 -2
View File
@@ -53,6 +53,7 @@ use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, Wire
pub mod configured;
pub mod corrections;
pub mod reviewed;
/// Provenance of a catalog row. Drives layer precedence and UI provenance.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -284,8 +285,13 @@ static BUNDLED_MODELS_DEV_CATALOG: OnceLock<ModelsDevCatalog> = OnceLock::new();
#[must_use]
pub fn bundled_models_dev_catalog() -> &'static ModelsDevCatalog {
BUNDLED_MODELS_DEV_CATALOG.get_or_init(|| {
ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
.expect("committed bundled Models.dev asset must be valid JSON")
let catalog = ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
.expect("committed bundled Models.dev asset must be valid JSON");
catalog
.reviewed
.validate()
.expect("committed reviewed catalog must be valid");
catalog
})
}
+4
View File
@@ -37,6 +37,9 @@ pub struct CatalogCorrections {
pub about: String,
/// Stamped as [`CatalogSource::CodewhaleBundled`] on prices it owns.
pub revision: String,
/// Pure reviewed model/transport data copied into the deterministic bundled seed.
#[serde(default)]
pub reviewed: super::reviewed::ReviewedCatalog,
/// Rules for every row a provider serves.
#[serde(default)]
pub providers: Vec<ProviderCorrection>,
@@ -81,6 +84,7 @@ impl CatalogCorrections {
if self.revision.trim().is_empty() {
return Err("revision must be set".into());
}
self.reviewed.validate()?;
for rule in &self.providers {
if rule.provider.trim().is_empty() || rule.pricing_withheld.trim().is_empty() {
return Err("provider rules need a provider and a reason".into());
+685
View File
@@ -0,0 +1,685 @@
//! Pure reviewed model facts in the existing catalog owner.
//!
//! The seed renderer copies the committed `catalog_corrections.json.reviewed`
//! data into the bundled Models.dev asset. Live/config/account rows never enter
//! this projection. An intrinsic fact or alias is metadata, not route authority;
//! executable requests still pass through the existing scoped RouteResolver.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::models_dev::{ModelsDevCatalog, ModelsDevModel};
use crate::route::{
CapabilityState, ModelId, ProviderId, ProviderModelOffering, RouteCapabilities, RouteLimits,
WireModelId,
};
/// Coarse model family for presentation only; never selects a provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ModelFamily {
DeepSeek,
Anthropic,
OpenAi,
OpenAiCodex,
Moonshot,
Zai,
Minimax,
Stepfun,
Qwen,
Arcee,
Together,
XiaomiMimo,
Meta,
Xai,
Mistral,
Google,
Other,
}
/// Reviewed intrinsic facts. Missing fields remain unknown. The generation
/// default is separate from the route maximum (notably Kimi K3).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IntrinsicModel {
#[serde(default)]
pub canonical_id: Option<String>,
#[serde(default)]
pub context_window: Option<u32>,
#[serde(default)]
pub max_output: Option<u32>,
#[serde(default)]
pub generation_default: Option<u32>,
#[serde(default)]
pub reasoning: Option<bool>,
#[serde(default)]
pub family: Option<ModelFamily>,
#[serde(default)]
pub display_name: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub modalities: Vec<String>,
#[serde(default)]
pub supported_parameters: Vec<String>,
#[serde(default)]
pub capabilities: RouteCapabilities,
#[serde(default)]
pub architecture: Option<serde_json::Value>,
#[serde(default)]
pub latency: Option<serde_json::Value>,
pub source: String,
}
/// Exact provider-scoped selector aliases. Their order preserves the released
/// first-match contract. These rows do not assert live account availability.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ModelSelection {
pub provider: String,
pub id: String,
pub aliases: Vec<String>,
pub supports_tools: bool,
pub supports_reasoning: bool,
}
/// Pure transport facts required by model-aware providers, without a second
/// routing implementation or pricing authority.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewedTransport {
pub provider: String,
pub id: String,
pub endpoint_key: String,
#[serde(default)]
pub canonical_model: Option<String>,
#[serde(default)]
pub default_for_provider: bool,
#[serde(default)]
pub limits: RouteLimits,
#[serde(default)]
pub capabilities: RouteCapabilities,
pub source: String,
}
impl ReviewedTransport {
#[must_use]
pub fn to_offering(&self) -> ProviderModelOffering {
ProviderModelOffering {
provider: ProviderId::from(self.provider.clone()),
canonical_model: self.canonical_model.clone().map(ModelId::from),
wire_model_id: WireModelId::from(self.id.clone()),
endpoint_key: self.endpoint_key.clone(),
default_for_provider: self.default_for_provider,
limits: self.limits,
capabilities: self.capabilities,
pricing: crate::route::PricingSku::UnknownOrStale,
}
}
}
/// Reviewed reference observations used by metadata-only presentation. They
/// never substitute for scoped live prices or authenticated billing facts.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReferencePrice {
pub input: f64,
pub output: f64,
pub cache_read: f64,
#[serde(default)]
pub cache_write: Option<f64>,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PublicModel {
pub id: String,
pub label: Option<String>,
pub added_at: Option<String>,
pub aliases: Vec<String>,
}
/// A retained compile-time contract name projected from an intrinsic field.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NumericRef {
pub model: String,
pub field: String,
}
/// Supplement to Models.dev's intrinsic/offering schema. The original tables
/// are retired into this one reviewed data owner, not reloaded from a global
/// provider cache. Runtime catalogs retain their existing scope and precedence.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewedCatalog {
#[serde(default)]
pub revision: String,
#[serde(default)]
pub intrinsic: BTreeMap<String, IntrinsicModel>,
#[serde(default)]
pub official_deepseek_aliases: BTreeMap<String, String>,
#[serde(default)]
pub openai_reasoning_ids: BTreeMap<String, String>,
#[serde(default)]
pub snapshot_prefixes: BTreeMap<String, String>,
#[serde(default)]
pub selections: Vec<ModelSelection>,
#[serde(default)]
pub public_models: Vec<PublicModel>,
#[serde(default)]
pub transports: Vec<ReviewedTransport>,
#[serde(default)]
pub constants: BTreeMap<String, String>,
#[serde(default)]
pub numeric_refs: BTreeMap<String, NumericRef>,
#[serde(default)]
pub groups: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub compatibility_aliases: BTreeMap<String, BTreeMap<String, String>>,
#[serde(default)]
pub completion_rosters: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub go_aliases: BTreeMap<String, String>,
#[serde(default)]
pub search_models: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub route_model_sets: BTreeMap<String, Vec<String>>,
#[serde(default)]
pub reference_prices: BTreeMap<String, ReferencePrice>,
#[serde(default)]
pub reference_price_policies: BTreeMap<String, String>,
#[serde(default)]
pub source_receipts: BTreeMap<String, String>,
}
impl ReviewedCatalog {
/// Validate authored source data before it can become a bundled projection.
/// Empty is allowed only for external Models.dev documents with no supplement.
pub fn validate(&self) -> Result<(), String> {
if self.revision.is_empty() {
if self == &Self::default() {
return Ok(());
}
return Err("reviewed catalog revision missing".into());
}
let identifier = |s: &str| {
!s.is_empty() && s.len() <= 512 && s.trim() == s && !s.chars().any(char::is_control)
};
for (id, row) in &self.intrinsic {
if !identifier(id)
|| row.source.trim().is_empty()
|| row.context_window == Some(0)
|| row.max_output == Some(0)
|| row.generation_default == Some(0)
{
return Err(format!("malformed intrinsic model {id}"));
}
}
for row in &self.selections {
// Authored rows retain exact legacy table identities; the picker
// parser deliberately collapses those into vendor-primary names.
let provider = crate::ProviderKind::parse_config_identity(&row.provider);
if !identifier(&row.id)
|| provider.is_none_or(|p| {
p.as_str() != row.provider || p == crate::ProviderKind::Antigravity
})
|| row.aliases.iter().any(|s| !identifier(s))
{
return Err(format!(
"malformed provider selector {}/{}",
row.provider, row.id
));
}
}
let mut routes = BTreeSet::new();
for row in &self.transports {
if !identifier(&row.id)
|| row.source.trim().is_empty()
|| !matches!(row.endpoint_key.as_str(), "chat" | "messages" | "responses")
|| !routes.insert((&row.provider, &row.id))
|| row.limits.context_tokens == Some(0)
|| row.limits.output_tokens == Some(0)
|| crate::ProviderKind::parse_config_identity(&row.provider).is_none_or(|p| {
p.as_str() != row.provider || p == crate::ProviderKind::Antigravity
})
{
return Err(format!("malformed transport {}/{}", row.provider, row.id));
}
}
for (id, row) in &self.reference_prices {
if !identifier(id)
|| row.source.trim().is_empty()
|| [
Some(row.input),
Some(row.output),
Some(row.cache_read),
row.cache_write,
]
.into_iter()
.flatten()
.any(|v| !v.is_finite() || v < 0.0)
{
return Err(format!("malformed reference price {id}"));
}
}
for (alias, target) in self
.official_deepseek_aliases
.iter()
.chain(&self.go_aliases)
{
if !identifier(alias) || !identifier(target) {
return Err("malformed exact alias".into());
}
}
for (prefix, target) in &self.snapshot_prefixes {
if !identifier(prefix) || !self.intrinsic.contains_key(target) {
return Err("missing snapshot target".into());
}
}
for reference in self.numeric_refs.values() {
let row = self
.intrinsic
.get(&reference.model)
.ok_or("missing numeric model")?;
let value = match reference.field.as_str() {
"context_window" => row.context_window,
"max_output" => row.max_output,
"generation_default" => row.generation_default,
_ => return Err("unknown numeric model field".into()),
};
if value.is_none_or(|value| value == 0) {
return Err("missing numeric model fact".into());
}
}
for row in &self.public_models {
if !identifier(&row.id)
|| !self.intrinsic.contains_key(&row.id.to_ascii_lowercase())
|| row.label.as_deref().is_some_and(|label| !identifier(label))
|| row.aliases.iter().any(|alias| !identifier(alias))
|| row.added_at.as_deref().is_some_and(|date| {
chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d").is_err()
})
{
return Err(format!("missing public model {}", row.id));
}
}
for (group, aliases) in &self.compatibility_aliases {
if !identifier(group)
|| aliases
.iter()
.any(|(alias, target)| !identifier(alias) || !identifier(target))
{
return Err("malformed compatibility selector".into());
}
}
for (provider, entries) in &self.completion_rosters {
if provider != crate::descriptors::LEGACY_DEEPSEEK_CN.id
&& crate::ProviderKind::parse_config_identity(provider)
.is_none_or(|kind| kind.as_str() != provider)
|| entries.iter().any(|entry| !identifier(entry))
|| matches!(provider.as_str(), "antigravity" | "custom") && !entries.is_empty()
{
return Err(format!("malformed completion roster {provider}"));
}
}
Ok(())
}
}
/// The same immutable parsed bundled catalog used by config's existing owner.
#[must_use]
pub fn bundled_reviewed() -> &'static ReviewedCatalog {
&super::bundled_models_dev_catalog().reviewed
}
/// Exact intrinsic lookup. Canonical facts win over provider enrichment; a
/// provider-scoped row is eligible only through an explicit, conflict-free join.
/// Never examine the process's live/custom/account catalog here.
#[must_use]
pub fn intrinsic_model(model: &str) -> Option<IntrinsicModel> {
intrinsic_model_in(super::bundled_models_dev_catalog(), model)
}
#[must_use]
pub fn intrinsic_model_in(catalog: &ModelsDevCatalog, model: &str) -> Option<IntrinsicModel> {
let id = model.trim().to_ascii_lowercase();
let reviewed = catalog.reviewed.intrinsic.get(&id);
let canonical = catalog
.models
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(&id))
.map(|(_, row)| row);
let mut result = reviewed.cloned().or_else(|| canonical.map(from_canonical));
if let Some(canonical) = canonical {
let canonical = from_canonical(canonical);
if let Some(row) = &mut result {
fill_missing(row, &canonical);
}
}
// A shared wire spelling does not establish a canonical model. Only
// explicit joins can supply missing intrinsic facts.
let joined = catalog
.providers
.values()
.flat_map(|provider| provider.models.iter())
.filter(|(_, row)| {
row.base_model
.as_deref()
.is_some_and(|base| base.eq_ignore_ascii_case(&id))
})
.collect::<Vec<_>>();
let joins = joined
.iter()
.filter_map(|(_, row)| row.base_model.as_deref())
.map(str::to_ascii_lowercase)
.collect::<BTreeSet<_>>();
if joins.len() == 1 {
// Enrich the named canonical model only. A provider-owned wire id is
// resolved through its scoped offering, never this model-only lookup.
if let Some(base) = joins.first().and_then(|base| {
catalog
.models
.iter()
.find(|(key, _)| key.eq_ignore_ascii_case(base))
.map(|(_, row)| row)
}) {
let canonical = from_canonical(base);
if let Some(row) = &mut result {
fill_missing(row, &canonical);
} else {
result = Some(canonical);
}
}
let consensus = IntrinsicModel {
context_window: consensus(joined.iter().filter_map(|(_, row)| {
row.limit
.as_ref()
.and_then(|v| v.context)
.and_then(|v| u32::try_from(v).ok())
})),
max_output: consensus(joined.iter().filter_map(|(_, row)| {
row.limit
.as_ref()
.and_then(|v| v.output)
.and_then(|v| u32::try_from(v).ok())
})),
reasoning: consensus(joined.iter().filter_map(|(_, row)| row.reasoning)),
source: "conflict-free explicitly joined bundled offerings".into(),
..IntrinsicModel::default()
};
if let Some(row) = &mut result {
fill_missing(row, &consensus);
} else if consensus.context_window.is_some()
|| consensus.max_output.is_some()
|| consensus.reasoning.is_some()
{
result = Some(consensus);
}
}
result
}
fn consensus<T: Copy + PartialEq>(values: impl IntoIterator<Item = T>) -> Option<T> {
let mut values = values.into_iter();
let first = values.next()?;
values.all(|value| value == first).then_some(first)
}
fn from_canonical(row: &ModelsDevModel) -> IntrinsicModel {
IntrinsicModel {
canonical_id: (!row.id.is_empty()).then(|| row.id.clone()),
context_window: row
.limit
.as_ref()
.and_then(|v| v.context)
.and_then(|v| u32::try_from(v).ok()),
max_output: row
.limit
.as_ref()
.and_then(|v| v.output)
.and_then(|v| u32::try_from(v).ok()),
reasoning: row.reasoning,
display_name: row.name.clone(),
modalities: row
.modalities
.as_ref()
.map(|v| v.input.clone())
.unwrap_or_default(),
capabilities: RouteCapabilities {
reasoning: CapabilityState::from_optional_bool(row.reasoning),
native_tool_calls: CapabilityState::from_optional_bool(row.tool_call),
structured_output: CapabilityState::from_optional_bool(row.structured_output),
attachments: CapabilityState::from_optional_bool(row.attachment),
image_input: crate::models_dev::image_input_support(row.modalities.as_ref()),
..RouteCapabilities::default()
},
source: "bundled canonical Models.dev row".into(),
..IntrinsicModel::default()
}
}
fn fill_missing(row: &mut IntrinsicModel, other: &IntrinsicModel) {
row.context_window = row.context_window.or(other.context_window);
row.max_output = row.max_output.or(other.max_output);
row.reasoning = row.reasoning.or(other.reasoning);
row.family = row.family.or(other.family);
if row.display_name.is_none() {
row.display_name.clone_from(&other.display_name);
}
}
/// Exact public bundled identifiers only. Live/private/configured model IDs
/// can never enlarge a telemetry/UI public-label allowlist.
#[must_use]
pub fn public_model_identifier(model: &str) -> bool {
let catalog = super::bundled_models_dev_catalog();
catalog.reviewed.intrinsic.contains_key(model)
|| catalog.models.contains_key(model)
|| catalog.providers.values().any(|provider| {
provider
.models
.iter()
.any(|(id, row)| id == model || row.id == model)
})
}
#[must_use]
pub fn official_deepseek_model_id(model: &str) -> Option<&'static str> {
bundled_reviewed()
.official_deepseek_aliases
.get(&model.trim().to_ascii_lowercase())
.map(String::as_str)
}
/// Exact data-owned compatibility selector. Provider/endpoint policy remains
/// at the caller; this projection cannot choose or authorize a route.
#[must_use]
pub fn compatibility_alias(group: &str, model: &str) -> Option<&'static str> {
constants::compatibility_alias(group, model)
}
#[must_use]
pub fn route_model_set_contains(set: &str, model: &str) -> bool {
bundled_reviewed()
.route_model_sets
.get(set)
.is_some_and(|ids| ids.iter().any(|id| id.eq_ignore_ascii_case(model.trim())))
}
#[must_use]
pub fn reviewed_transport(provider: &str, model: &str) -> Option<&'static ReviewedTransport> {
bundled_reviewed()
.transports
.iter()
.find(|row| row.provider == provider && row.id == model)
}
/// Compile-time compatibility names are generated from the same JSON owner.
pub mod constants {
include!(concat!(env!("OUT_DIR"), "/catalog_constants.rs"));
}
/// Actual source fetch time of the existing generated seed, not an unrelated
/// former bundled asset date. Callers evaluate TTL against their current clock.
#[must_use]
pub fn bundled_source_fetched_at() -> Option<u64> {
let source = super::bundled_models_dev_catalog()
.meta
.get("upstream")?
.as_str()?;
let (_, source) = source.split_once(" fetched ")?;
let time = source.split_whitespace().next()?;
u64::try_from(chrono::DateTime::parse_from_rfc3339(time).ok()?.timestamp()).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn catalog(value: serde_json::Value) -> ModelsDevCatalog {
ModelsDevCatalog::parse_json(&value.to_string()).expect("fixture")
}
#[test]
fn identical_wire_names_do_not_merge_provider_private_facts() {
let data = catalog(serde_json::json!({ "providers": {
"one": { "models": { "private-name": { "id": "private-name", "reasoning": true, "limit": { "context": 900000 } } } },
"two": { "models": { "private-name": { "id": "private-name", "reasoning": false, "limit": { "context": 32000 } } } }
}}));
assert!(intrinsic_model_in(&data, "private-name").is_none());
assert_eq!(
data.provider_offering("one", "private-name")
.unwrap()
.limits
.context_tokens,
Some(900000)
);
assert_eq!(
data.provider_offering("two", "private-name")
.unwrap()
.limits
.context_tokens,
Some(32000)
);
assert!(!public_model_identifier("private-name"));
}
#[test]
fn proven_join_preserves_canonical_facts_and_wire_lookup_stays_scoped() {
let mut fixture = serde_json::json!({
"models": { "canonical": { "id": "canonical", "reasoning": false, "limit": { "context": 128000 } } },
"providers": { "one": { "models": { "wire": { "id": "wire", "base_model": "canonical", "reasoning": true, "limit": { "context": 900000 } } } } }
});
let data = catalog(fixture.clone());
assert!(intrinsic_model_in(&data, "wire").is_none());
assert_eq!(
data.provider_offering("one", "wire")
.expect("exact provider-owned offering")
.limits
.context_tokens,
Some(900000)
);
let joined = intrinsic_model_in(&data, "canonical").unwrap();
assert_eq!(joined.canonical_id.as_deref(), Some("canonical"));
assert_eq!(joined.context_window, Some(128000));
assert_eq!(joined.reasoning, Some(false));
fixture["providers"]["two"] = serde_json::json!({ "models": { "wire": { "id": "wire", "base_model": "different", "reasoning": true } } });
assert!(intrinsic_model_in(&catalog(fixture), "wire").is_none());
}
#[test]
fn reviewed_selection_and_transport_preserve_separate_scopes() {
let reviewed = bundled_reviewed();
reviewed.validate().expect("committed data");
assert_eq!(reviewed.selections.len(), 175);
assert_eq!(reviewed.transports.len(), 123);
let go = reviewed_transport("opencode-go", "gpt-5.6-luna").unwrap();
assert_eq!(go.endpoint_key, "responses");
assert_eq!(
go.limits,
RouteLimits::default(),
"Go metadata is enriched by existing resolver, not borrowed from another account"
);
assert_eq!(
reviewed_transport("opencode-zen", "claude-opus-5")
.unwrap()
.endpoint_key,
"messages"
);
assert!(reviewed_transport("custom", "gpt-5.6").is_none());
assert!(constants::completion_names("custom").is_empty());
assert!(constants::completion_names("antigravity").is_empty());
assert_eq!(
compatibility_alias("canonical_zai_model_id", "glm-5.2"),
Some("GLM-5.2")
);
assert_eq!(
compatibility_alias("canonical_zai_model_id", "glm-5.3"),
Some("GLM-5.3")
);
}
#[test]
fn generation_default_does_not_assert_route_maximum_or_unknown_capability() {
let kimi = intrinsic_model("kimi-k3").unwrap();
assert_eq!(kimi.generation_default, Some(131072));
assert_ne!(kimi.generation_default, Some(1_048_576));
assert_eq!(kimi.capabilities.streaming, CapabilityState::Unknown);
assert!(intrinsic_model("kimi-k3-private").is_none());
}
#[test]
fn exact_legacy_identities_validate_without_accepting_picker_aliases() {
let reviewed = ReviewedCatalog {
revision: "fixture".into(),
selections: vec![ModelSelection {
provider: "minimax-anthropic".into(),
id: "MiniMax-M3".into(),
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
}],
transports: vec![ReviewedTransport {
provider: "modelstudio-coding-plan-anthropic".into(),
id: "qwen".into(),
endpoint_key: "messages".into(),
canonical_model: None,
default_for_provider: false,
limits: RouteLimits::default(),
capabilities: RouteCapabilities::default(),
source: "fixture".into(),
}],
..ReviewedCatalog::default()
};
reviewed
.validate()
.expect("exact released config identities");
for bad in ["mini-max-anthropic", "unknown-provider", "antigravity"] {
let mut malformed = reviewed.clone();
malformed.selections[0].provider = bad.into();
assert!(malformed.validate().is_err(), "selector {bad}");
let mut malformed = reviewed.clone();
malformed.transports[0].provider = bad.into();
assert!(malformed.validate().is_err(), "transport {bad}");
}
}
#[test]
fn malformed_authored_rows_are_refused_before_projection() {
for bad in ["unknown-provider", "antigravity", "openai-compatible"] {
let mut reviewed = bundled_reviewed().clone();
reviewed.selections[0].provider = bad.into();
assert!(reviewed.validate().is_err());
}
let mut reviewed = bundled_reviewed().clone();
reviewed.transports[0].limits.output_tokens = Some(0);
assert!(reviewed.validate().is_err());
reviewed = bundled_reviewed().clone();
reviewed
.completion_rosters
.insert("custom".into(), vec!["private-name".into()]);
assert!(reviewed.validate().is_err());
}
}
+13
View File
@@ -52,6 +52,19 @@ where
mutate_locked(path, |doc, _| mutate(doc))
}
/// The undoable write, also retaining the migration performed under this same
/// lock. The caller can validate a captured route against the freshly moved
/// document before writing its delta; undo still compares the exact written bytes.
pub fn mutate_config_document_undoable_with_migration<T, F>(
path: &Path,
mutate: F,
) -> Result<(T, ConfigDocumentUndo)>
where
F: FnOnce(&mut toml_edit::DocumentMut, &crate::legacy_root::LegacyRootMigration) -> Result<T>,
{
mutate_locked(path, mutate)
}
fn mutate_locked<T, F>(path: &Path, mutate: F) -> Result<(T, ConfigDocumentUndo)>
where
F: FnOnce(&mut toml_edit::DocumentMut, &crate::legacy_root::LegacyRootMigration) -> Result<T>,
+209 -1
View File
@@ -1,6 +1,7 @@
//! OMP-style provider descriptors: how to talk to a host.
//!
//! Model ids are **not** compiled here. A descriptor names the wire, URL, env
//! One committed data file owns built-in/legacy defaults and compatible hosts.
//! Model rosters are **not** compiled here. A descriptor names the wire, URL, env
//! var, and whether authenticated `GET /v1/models` is the catalog authority
//! for that host. Offerings come from the Codewhale catalog layers and live
//! provider `/models` refreshes.
@@ -11,6 +12,131 @@ use serde::Deserialize;
const DESCRIPTORS_JSON: &str = include_str!("../assets/provider_descriptors.json");
/// Immutable built-in view compiled from the same descriptor data file.
#[derive(Debug, Clone, Copy)]
pub(crate) struct BuiltinProviderDescriptor {
pub kind: crate::ProviderKind,
pub id: &'static str,
pub label: &'static str,
pub base_url: &'static str,
pub default_model: &'static str,
pub env_vars: &'static [&'static str],
pub aliases: &'static [&'static str],
pub config_key: &'static str,
pub secret_store_slot: &'static str,
pub family: &'static str,
pub selectable: bool,
pub retired: bool,
pub wire_policy: crate::provider::WirePolicy,
pub credential_help: crate::provider::CredentialHelp,
}
/// Exact compatibility identity for the TUI-only legacy DeepSeek China table.
#[derive(Debug, Clone, Copy)]
pub struct LegacyProviderDescriptor {
/// Canonical historical identity.
pub id: &'static str,
/// Legacy display label.
pub label: &'static str,
/// Historical route seed.
pub base_url: &'static str,
/// Historical model seed.
pub default_model: &'static str,
/// Config table key; never collapsed into an alias during lookup.
pub config_key: &'static str,
/// Existing shared durable credential slot.
pub secret_store_slot: &'static str,
}
/// Pure identity/presentation compatibility from the existing descriptor owner.
/// This is a name projection, never credential or route admission authority.
#[derive(Debug, Clone, Copy)]
pub struct ProviderCompatibility {
pub kind: crate::ProviderKind,
pub id: &'static str,
pub tui_wire_tag: &'static str,
pub config_key: &'static str,
pub base_url_config_key: &'static str,
pub catalog_id: &'static str,
pub catalog_source_id: &'static str,
pub subagent_aliases: &'static [&'static str],
pub selector_aliases: &'static [&'static str],
pub label: &'static str,
pub base_url: &'static str,
pub default_model: &'static str,
}
/// Released presentation rows in the descriptor owner's stable order.
#[must_use]
pub fn provider_compatibility() -> &'static [ProviderCompatibility] {
PROVIDER_COMPATIBILITY
}
/// Exact canonical configured name; custom table names are not classified here.
#[must_use]
pub fn compatibility_for_id(id: &str) -> Option<&'static ProviderCompatibility> {
PROVIDER_COMPATIBILITY.iter().find(|row| row.id == id)
}
/// Intrinsic built-in's canonical metadata. No active config is consulted.
#[must_use]
pub fn compatibility_for_kind(kind: crate::ProviderKind) -> &'static ProviderCompatibility {
PROVIDER_COMPATIBILITY
.iter()
.find(|row| row.id == kind.as_str())
.expect("generated compatibility covers every intrinsic kind")
}
/// Decode a released presentation tag; callers still admit the resulting name.
#[must_use]
pub fn compatibility_from_wire_tag(tag: &str) -> Option<&'static ProviderCompatibility> {
PROVIDER_COMPATIBILITY
.iter()
.find(|row| row.tui_wire_tag == tag)
}
/// Explicit legacy aliases are checked before ordinary kind alias grouping.
#[must_use]
pub fn compatibility_for_selector(value: &str) -> Option<&'static ProviderCompatibility> {
let name = value.trim();
PROVIDER_COMPATIBILITY
.iter()
.find(|row| {
row.selector_aliases
.iter()
.any(|alias| alias.eq_ignore_ascii_case(name))
})
.or_else(|| crate::ProviderKind::parse_config_identity(name).map(compatibility_for_kind))
}
/// Released TUI wire spelling, paired with an exact non-secret route key.
/// This projection does not admit a route or infer a custom table's kind.
#[must_use]
pub fn tui_wire_tag_for_route(kind: crate::ProviderKind, id: &str) -> Option<&'static str> {
if id.trim().is_empty() || id.trim() != id {
return None;
}
if kind == crate::ProviderKind::Custom {
return Some(compatibility_for_kind(kind).tui_wire_tag);
}
let row = compatibility_for_id(id)?;
(row.kind == kind).then_some(row.tui_wire_tag)
}
/// Decode a released tag while retaining its exact identity provenance.
#[must_use]
pub fn kind_from_tui_wire_tag(tag: &str, id: &str) -> Option<crate::ProviderKind> {
let row = compatibility_from_wire_tag(tag)?;
(tui_wire_tag_for_route(row.kind, id) == Some(tag)).then_some(row.kind)
}
/// Compile-time compatibility constants, generated from descriptor data.
pub mod defaults {
include!(concat!(env!("OUT_DIR"), "/provider_defaults.rs"));
}
include!(concat!(env!("OUT_DIR"), "/provider_descriptors.rs"));
/// How this host's model list is discovered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -198,4 +324,86 @@ mod tests {
"do not compile a Groq roster into descriptors"
);
}
#[test]
fn metadata_preserves_distinct_registry_and_selector_orders() {
use crate::ProviderKind;
let registry: Vec<_> = crate::provider::all_providers()
.iter()
.map(|row| row.kind())
.collect();
let index = |rows: &[ProviderKind], kind| rows.iter().position(|row| *row == kind).unwrap();
assert_eq!(registry.len(), 52);
assert_eq!(ProviderKind::ALL.len(), 46);
assert!(
index(&registry, ProviderKind::Modelscope) < index(&registry, ProviderKind::Together)
);
assert!(
index(&ProviderKind::ALL, ProviderKind::ModelstudioTokenPlan)
< index(&ProviderKind::ALL, ProviderKind::Modelscope)
);
assert_eq!(ProviderKind::ALL.last(), Some(&ProviderKind::Custom));
assert_eq!(ProviderKind::parse("agy"), None);
assert_eq!(
ProviderKind::parse_config_identity("agy"),
Some(ProviderKind::Antigravity)
);
assert!(
crate::provider::providers_sorted_for_display()
.iter()
.all(|row| row.kind() != ProviderKind::Antigravity)
);
}
#[test]
fn grouped_secret_slots_do_not_collapse_config_or_wire_identity() {
use crate::ProviderKind;
use crate::provider::{CredentialAcquisition, WireFormat, WirePolicy};
let token = ProviderKind::ModelstudioTokenPlan.provider();
let coding = ProviderKind::ModelstudioCodingPlanAnthropic.provider();
assert_ne!(token.provider_config_key(), coding.provider_config_key());
assert_ne!(token.default_base_url(), coding.default_base_url());
assert_eq!(
ProviderKind::ModelstudioCodingPlanAnthropic.secret_store_slot(),
"modelstudio-token-plan"
);
assert_eq!(
ProviderKind::SiliconflowCN.secret_store_slot(),
"siliconflow"
);
assert_eq!(
ProviderKind::parse_config_identity(coding.id()),
Some(ProviderKind::ModelstudioCodingPlanAnthropic)
);
assert_eq!(
token.wire_policy(),
WirePolicy::Fixed(WireFormat::ChatCompletions)
);
assert_eq!(
coding.wire_policy(),
WirePolicy::Fixed(WireFormat::AnthropicMessages)
);
assert_eq!(
ProviderKind::Codewhale.provider().wire_policy(),
WirePolicy::ModelAware
);
// Preserve the existing public const metadata accessor as well as
// the ordinary trait facade used by runtime consumers.
const CODEX_HELP: crate::provider::CredentialHelp =
crate::provider::credential_help(ProviderKind::OpenaiCodex);
assert_eq!(CODEX_HELP.acquisition, CredentialAcquisition::OAuth);
assert_eq!(
ProviderKind::Sglang
.provider()
.credential_help()
.acquisition,
CredentialAcquisition::LocalOptional
);
assert_eq!(LEGACY_DEEPSEEK_CN.secret_store_slot, "deepseek");
assert_eq!(LEGACY_DEEPSEEK_CN.config_key, "deepseek_cn");
// A named custom table must never obtain built-in authority just by
// reusing its name; compatible-host lookup remains separately scoped.
assert!(provider_descriptor("openai").is_none());
assert!(provider_descriptor("deepseek").is_none());
assert_eq!(bundled_provider_descriptors().len(), 10);
}
}
+5
View File
@@ -320,6 +320,11 @@ pub fn legacy_root_owner(base_url: &str) -> Option<ProviderKind> {
return None;
}
let lower = trimmed.to_ascii_lowercase();
if crate::device_code::url_scheme_and_host(trimmed).is_ok_and(|(scheme, host, credentials)| {
scheme == "https" && host == "chatgpt.com" && !credentials
}) {
return Some(ProviderKind::OpenaiCodex);
}
if lower.contains("integrate.api.nvidia.com") {
return Some(ProviderKind::NvidiaNim);
}
+10 -111
View File
@@ -16,6 +16,7 @@ mod opencode_go;
pub use opencode_go::{opencode_go_endpoint_key, opencode_go_model_id, opencode_go_models};
pub mod persistence;
pub mod pricing;
pub mod private_directory;
pub mod provider;
mod provider_defaults;
mod provider_kind;
@@ -25,16 +26,20 @@ pub mod route;
pub mod settings_schema;
pub mod setup_state;
pub mod user_constitution;
#[cfg(windows)]
pub mod windows_identity;
mod xai_credentials;
pub use config_document::{
ConfigDocumentUndo, create_config_document, migrate_legacy_root_config, mutate_config_document,
mutate_config_document_undoable, mutate_config_document_with_migration,
preview_legacy_root_config, replace_config_document_if_unchanged, set_config_document_value,
unset_config_document_value, with_config_write_lock,
mutate_config_document_undoable, mutate_config_document_undoable_with_migration,
mutate_config_document_with_migration, preview_legacy_root_config,
replace_config_document_if_unchanged, set_config_document_value, unset_config_document_value,
with_config_write_lock,
};
pub use model_reference::{Modality, ModelReferenceCard, ModelReferenceDatabase};
pub(crate) use provider_defaults::*;
pub use provider_kind::ProviderKind;
pub use route::ProviderId;
pub use settings_schema::{
SETTINGS_SCHEMA, SettingDef, SettingKind, SettingOption, SettingUi, schema_groups, schema_rows,
schema_tabs, setting, setting_index,
@@ -4572,117 +4577,11 @@ fn canonical_orcarouter_recent_model_id(model: &str) -> Option<&'static str> {
}
fn default_model_for_provider(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_MODEL,
ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_MODEL,
ProviderKind::Openai => DEFAULT_OPENAI_MODEL,
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_MODEL,
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_MODEL,
ProviderKind::Volcengine => DEFAULT_VOLCENGINE_MODEL,
ProviderKind::Openrouter => DEFAULT_OPENROUTER_MODEL,
ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_MODEL,
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_MODEL,
ProviderKind::Novita => DEFAULT_NOVITA_MODEL,
ProviderKind::Fireworks => DEFAULT_FIREWORKS_MODEL,
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_MODEL,
ProviderKind::Arcee => DEFAULT_ARCEE_MODEL,
ProviderKind::Moonshot => DEFAULT_MOONSHOT_MODEL,
ProviderKind::Sglang => DEFAULT_SGLANG_MODEL,
ProviderKind::Vllm => DEFAULT_VLLM_MODEL,
ProviderKind::Ollama => DEFAULT_OLLAMA_MODEL,
ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_MODEL,
ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_MODEL,
ProviderKind::Modelscope => DEFAULT_MODELSCOPE_MODEL,
ProviderKind::Together => DEFAULT_TOGETHER_MODEL,
ProviderKind::Qianfan => DEFAULT_QIANFAN_MODEL,
ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_MODEL,
ProviderKind::Anthropic => DEFAULT_ANTHROPIC_MODEL,
ProviderKind::Openmodel => DEFAULT_OPENMODEL_MODEL,
ProviderKind::Zai => DEFAULT_ZAI_MODEL,
ProviderKind::Stepfun => DEFAULT_STEPFUN_MODEL,
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_MODEL,
ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_MODEL,
ProviderKind::Sakana => DEFAULT_SAKANA_MODEL,
ProviderKind::LongCat => DEFAULT_LONGCAT_MODEL,
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_MODEL,
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_MODEL,
ProviderKind::Meta => DEFAULT_META_MODEL,
ProviderKind::Xai => DEFAULT_XAI_MODEL,
ProviderKind::Mistral => DEFAULT_MISTRAL_MODEL,
ProviderKind::Google => DEFAULT_GOOGLE_MODEL,
ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_MODEL,
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_MODEL,
ProviderKind::Edenai => DEFAULT_EDENAI_MODEL,
ProviderKind::Zenmux => DEFAULT_ZENMUX_MODEL,
ProviderKind::Csdn => DEFAULT_CSDN_MODEL,
ProviderKind::Concentrate => DEFAULT_CONCENTRATE_MODEL,
ProviderKind::Codewhale => DEFAULT_CODEWHALE_MODEL,
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic => DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
// No built-in default model; the registry placeholder keeps this total.
ProviderKind::Custom => provider.provider().default_model(),
}
provider.provider().default_model()
}
fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
match provider {
ProviderKind::Deepseek => DEFAULT_DEEPSEEK_BASE_URL,
ProviderKind::DeepseekAnthropic => DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
ProviderKind::NvidiaNim => DEFAULT_NVIDIA_NIM_BASE_URL,
ProviderKind::Openai => DEFAULT_OPENAI_BASE_URL,
ProviderKind::Atlascloud => DEFAULT_ATLASCLOUD_BASE_URL,
ProviderKind::WanjieArk => DEFAULT_WANJIE_ARK_BASE_URL,
ProviderKind::Volcengine => DEFAULT_VOLCENGINE_BASE_URL,
ProviderKind::Openrouter => DEFAULT_OPENROUTER_BASE_URL,
ProviderKind::Orcarouter => DEFAULT_ORCAROUTER_BASE_URL,
ProviderKind::XiaomiMimo => DEFAULT_XIAOMI_MIMO_BASE_URL,
ProviderKind::Novita => DEFAULT_NOVITA_BASE_URL,
ProviderKind::Fireworks => DEFAULT_FIREWORKS_BASE_URL,
ProviderKind::Siliconflow => DEFAULT_SILICONFLOW_BASE_URL,
ProviderKind::SiliconflowCN => DEFAULT_SILICONFLOW_CN_BASE_URL,
ProviderKind::Arcee => DEFAULT_ARCEE_BASE_URL,
ProviderKind::Moonshot => DEFAULT_MOONSHOT_BASE_URL,
ProviderKind::Sglang => DEFAULT_SGLANG_BASE_URL,
ProviderKind::Vllm => DEFAULT_VLLM_BASE_URL,
ProviderKind::Ollama => DEFAULT_OLLAMA_BASE_URL,
ProviderKind::OllamaCloud => DEFAULT_OLLAMA_CLOUD_BASE_URL,
ProviderKind::Huggingface => DEFAULT_HUGGINGFACE_BASE_URL,
ProviderKind::Modelscope => DEFAULT_MODELSCOPE_BASE_URL,
ProviderKind::Together => DEFAULT_TOGETHER_BASE_URL,
ProviderKind::Qianfan => DEFAULT_QIANFAN_BASE_URL,
ProviderKind::OpenaiCodex => DEFAULT_OPENAI_CODEX_BASE_URL,
ProviderKind::Anthropic => DEFAULT_ANTHROPIC_BASE_URL,
ProviderKind::Openmodel => DEFAULT_OPENMODEL_BASE_URL,
ProviderKind::Zai => DEFAULT_ZAI_BASE_URL,
ProviderKind::Stepfun => DEFAULT_STEPFUN_BASE_URL,
ProviderKind::Minimax => DEFAULT_MINIMAX_BASE_URL,
ProviderKind::MinimaxAnthropic => DEFAULT_MINIMAX_ANTHROPIC_BASE_URL,
ProviderKind::Deepinfra => DEFAULT_DEEPINFRA_BASE_URL,
ProviderKind::Sakana => DEFAULT_SAKANA_BASE_URL,
ProviderKind::LongCat => DEFAULT_LONGCAT_BASE_URL,
ProviderKind::OpencodeGo => DEFAULT_OPENCODE_GO_BASE_URL,
ProviderKind::OpencodeZen => DEFAULT_OPENCODE_ZEN_BASE_URL,
ProviderKind::Meta => DEFAULT_META_BASE_URL,
ProviderKind::Xai => DEFAULT_XAI_BASE_URL,
ProviderKind::Mistral => DEFAULT_MISTRAL_BASE_URL,
ProviderKind::Google => DEFAULT_GOOGLE_BASE_URL,
ProviderKind::Antigravity => DEFAULT_ANTIGRAVITY_BASE_URL,
ProviderKind::Telecomjs => DEFAULT_TELECOMJS_BASE_URL,
ProviderKind::Edenai => DEFAULT_EDENAI_BASE_URL,
ProviderKind::Zenmux => DEFAULT_ZENMUX_BASE_URL,
ProviderKind::Csdn => DEFAULT_CSDN_BASE_URL,
ProviderKind::Concentrate => DEFAULT_CONCENTRATE_BASE_URL,
ProviderKind::Codewhale => DEFAULT_CODEWHALE_BASE_URL,
ProviderKind::ModelstudioTokenPlan => DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL,
ProviderKind::ModelstudioTokenPlanAnthropic => MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
ProviderKind::ModelstudioCodingPlan => DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
ProviderKind::ModelstudioCodingPlanAnthropic => MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL,
// No built-in default base URL; the registry placeholder keeps this total.
ProviderKind::Custom => provider.provider().default_base_url(),
}
provider.provider().default_base_url()
}
fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
+13 -25
View File
@@ -32,6 +32,11 @@ pub const MODELS_DEV_CATALOG_URL: &str = "https://models.dev/catalog.json";
/// Combined Models.dev catalog payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevCatalog {
/// Generated bundled presentation/transport facts; external documents cannot affect the compiled owner.
#[serde(default, rename = "_reviewed")]
pub reviewed: crate::catalog::reviewed::ReviewedCatalog,
#[serde(default, rename = "_meta")]
pub meta: BTreeMap<String, serde_json::Value>,
/// Provider-agnostic model facts, keyed by canonical model id.
#[serde(default)]
pub models: BTreeMap<String, ModelsDevModel>,
@@ -71,30 +76,13 @@ impl ModelsDevCatalog {
self.provider(provider_id)?.models.get(wire_model_id.trim())
}
/// Resolve the sourced `reasoning` fact for a model id, wherever the
/// catalog carries the row: the provider-agnostic `models` map, or any
/// provider's scoped rows (OpenRouter-style rows are keyed by the full
/// `vendor/model` id, so compound ids match verbatim). The id must match
/// exactly — no provider aliasing, no prefix inference (#6032).
///
/// Returns `None` when no row for the id states the fact, or when rows
/// disagree across providers — a conflict stays unknown rather than
/// guessed.
/// Resolve an intrinsic reasoning fact from canonical data or a
/// conflict-free explicit `base_model` join. A provider wire spelling
/// without that join never becomes an unscoped capability (#6032).
/// Missing and conflicting facts remain unknown.
#[must_use]
pub fn reasoning_support(&self, model_id: &str) -> Option<bool> {
let key = model_id.trim();
let mut sourced = self.models.get(key).and_then(|model| model.reasoning);
for provider in self.providers.values() {
let Some(reasoning) = provider.models.get(key).and_then(|row| row.reasoning) else {
continue;
};
match sourced {
None => sourced = Some(reasoning),
Some(known) if known == reasoning => {}
Some(_) => return None,
}
}
sourced
crate::catalog::reviewed::intrinsic_model_in(self, model_id).and_then(|row| row.reasoning)
}
/// Build a route offering from a provider-scoped Models.dev row.
@@ -672,11 +660,11 @@ mod tests {
}
#[test]
fn reasoning_support_reads_top_level_and_provider_rows_exactly() {
fn reasoning_support_requires_canonical_rows_or_explicit_joins() {
let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
// Compound canonical id (top-level map) and bare provider ids.
// The canonical row is intrinsic; the bare wire row has no proven join.
assert_eq!(catalog.reasoning_support("zhipuai/glm-5.2"), Some(true));
assert_eq!(catalog.reasoning_support("glm-5.2"), Some(true));
assert_eq!(catalog.reasoning_support("glm-5.2"), None);
// Unknown id stays unknown; no prefix or alias inference.
assert_eq!(catalog.reasoning_support("glm-5.1"), None);
assert_eq!(catalog.reasoning_support("zai/glm-5.2"), None);
+11 -70
View File
@@ -4,61 +4,10 @@
//! Previously accepted Chat ids remain compatible absent explicit deprecation.
//! Membership proves a wire protocol, not live availability, limits or pricing.
pub(crate) const MODEL_GROUPS: &[(&str, &[&str])] = &[
(
"chat",
&[
crate::DEFAULT_OPENCODE_GO_MODEL,
crate::OPENCODE_GO_GROK_4_5_MODEL,
crate::OPENCODE_GO_GLM_5_2_MODEL,
crate::OPENCODE_GO_GLM_5_1_MODEL,
crate::OPENCODE_GO_KIMI_K3_MODEL,
crate::OPENCODE_GO_KIMI_K2_7_CODE_MODEL,
crate::OPENCODE_GO_KIMI_K2_6_MODEL,
crate::OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL,
crate::OPENCODE_GO_MIMO_V2_5_MODEL,
crate::OPENCODE_GO_MIMO_V2_5_PRO_MODEL,
"glm-5.3-flash",
"glm-5.3",
"longcat-2.0",
"deepseek-v4-flash-vision-exp",
"hy4-preview",
"hy3",
"omen-alpha",
"deepseek-v4.1-flash",
],
),
(
"responses",
&[
"grok-4.6",
"gpt-5.6-luna",
"muse-spark-1.3-contributor",
"muse-spark-1.2-contributor",
],
),
(
"messages",
&[
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
"qwen3.8-max",
"qwen3.8-flash",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
],
),
];
/// Every documented Go wire id, in stable picker order.
#[must_use]
pub fn opencode_go_models() -> Vec<&'static str> {
MODEL_GROUPS
.iter()
.flat_map(|(_, models)| models.iter().copied())
.collect()
crate::catalog::reviewed::constants::OPENCODE_GO_MODELS.to_vec()
}
/// Canonicalize only IDs whose Go protocol is known. Unknown models cannot
@@ -69,29 +18,21 @@ pub fn opencode_go_model_id(model: &str) -> Option<&'static str> {
let normalized = normalized
.strip_prefix("opencode-go/")
.unwrap_or(&normalized);
let normalized = match normalized {
"grok-4-5" => "grok-4.5",
"glm-5-2" => "glm-5.2",
"glm-5-1" => "glm-5.1",
"kimi-k2-7-code" => "kimi-k2.7-code",
"kimi-k2-6" => "kimi-k2.6",
"deepseek-v4pro" => "deepseek-v4-pro",
"deepseek-v4flash" => "deepseek-v4-flash",
"mimo-v2-5" => "mimo-v2.5",
"mimo-v2-5-pro" => "mimo-v2.5-pro",
other => other,
};
MODEL_GROUPS
let reviewed = crate::catalog::reviewed::bundled_reviewed();
let normalized = reviewed
.go_aliases
.get(normalized)
.map_or(normalized, String::as_str);
crate::catalog::reviewed::constants::OPENCODE_GO_MODELS
.iter()
.flat_map(|(_, models)| models.iter().copied())
.find(|candidate| *candidate == normalized)
.copied()
.find(|id| *id == normalized)
}
/// The documented endpoint for a Go model, independent of stale catalog metadata.
#[must_use]
pub fn opencode_go_endpoint_key(model: &str) -> Option<&'static str> {
let canonical = opencode_go_model_id(model)?;
MODEL_GROUPS
.iter()
.find_map(|(endpoint, models)| models.contains(&canonical).then_some(*endpoint))
crate::catalog::reviewed::reviewed_transport("opencode-go", canonical)
.map(|row| row.endpoint_key.as_str())
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+8 -241
View File
@@ -1,249 +1,16 @@
//! Built-in provider default seeds: per-provider default model ids and
//! base URLs, plus the named model/tier constants the alias-normalization
//! tables resolve to. Extracted verbatim from `lib.rs` (#3311) to separate
//! these provider execution defaults from config schema/loading code; values
//! are unchanged. Re-exported `pub(crate)` at the crate root so existing
//! `crate::DEFAULT_*` references keep resolving.
//! Named model aliases and curated rosters.
//! Provider default seeds are generated from the shared descriptor owner;
//! model aliases and completion rosters are generated from the same reviewed catalog.
pub(crate) use crate::catalog::reviewed::constants::*;
pub(crate) use crate::descriptors::defaults::*;
// V4.1 Flash remains the selected default. The September 11 vendor reversal
// keeps V4 Pro available with unchanged billing; explicit Pro selections stay Pro.
pub(crate) const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-flash";
pub(crate) const DEFAULT_DEEPSEEK_ANTHROPIC_MODEL: &str = DEFAULT_DEEPSEEK_MODEL;
pub(crate) const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
pub(crate) const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
// A DeepSeek id here guaranteed a 404 against the default OpenAI endpoint:
// unlike the hosted-aggregator rows below, api.openai.com serves no DeepSeek
// models. Default to OpenAI's own flagship instead (#5588).
pub(crate) const DEFAULT_OPENAI_MODEL: &str = "gpt-5.6";
pub(crate) const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
pub(crate) const DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL: &str = "https://api.deepseek.com/anthropic";
pub(crate) const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
pub(crate) const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.6";
pub(crate) const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
pub(crate) const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
pub(crate) const DEFAULT_OPENMODEL_MODEL: &str = "deepseek-v4-flash";
pub(crate) const DEFAULT_OPENMODEL_BASE_URL: &str = "https://api.openmodel.ai";
pub(crate) const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://api.openai.com/v1";
pub(crate) const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
pub(crate) const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
pub(crate) const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
pub(crate) const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
pub(crate) const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
pub(crate) const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
pub(crate) const DEFAULT_VOLCENGINE_BASE_URL: &str =
"https://ark.cn-beijing.volces.com/api/coding/v3";
pub(crate) const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
pub(crate) const DEFAULT_ORCAROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_ORCAROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
/// OrcaRouter's own auto-routing model: picks the best upstream model per
/// request. Resolved from the bare `auto` alias on the OrcaRouter provider.
pub(crate) const ORCAROUTER_AUTO_MODEL: &str = "orcarouter/auto";
pub(crate) const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str =
"arcee-ai/trinity-large-thinking";
pub(crate) const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
pub(crate) const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
pub(crate) const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
pub(crate) const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
// GLM-5.3 is live on the Z.ai Coding Plan (2026-08-13). Capability/limit
// metadata still inherits from glm-5.2 until Z.ai publishes distinct 5.3
// numbers. No USD price. The OpenRouter id is registered so the alias
// resolves to OpenRouter rather than another vendor. See
// models_dev.bundled.json `_meta.pending_release_metadata`.
pub(crate) const OPENROUTER_GLM_5_3_MODEL: &str = "z-ai/glm-5.3";
// GLM-5.3-Flash (2026-08-26): first natively multimodal GLM-5, 1M context,
// published USD list $0.15/$0.50 (50% promo until 2026-09-09 UTC+8 is not
// the durable row). OpenRouter mirror is z-ai/glm-5.3-flash.
pub(crate) const OPENROUTER_GLM_5_3_FLASH_MODEL: &str = "z-ai/glm-5.3-flash";
pub(crate) const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
pub(crate) const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
pub(crate) const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
pub(crate) const OPENROUTER_MINIMAX_M2_7_MODEL: &str = "minimax/minimax-m2.7";
pub(crate) const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
pub(crate) const OPENROUTER_QWEN_3_6_FLASH_MODEL: &str = "qwen/qwen3.6-flash";
pub(crate) const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
pub(crate) const OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL: &str = "qwen/qwen3.6-max-preview";
pub(crate) const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
pub(crate) const OPENROUTER_QWEN_3_6_PLUS_MODEL: &str = "qwen/qwen3.6-plus";
pub(crate) const OPENROUTER_QWEN_3_7_PLUS_MODEL: &str = "qwen/qwen3.7-plus";
pub(crate) const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max";
pub(crate) const OPENROUTER_QWEN_3_8_FLASH_MODEL: &str = "qwen/qwen3.8-flash";
pub(crate) const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
pub(crate) const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
pub(crate) const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
pub(crate) const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
pub(crate) const XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL: &str = "mimo-v2.5-pro-ultraspeed";
pub(crate) const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5";
pub(crate) const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr";
pub(crate) const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts";
pub(crate) const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign";
pub(crate) const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone";
pub(crate) const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts";
pub(crate) const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
pub(crate) const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
pub(crate) const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_ARCEE_MODEL: &str = "trinity-large-thinking";
pub(crate) const ARCEE_TRINITY_LARGE_PREVIEW_MODEL: &str = "trinity-large-preview";
pub(crate) const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini";
pub(crate) const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code";
pub(crate) const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
pub(crate) const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
pub(crate) const MOONSHOT_CN_BASE_URL: &str = "https://api.moonshot.cn/v1";
pub(crate) const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
pub(crate) const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
pub(crate) const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
pub(crate) const DEFAULT_ORCAROUTER_BASE_URL: &str = "https://api.orcarouter.ai/v1";
pub(crate) const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
pub(crate) const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
pub(crate) const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str =
"https://token-plan-cn.xiaomimimo.com/v1";
pub(crate) const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL;
pub(crate) const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str =
"https://token-plan-ams.xiaomimimo.com/v1";
pub(crate) const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1";
pub(crate) const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
pub(crate) const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
pub(crate) const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1";
pub(crate) const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1";
pub(crate) const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1";
pub(crate) const DEFAULT_MODELSCOPE_MODEL: &str = "Qwen/Qwen3.5-397B-A17B";
pub(crate) const DEFAULT_MODELSCOPE_BASE_URL: &str = "https://api-inference.modelscope.cn/v1";
pub(crate) const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_TOGETHER_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
pub(crate) const DEFAULT_QIANFAN_MODEL: &str = "ernie-4.0-turbo-8k";
pub(crate) const DEFAULT_QIANFAN_BASE_URL: &str = "https://api.baiduqianfan.ai/v1";
pub(crate) const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
pub(crate) const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
/// Unresolved local-Ollama default. A live `GET /v1/models` (Ollama's
/// OpenAI-compat catalog, same tags as `/api/tags`) must supply the real id;
/// this marker must never be sent as a model name.
pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "unknown";
pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
pub(crate) const DEFAULT_OLLAMA_CLOUD_MODEL: &str = "gpt-oss:120b";
pub(crate) const DEFAULT_OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com/v1";
// Z.ai (GLM Coding Plan) defaults. GLM-5.3 is live on the Z.ai Coding Plan
// (2026-08-13) and is the default for new Z.ai routes. Capability/limit
// metadata still inherits from glm-5.2 until Z.ai publishes distinct 5.3
// numbers; no USD price is claimed. See models_dev.bundled.json
// `_meta.pending_release_metadata`. Explicit GLM-5.2 selections keep their
// own id: only the default moved.
pub(crate) const DEFAULT_ZAI_MODEL: &str = ZAI_GLM_5_3_MODEL;
pub(crate) const ZAI_GLM_5_3_MODEL: &str = "GLM-5.3";
pub(crate) const ZAI_GLM_5_3_FLASH_MODEL: &str = "GLM-5.3-Flash";
pub(crate) const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
pub(crate) const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
pub(crate) const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
pub(crate) const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
// StepFun / StepFlash defaults
pub(crate) const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
pub(crate) const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
// MiniMax defaults
pub(crate) const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3";
pub(crate) const MINIMAX_M2_7_MODEL: &str = "MiniMax-M2.7";
pub(crate) const MINIMAX_M2_7_HIGHSPEED_MODEL: &str = "MiniMax-M2.7-highspeed";
pub(crate) const MINIMAX_M2_5_MODEL: &str = "MiniMax-M2.5";
pub(crate) const MINIMAX_M2_5_HIGHSPEED_MODEL: &str = "MiniMax-M2.5-highspeed";
pub(crate) const MINIMAX_M2_1_MODEL: &str = "MiniMax-M2.1";
pub(crate) const MINIMAX_M2_1_HIGHSPEED_MODEL: &str = "MiniMax-M2.1-highspeed";
pub(crate) const MINIMAX_M2_MODEL: &str = "MiniMax-M2";
pub(crate) const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
pub(crate) const DEFAULT_MINIMAX_ANTHROPIC_BASE_URL: &str = "https://api.minimax.io/anthropic";
pub(crate) const DEFAULT_DEEPINFRA_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_DEEPINFRA_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_DEEPINFRA_BASE_URL: &str = "https://api.deepinfra.com/v1/openai";
// Sakana AI Fugu defaults
pub(crate) const DEFAULT_SAKANA_MODEL: &str = "fugu";
pub(crate) const DEFAULT_SAKANA_BASE_URL: &str = "https://api.sakana.ai/v1";
// Meituan LongCat defaults
pub(crate) const DEFAULT_LONGCAT_MODEL: &str = "LongCat-2.0";
pub(crate) const DEFAULT_LONGCAT_BASE_URL: &str = "https://api.longcat.chat/openai/v1";
// OpenCode Go keeps its Chat default and resolves other model protocols from
// the shared opencode_go roster.
pub(crate) const DEFAULT_OPENCODE_GO_MODEL: &str = "deepseek-v4-pro";
pub(crate) const DEFAULT_OPENCODE_GO_BASE_URL: &str = "https://opencode.ai/zen/go/v1";
pub(crate) const OPENCODE_GO_GROK_4_5_MODEL: &str = "grok-4.5";
pub(crate) const OPENCODE_GO_GLM_5_2_MODEL: &str = "glm-5.2";
pub(crate) const OPENCODE_GO_GLM_5_1_MODEL: &str = "glm-5.1";
pub(crate) const OPENCODE_GO_KIMI_K3_MODEL: &str = "kimi-k3";
pub(crate) const OPENCODE_GO_KIMI_K2_7_CODE_MODEL: &str = "kimi-k2.7-code";
pub(crate) const OPENCODE_GO_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
pub(crate) const OPENCODE_GO_DEEPSEEK_V4_FLASH_MODEL: &str = "deepseek-v4-flash";
pub(crate) const OPENCODE_GO_MIMO_V2_5_MODEL: &str = "mimo-v2.5";
pub(crate) const OPENCODE_GO_MIMO_V2_5_PRO_MODEL: &str = "mimo-v2.5-pro";
// OpenCode Zen is a model-aware gateway. The default is a documented
// Responses model, but every executable route must still obtain its protocol
// from a provider-scoped catalog offering.
pub(crate) const DEFAULT_OPENCODE_ZEN_MODEL: &str = "gpt-5.6";
pub(crate) const DEFAULT_OPENCODE_ZEN_BASE_URL: &str = "https://opencode.ai/zen/v1";
// Meta Model API / Muse Spark defaults
pub(crate) const DEFAULT_META_MODEL: &str = "muse-spark-1.2";
pub(crate) const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1";
// xAI / Grok API-key route defaults
pub(crate) const DEFAULT_XAI_MODEL: &str = "grok-4.6";
pub(crate) const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
// Mistral AI (la Plateforme) defaults
pub(crate) const DEFAULT_MISTRAL_MODEL: &str = "mistral-code-latest";
pub(crate) const DEFAULT_MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1";
// TelecomJS (Jiangsu Telecom TokenHub) defaults
pub(crate) const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro";
pub(crate) const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1";
// Eden AI (OpenAI-compatible AI gateway) defaults
pub(crate) const DEFAULT_EDENAI_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_EDENAI_BASE_URL: &str = "https://api.edenai.run/v3";
// ZenMux (OpenAI-compatible AI gateway) defaults. Default model verified
// present in the live keyless catalog at https://zenmux.ai/api/v1/models.
pub(crate) const DEFAULT_ZENMUX_MODEL: &str = "deepseek/deepseek-v4.1-flash";
pub(crate) const DEFAULT_ZENMUX_BASE_URL: &str = "https://zenmux.ai/api/v1";
// Concentrate (OpenAI Responses-compatible AI gateway) defaults. Contract:
// https://concentrate.ai/docs/api-reference/introduction — base URL, bearer
// Universal API key, `POST /v1/responses`, unauthenticated `GET /v1/models`.
// The default is a plain catalog id (the gateway picks the upstream provider);
// `provider/model` pins a provider and `concentrate/auto` is the gateway router.
pub(crate) const DEFAULT_CONCENTRATE_MODEL: &str = "deepseek-v4-pro";
pub(crate) const DEFAULT_CONCENTRATE_BASE_URL: &str = "https://api.concentrate.ai/v1";
pub(crate) const DEFAULT_CSDN_MODEL: &str = "glm_for_coding";
pub(crate) const DEFAULT_CSDN_BASE_URL: &str = "https://ai.csdn.net/api/model/v1";
// Codewhale API (account-backed model access) defaults. The account control
// plane serves an OpenAI-shaped `GET /v1/models` whose rows carry the wire
// protocol per model, so this route is model-aware and its catalog is the
// account's own connected providers. The default is a bootstrap hint only.
pub(crate) const DEFAULT_CODEWHALE_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_CODEWHALE_BASE_URL: &str = "https://api.codewhale.net/v1";
// Alibaba Cloud Model Studio (DashScope) defaults
// Token Plan (Personal / Team): shared endpoint, OpenAI + Anthropic dialects
pub(crate) const DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL: &str = "qwen3.8-max";
pub(crate) const DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL: &str =
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1";
pub(crate) const MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL: &str =
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic";
// Coding Plan: separate endpoint, OpenAI + Anthropic dialects
pub(crate) const DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL: &str =
"https://coding-intl.dashscope.aliyuncs.com/v1";
pub(crate) const MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL: &str =
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic";
/// Google Gemini OpenAI-compatible Chat Completions base URL.
pub const DEFAULT_GOOGLE_BASE_URL: &str =
"https://generativelanguage.googleapis.com/v1beta/openai/";
/// Default Gemini model for the Google provider (preview flagship, 2026-08).
pub const DEFAULT_GOOGLE_MODEL: &str = "gemini-3.1-pro-preview";
/// Non-network endpoint for the legacy Antigravity configuration tombstone.
///
/// `.invalid` is reserved and cannot resolve. Existing configuration remains
/// readable solely so Codewhale can guide the user to clear it safely.
pub const DEFAULT_ANTIGRAVITY_BASE_URL: &str = "https://legacy-antigravity.invalid";
/// Non-runnable model marker for the legacy Antigravity tombstone.
pub const DEFAULT_ANTIGRAVITY_MODEL: &str = "legacy-antigravity-disabled";
+5 -58
View File
@@ -309,54 +309,7 @@ impl ProviderKind {
/// stay on the enum for serde and `provider_for_kind`, but they are not
/// first-class catalog rows. Plan is `mode` / base_url; dialect is
/// `wire = openai|anthropic` on the primary provider config.
pub const ALL: [Self; 46] = [
Self::Deepseek,
Self::NvidiaNim,
Self::Openai,
Self::Atlascloud,
Self::WanjieArk,
Self::Volcengine,
Self::Openrouter,
Self::Orcarouter,
Self::XiaomiMimo,
Self::Novita,
Self::Fireworks,
Self::Siliconflow,
Self::Arcee,
Self::SiliconflowCN,
Self::Moonshot,
Self::Sglang,
Self::Vllm,
Self::Ollama,
Self::OllamaCloud,
Self::Huggingface,
Self::Together,
Self::Qianfan,
Self::OpenaiCodex,
Self::Anthropic,
Self::Openmodel,
Self::Zai,
Self::Stepfun,
Self::Minimax,
Self::Deepinfra,
Self::Sakana,
Self::LongCat,
Self::OpencodeGo,
Self::OpencodeZen,
Self::Meta,
Self::Xai,
Self::Mistral,
Self::Telecomjs,
Self::ModelstudioTokenPlan,
Self::Modelscope,
Self::Google,
Self::Edenai,
Self::Zenmux,
Self::Csdn,
Self::Concentrate,
Self::Codewhale,
Self::Custom,
];
pub const ALL: [Self; 46] = crate::descriptors::SELECTABLE_PROVIDER_KINDS;
#[must_use]
pub fn all() -> &'static [Self] {
@@ -385,8 +338,9 @@ impl ProviderKind {
// surface must never be able to resolve one back into a route.
Self::all().iter().copied().find(|kind| {
let p = kind.provider();
trimmed.eq_ignore_ascii_case(p.id())
|| p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))
crate::descriptors::builtin_provider_descriptor(*kind).selectable
&& (trimmed.eq_ignore_ascii_case(p.id())
|| p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a)))
})
}
@@ -453,14 +407,7 @@ impl ProviderKind {
/// one variant held the key.
#[must_use]
pub fn secret_store_slot(self) -> &'static str {
match self {
Self::SiliconflowCN => "siliconflow",
Self::ModelstudioTokenPlan
| Self::ModelstudioTokenPlanAnthropic
| Self::ModelstudioCodingPlan
| Self::ModelstudioCodingPlanAnthropic => "modelstudio-token-plan",
_ => self.as_str(),
}
crate::descriptors::builtin_provider_descriptor(self).secret_store_slot
}
/// Return the built-in metadata entry for this provider.
+8 -46
View File
@@ -65,43 +65,11 @@ pub(crate) fn documented_server_side_web_search(
) -> CapabilityState {
let provider_id = provider_id.trim().to_ascii_lowercase();
let wire_model_id = wire_model_id.trim().to_ascii_lowercase();
let supported = match provider_id.as_str() {
"openai" => matches!(
wire_model_id.as_str(),
"gpt-5.6" | "gpt-5.5" | "gpt-5.4" | "gpt-4.1" | "gpt-4.1-mini" | "o4-mini"
),
"anthropic" => matches!(
wire_model_id.as_str(),
"claude-fable-5"
| "claude-opus-4-8"
| "claude-mythos-5"
| "claude-mythos-preview"
| "claude-opus-4-7"
| "claude-opus-4-6"
| "claude-sonnet-5"
| "claude-sonnet-4-6"
),
"xai" => matches!(wire_model_id.as_str(), "grok-4.6" | "grok-4.5"),
"xiaomi-mimo" => matches!(wire_model_id.as_str(), "mimo-v2.5-pro" | "mimo-v2.5"),
"zai" => matches!(
wire_model_id.as_str(),
"glm-5.3" | "glm-5.3-flash" | "glm-5.2" | "glm-5.1" | "glm-5-turbo"
),
"modelstudio-token-plan" => matches!(
wire_model_id.as_str(),
"qwen3.8-max" | "qwen3.7-plus" | "qwen3.7-max"
),
"deepseek" => matches!(
wire_model_id.as_str(),
"deepseek-flash"
| "deepseek-v4-flash"
| "deepseek-v4-pro"
| "deepseek-v4-flash-vision-exp"
),
"moonshot" => matches!(wire_model_id.as_str(), "kimi-k3" | "kimi-k2.6"),
_ => false,
};
if supported {
if crate::catalog::reviewed::bundled_reviewed()
.search_models
.get(&provider_id)
.is_some_and(|models| models.contains(&wire_model_id))
{
CapabilityState::Supported
} else {
CapabilityState::Unknown
@@ -143,10 +111,7 @@ pub(crate) fn documented_moonshot_web_search_for_route(
}
let model = wire_model_id.trim().to_ascii_lowercase();
if crate::provider::is_exact_kimi_code_route(provider, base_url)
&& matches!(
model.as_str(),
"k3" | "k3-256k" | "kimi-for-coding" | "kimi-for-coding-highspeed"
)
&& crate::catalog::reviewed::route_model_set_contains("kimi_membership_search", &model)
{
return CapabilityState::Supported;
}
@@ -175,7 +140,7 @@ pub(crate) fn documented_deepseek_files_api_for_route(
return CapabilityState::Unknown;
}
let model = wire_model_id.trim().to_ascii_lowercase();
if matches!(model.as_str(), "deepseek-flash" | "deepseek-v4-flash") {
if crate::catalog::reviewed::route_model_set_contains("deepseek_files", &model) {
CapabilityState::Supported
} else {
CapabilityState::Unknown
@@ -202,10 +167,7 @@ pub(crate) fn documented_deepseek_image_input_for_route(
return CapabilityState::Unknown;
}
let model = wire_model_id.trim().to_ascii_lowercase();
if matches!(
model.as_str(),
"deepseek-flash" | "deepseek-v4-flash" | "deepseek-v4-flash-vision-exp"
) {
if crate::catalog::reviewed::route_model_set_contains("deepseek_image", &model) {
CapabilityState::Supported
} else {
CapabilityState::Unknown
+1 -11
View File
@@ -186,17 +186,7 @@ impl TransportKind {
/// `select` whose option value **is a route id**.
#[must_use]
pub fn family_for(kind: ProviderKind) -> &'static str {
match kind {
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => "deepseek",
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => "minimax",
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
| ProviderKind::ModelstudioCodingPlanAnthropic => "alibaba-modelstudio",
ProviderKind::Siliconflow | ProviderKind::SiliconflowCN => "siliconflow",
ProviderKind::Ollama | ProviderKind::OllamaCloud => "ollama",
other => other.as_str(),
}
crate::descriptors::builtin_provider_descriptor(kind).family
}
/// Auth methods declared for a provider kind. OAuth is a type, not an adapter.
+8 -372
View File
@@ -14,7 +14,7 @@
use serde::{Deserialize, Serialize};
use super::candidate::PricingSku;
use super::capabilities::{CapabilityState, RouteCapabilities};
use super::capabilities::RouteCapabilities;
use super::ids::{ModelId, ProviderId, WireModelId};
/// Token limits for one resolved route/offering.
@@ -74,140 +74,6 @@ pub struct ProviderModelOffering {
pub pricing: PricingSku,
}
/// Token Plan text models (Text Generation / Reasoning, coding scope).
///
/// Available on both Token Plan Personal and Team. The same model set is also
/// available on the Coding Plan; rows are duplicated per provider id below.
/// Pay-as-you-go workspace-id templating is deferred to a follow-up.
const MODELSTUDIO_TEXT_MODELS: &[&str] = &[
"qwen3.8-max",
"qwen3.8-max-preview",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
// DeepSeek models served under Model Studio are scoped to this provider;
// they do not collide with first-party DeepSeek routes.
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
// GLM models served under Model Studio are scoped to this provider;
// they do not collide with first-party Zhipu / Z.ai routes.
//
// glm-5.3 is deliberately absent (2026-08-03): this list is a curated
// snapshot of what Model Studio's upstream roster actually serves, and
// Model Studio publishes no glm-5.3 entry. The direct Z.ai / OpenRouter
// glm-5.3 rows inherit their metadata from glm-5.2, but metadata
// inheritance is not evidence that a third-party gateway carries the
// model. Add it here only against a Model Studio console/roster listing.
"glm-5.2",
];
// OpenCode Zen transport snapshot, refreshed 2026-09-28 (#6705) against the
// endpoint table at https://opencode.ai/docs/zen and the `opencode` provider
// in https://models.dev/catalog.json, whose per-model `provider.npm` names the
// AI SDK package (and so the wire) OpenCode itself uses. The two sources agree
// row for row. This list is only the offline floor: a Models.dev snapshot
// carries the same fact for models released after this build (see
// `opencode_zen_endpoint_key_for_npm`), so a new Zen model routes without a
// Codewhale release once the catalog knows it.
//
// Gemini (`/models/{id}`, Google's generateContent wire) and Jev
// (`/systemone`) are deliberately absent: Codewhale speaks neither protocol,
// so those models fail closed with their endpoint named.
pub(crate) const OPENCODE_ZEN_RESPONSES_MODELS: &[&str] = &[
"gpt-6-astra",
"gpt-6-sol",
"gpt-6-luna",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.4",
"gpt-5.4-pro",
"gpt-5.4-mini",
"gpt-5.4-nano",
"gpt-5.3-codex",
"gpt-5.3-codex-spark",
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5.1-codex-mini",
"gpt-5",
"gpt-5-codex",
"gpt-5-nano",
// Zen serves Grok over Responses (`@ai-sdk/openai`); snapshots before
// 2026-09-28 listed grok-4.5 and grok-build-0.1 under Chat Completions.
"grok-4.7",
"grok-4.6",
"grok-4.5",
"grok-build-0.1",
// Muse Spark via OpenCode Zen gateway — Responses-only (reported
// 2026-08-29: muse-spark-1.2-contributor-free rejects Chat Completions).
"muse-spark-1.3",
"muse-spark-1.3-contributor-free",
"muse-spark-1.2",
"muse-spark-1.2-contributor",
"muse-spark-1.2-contributor-free",
];
pub(crate) const OPENCODE_ZEN_MESSAGES_MODELS: &[&str] = &[
"claude-fable-5-1",
"claude-fable-5",
"claude-opus-5-5",
"claude-opus-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4-5",
// claude-sonnet-5-5 and claude-sonnet-4 are on Zen's live `/models` and
// Models.dev lists both as `@ai-sdk/anthropic`; the docs table omits them.
"claude-sonnet-5-5",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-sonnet-4-5",
"claude-sonnet-4",
"claude-haiku-4-5",
"qwen3.8-flash",
"qwen3.7-max",
"qwen3.7-plus",
"qwen3.6-plus",
"qwen3.5-plus",
];
pub(crate) const OPENCODE_ZEN_CHAT_MODELS: &[&str] = &[
"deepseek-v4.1-flash",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-flash-vision-exp",
"minimax-m3",
"minimax-m2.7",
"minimax-m2.5",
// Zen's endpoint table lists glm-5.3 and glm-5.3-flash as of 2026-09-28.
"glm-5.3-flash",
"glm-5.3",
"glm-5.2",
"glm-5.1",
"glm-5",
"kimi-k3",
"kimi-k2.7-code",
"kimi-k2.6",
"kimi-k2.5",
// Unlike its Qwen siblings, qwen3.8-max is served over Chat Completions.
"qwen3.8-max",
"big-pickle",
"space-bunny-free",
"longcat-2.5-preview-free",
"mimo-v2.6-flash-free",
"mimo-v2.5-free",
"ling-3.0-flash-fin-free",
"north-mini-code-free",
"nemotron-3-ultra-free",
"nemotron-3.5-lightning-free",
"deepseek-v4-flash-free",
];
/// Endpoint key for a Zen catalog row Models.dev marks `deprecated`. It names
/// no protocol, so the resolver refuses the model locally with this reason
/// instead of sending a request Zen no longer serves.
@@ -239,20 +105,7 @@ pub fn opencode_zen_endpoint_key_for_npm(npm: Option<&str>) -> &'static str {
/// `gpt-5.6-sol` is the proven Responses wire id.
#[must_use]
pub fn opencode_zen_picker_models() -> Vec<&'static str> {
let mut models = vec![crate::DEFAULT_OPENCODE_ZEN_MODEL];
for model in OPENCODE_ZEN_RESPONSES_MODELS
.iter()
.chain(OPENCODE_ZEN_MESSAGES_MODELS)
.chain(OPENCODE_ZEN_CHAT_MODELS)
{
if !models
.iter()
.any(|existing| existing.eq_ignore_ascii_case(model))
{
models.push(*model);
}
}
models
crate::catalog::reviewed::constants::completion_names("opencode-zen").to_vec()
}
/// Codewhale API bootstrap rows used only when the account's live
@@ -262,11 +115,7 @@ pub fn opencode_zen_picker_models() -> Vec<&'static str> {
/// customer connected, and each row states its own protocol. These three rows
/// exist so a route can still be selected offline; every consumer that shows
/// models must say the list is a fallback, not the account's catalog.
pub const CODEWHALE_FALLBACK_MODELS: &[&str] = &[
"deepseek/deepseek-v4-pro",
"anthropic/claude-sonnet-5",
"openai/gpt-5.6",
];
pub use crate::catalog::reviewed::constants::CODEWHALE_FALLBACK_MODELS;
/// Endpoint key for one Codewhale API model id.
///
@@ -293,222 +142,9 @@ pub fn codewhale_endpoint_key_for_model(model: &str) -> &'static str {
/// identity: their sole claim is the documented wire model and endpoint key.
#[must_use]
pub fn bundled_offerings() -> Vec<ProviderModelOffering> {
// DeepSeek's 2026-07-31 production Flash update added a native Responses
// endpoint without changing the model id, and the 2026-08 unversioned
// rename to `deepseek-flash` kept that wire: DeepSeek's own Codex
// integration documents the Responses API as the path for `deepseek-flash`
// (legacy `deepseek-v4-flash` ids are served by the same model). The
// shipped default therefore rides Responses; Pro remains Chat Completions
// until its announced Responses rollout. These exact-route transport facts
// cannot be represented by the Models.dev-shaped fallback asset.
let deepseek = ProviderId::from("deepseek");
let documented_capabilities = RouteCapabilities {
image_input: CapabilityState::Unsupported,
reasoning: CapabilityState::Supported,
native_tool_calls: CapabilityState::Supported,
structured_output: CapabilityState::Supported,
parallel_tool_calls: CapabilityState::Supported,
streaming: CapabilityState::Supported,
prompt_caching: CapabilityState::Supported,
// Search execution uses a separate bounded Responses request rather
// than replaying `web_search_call` items in the main chat.
server_side_web_search: CapabilityState::Supported,
..RouteCapabilities::default()
};
// DeepSeek's Vision guide documents image input for `deepseek-flash`
// over Chat Completions, Responses and Messages; the legacy
// `deepseek-v4-flash` and `deepseek-v4-flash-vision-exp` names are served
// by the same model (verified 2026-09-23, #6421). Pro stays text-only.
// This curated row wins identity collisions over the Models.dev asset, so
// correcting only the asset left the resolved route stripping images.
let flash_capabilities = RouteCapabilities {
image_input: CapabilityState::Supported,
..documented_capabilities
};
let documented_limits = RouteLimits {
context_tokens: Some(1_000_000),
input_tokens: None,
output_tokens: Some(384_000),
};
let mut offerings = vec![
ProviderModelOffering {
provider: deepseek.clone(),
canonical_model: Some(ModelId::from("deepseek-flash")),
wire_model_id: WireModelId::from("deepseek-flash"),
endpoint_key: "responses".to_string(),
default_for_provider: true,
limits: documented_limits,
capabilities: flash_capabilities,
pricing: PricingSku::UnknownOrStale,
},
ProviderModelOffering {
provider: deepseek.clone(),
canonical_model: Some(ModelId::from("deepseek-v4-pro")),
wire_model_id: WireModelId::from("deepseek-v4-pro"),
endpoint_key: "chat".to_string(),
default_for_provider: false,
limits: documented_limits,
capabilities: documented_capabilities,
pricing: PricingSku::UnknownOrStale,
},
ProviderModelOffering {
provider: deepseek.clone(),
canonical_model: Some(ModelId::from("deepseek-v4-flash")),
wire_model_id: WireModelId::from("deepseek-v4-flash"),
endpoint_key: "responses".to_string(),
default_for_provider: false,
limits: documented_limits,
capabilities: flash_capabilities,
pricing: PricingSku::UnknownOrStale,
},
// Vision-experimental sibling of v4-flash, verified live on
// api.deepseek.com /models (2026-08-21). Image input is the one
// documented difference; limits inherit the v4-flash row until
// DeepSeek publishes distinct numbers.
ProviderModelOffering {
provider: deepseek,
canonical_model: Some(ModelId::from("deepseek-v4-flash-vision-exp")),
wire_model_id: WireModelId::from("deepseek-v4-flash-vision-exp"),
endpoint_key: "chat".to_string(),
default_for_provider: false,
limits: documented_limits,
capabilities: flash_capabilities,
pricing: PricingSku::UnknownOrStale,
},
];
offerings.extend(
crate::opencode_go::MODEL_GROUPS
.iter()
.flat_map(|(endpoint_key, models)| {
models.iter().map(move |model| ProviderModelOffering {
provider: ProviderId::from("opencode-go"),
canonical_model: None,
wire_model_id: WireModelId::from(*model),
endpoint_key: (*endpoint_key).to_string(),
default_for_provider: *model == crate::DEFAULT_OPENCODE_GO_MODEL,
limits: RouteLimits::default(),
capabilities: RouteCapabilities::default(),
pricing: PricingSku::UnknownOrStale,
})
}),
);
let provider = ProviderId::from("opencode-zen");
let groups = [
("responses", OPENCODE_ZEN_RESPONSES_MODELS),
("messages", OPENCODE_ZEN_MESSAGES_MODELS),
("chat", OPENCODE_ZEN_CHAT_MODELS),
];
offerings.extend(groups.into_iter().flat_map(|(endpoint_key, models)| {
let provider = provider.clone();
models.iter().map(move |model| ProviderModelOffering {
provider: provider.clone(),
// The bundled catalog exposes `gpt-5.6` as the user-facing
// logical choice and records `gpt-5.6-sol` as its proven Zen
// wire id. Keep the generic choice honest by resolving it to the
// documented concrete Responses model rather than sending an
// unproven generic wire id to Zen.
canonical_model: (*model == "gpt-5.6-sol").then(|| ModelId::from("gpt-5.6")),
wire_model_id: WireModelId::from(*model),
endpoint_key: endpoint_key.to_string(),
default_for_provider: *model == "gpt-5.6-sol",
limits: RouteLimits::default(),
capabilities: RouteCapabilities::default(),
pricing: PricingSku::UnknownOrStale,
})
}));
// Codewhale API bootstrap rows. The account's authenticated
// `GET {base}/models` is the catalog authority and replaces these as soon
// as it is reachable; they exist so the route resolves offline.
let codewhale = ProviderId::from("codewhale");
offerings.extend(
CODEWHALE_FALLBACK_MODELS
.iter()
.map(|model| ProviderModelOffering {
provider: codewhale.clone(),
canonical_model: None,
wire_model_id: WireModelId::from(*model),
endpoint_key: codewhale_endpoint_key_for_model(model).to_string(),
default_for_provider: *model == crate::DEFAULT_CODEWHALE_MODEL,
limits: RouteLimits::default(),
capabilities: RouteCapabilities::default(),
pricing: PricingSku::UnknownOrStale,
}),
);
// Alibaba Cloud Model Studio — one vendor identity in the hand seam
// (`modelstudio-token-plan`). Plan (token vs coding) and wire dialect
// (OpenAI Chat Completions vs Anthropic Messages) are config (`mode` /
// `wire`), not separate ProviderKinds — same product shape as Z.ai /
// Xiaomi for plans and a power-user toggle for dialect. Legacy provider
// ids still get catalog rows so old configs resolve, but the picker
// catalog surface only lists the primary id.
//
// Limits: owner's Token Plan console + curated models_dev rows
// (2026-08-03): qwen3.8-max is ~1M context / 128K output, NOT 128K
// total. Empty RouteLimits here used to win identity collisions over
// the asset catalog and fall through to the 128K legacy default.
fn ms_capabilities(model: &str) -> RouteCapabilities {
let image_input = match model {
"qwen3.8-max" | "qwen3.8-max-preview" | "qwen3.7-plus" | "qwen3.6-flash" => {
CapabilityState::Supported
}
_ => CapabilityState::Unsupported,
};
RouteCapabilities {
reasoning: CapabilityState::Supported,
native_tool_calls: CapabilityState::Supported,
structured_output: CapabilityState::Supported,
streaming: CapabilityState::Supported,
image_input,
server_side_web_search: super::documented_server_side_web_search(
"modelstudio-token-plan",
model,
),
..RouteCapabilities::default()
}
}
fn ms_limits(model: &str) -> RouteLimits {
// Context/output from models_dev.bundled.json Model Studio rows and
// the owner console (verified 2026-08-03). Keep output separate from
// context so a 128K generation ceiling is never mistaken for the
// window.
let (context_tokens, output_tokens) = match model {
"qwen3.8-max" | "qwen3.8-max-preview" => (1_000_000, 131_072),
"qwen3.7-plus" | "qwen3.7-max" => (1_000_000, 65_536),
"qwen3.6-flash" => (1_000_000, 65_536),
"deepseek-v4-pro" | "deepseek-v4-flash-0731" => (1_000_000, 384_000),
"glm-5.2" => (1_000_000, 131_072),
_ => (1_000_000, 131_072),
};
RouteLimits {
context_tokens: Some(context_tokens),
input_tokens: None,
output_tokens: Some(output_tokens),
}
}
// Primary vendor id only in the hand seam. Coding-plan / anthropic
// dialect endpoint selection is owned by config resolution (mode/wire),
// which rewrites base_url + request dialect without inventing kinds.
let plan = ProviderId::from("modelstudio-token-plan");
offerings.extend(
MODELSTUDIO_TEXT_MODELS
.iter()
.enumerate()
.map(|(i, model)| ProviderModelOffering {
provider: plan.clone(),
canonical_model: None,
wire_model_id: WireModelId::from(*model),
endpoint_key: "chat".to_string(),
default_for_provider: i == 0,
limits: ms_limits(model),
capabilities: ms_capabilities(model),
pricing: PricingSku::UnknownOrStale,
}),
);
offerings
crate::catalog::reviewed::bundled_reviewed()
.transports
.iter()
.map(crate::catalog::reviewed::ReviewedTransport::to_offering)
.collect()
}
+2 -2
View File
@@ -118,8 +118,8 @@ impl RouteResolver {
///
/// The default offerings are the committed Models.dev-shaped catalog asset
/// (`crate::catalog::bundled_catalog_offerings`, real context windows and
/// honest per-row `cost`) merged with the tiny hand seam
/// ([`bundled_offerings`]). The hand seam is kept and given precedence on a
/// honest per-row `cost`) merged with the reviewed transport projection
/// ([`bundled_offerings`]). The reviewed projection is given precedence on a
/// `(provider, wire id)` collision: it encodes the curated canonical-model
/// joins the route invariants depend on (e.g. a DeepSeek-native row and the
/// aggregator rows that map a prefixed wire id back to `deepseek-v4-pro`),
+1 -1
View File
@@ -1469,7 +1469,7 @@ fn codewhale_api_base_env_refuses_cleartext_off_loopback() {
#[test]
fn opencode_zen_resolver_selects_protocol_from_documented_model_catalog() {
use super::offering::{
use crate::catalog::reviewed::constants::{
OPENCODE_ZEN_CHAT_MODELS, OPENCODE_ZEN_MESSAGES_MODELS, OPENCODE_ZEN_RESPONSES_MODELS,
};
+297
View File
@@ -0,0 +1,297 @@
//! Existing Windows current-user token/SID custody, shared by owned storage and IPC.
use anyhow::{Context, Result, bail};
use std::os::windows::io::FromRawHandle as _;
#[cfg(windows)]
pub struct CurrentWindowsUser {
_token: std::os::windows::io::OwnedHandle,
token_info: Vec<usize>,
}
#[cfg(windows)]
impl CurrentWindowsUser {
pub fn open() -> Result<Self> {
// SAFETY: Windows supplies a process pseudo-handle; no ownership transfer.
Self::from_process(unsafe { windows_sys::Win32::System::Threading::GetCurrentProcess() })
}
fn from_process(process: windows_sys::Win32::Foundation::HANDLE) -> Result<Self> {
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE};
use windows_sys::Win32::Security::{
GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser,
};
use windows_sys::Win32::System::Threading::OpenProcessToken;
let mut token: HANDLE = std::ptr::null_mut();
// SAFETY: the pseudo-process handle is valid and `token` is writable.
if unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) } == 0 {
return Err(std::io::Error::last_os_error())
.context("opening current Windows user token");
}
let mut needed = 0;
// SAFETY: a null buffer/zero length asks for the required size.
let _ =
unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) };
if needed < std::mem::size_of::<TOKEN_USER>() as u32 || needed > 65536 {
let error = std::io::Error::from_raw_os_error(unsafe { GetLastError() } as i32);
// SAFETY: the token is owned on this error path.
unsafe { CloseHandle(token) };
return Err(error).context("sizing current Windows user token information");
}
let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
let mut token_info = vec![0usize; words];
// SAFETY: the aligned buffer contains at least `needed` writable bytes.
if unsafe {
GetTokenInformation(
token,
TokenUser,
token_info.as_mut_ptr().cast(),
needed,
&mut needed,
)
} == 0
{
let error = std::io::Error::last_os_error();
// SAFETY: the token is owned on this error path.
unsafe { CloseHandle(token) };
return Err(error).context("reading current Windows user token information");
}
let user = unsafe { &*token_info.as_ptr().cast::<TOKEN_USER>() };
if user.User.Sid.is_null() {
// SAFETY: the token is owned on this error path.
unsafe { CloseHandle(token) };
bail!("current Windows user token has no SID");
}
// SAFETY: the token was opened above and is transferred exactly once.
let token = unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(token) };
Ok(Self {
_token: token,
token_info,
})
}
pub fn sid_string(&self) -> Result<String> {
use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW;
let mut text = std::ptr::null_mut();
// SAFETY: the validated SID remains held and the output is writable.
if unsafe { ConvertSidToStringSidW(self.sid(), &mut text) } == 0 {
return Err(std::io::Error::last_os_error()).context("encoding current Windows SID");
}
let _text = WindowsLocalAllocation(text.cast());
anyhow::ensure!(!text.is_null(), "Windows SID text unavailable");
let mut units = Vec::new();
for index in 0..192 {
let unit = unsafe { *text.add(index) };
if unit == 0 {
return String::from_utf16(&units).map_err(Into::into);
}
units.push(unit);
}
bail!("Windows SID text exceeds its fixed bound")
}
pub fn sid(&self) -> windows_sys::Win32::Security::PSID {
use windows_sys::Win32::Security::TOKEN_USER;
// SAFETY: the aligned token buffer remains owned by `self`.
unsafe { (*self.token_info.as_ptr().cast::<TOKEN_USER>()).User.Sid }
}
}
#[cfg(windows)]
pub(crate) struct WindowsLocalAllocation(pub(crate) *mut core::ffi::c_void);
#[cfg(windows)]
impl Drop for WindowsLocalAllocation {
fn drop(&mut self) {
if !self.0.is_null() {
// SAFETY: Windows allocated this block for a LocalFree caller.
unsafe { windows_sys::Win32::Foundation::LocalFree(self.0) };
}
}
}
/// The actual kernel-selected peer process and token, held across control admission.
/// A display PID or a same-user SID is never sufficient to select an owner.
pub struct WindowsPeerProcess {
process: std::os::windows::io::OwnedHandle,
user: CurrentWindowsUser,
pid: u32,
start: String,
}
impl WindowsPeerProcess {
pub fn open_current_user(pid: u32) -> Result<Self> {
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE;
use windows_sys::Win32::System::Threading::{
GetProcessId, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
anyhow::ensure!(pid > 0, "invalid Windows peer PID");
let process =
unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE, 0, pid) };
anyhow::ensure!(!process.is_null(), "Windows peer process unavailable");
let process = unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(process) };
anyhow::ensure!(
unsafe { GetProcessId(process.as_raw_handle()) } == pid,
"Windows kernel peer PID changed"
);
let user = CurrentWindowsUser::from_process(process.as_raw_handle())?;
let start = process_creation(process.as_raw_handle())?;
let value = Self {
process,
user,
pid,
start,
};
value.check_current_user()?;
Ok(value)
}
pub fn pid(&self) -> u32 {
self.pid
}
pub fn start(&self) -> &str {
&self.start
}
pub fn principal(&self) -> Result<String> {
self.user.sid_string()
}
pub fn check_current_user(&self) -> Result<()> {
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::Security::EqualSid;
use windows_sys::Win32::System::Threading::GetExitCodeProcess;
let mut status = 0;
anyhow::ensure!(
unsafe { GetExitCodeProcess(self.process.as_raw_handle(), &mut status) } != 0
&& status == 259,
"Windows peer process has exited"
);
let current = CurrentWindowsUser::open()?;
let peer = CurrentWindowsUser::from_process(self.process.as_raw_handle())?;
anyhow::ensure!(
unsafe { EqualSid(current.sid(), peer.sid()) } != 0
&& unsafe { EqualSid(self.user.sid(), peer.sid()) } != 0,
"Windows peer principal changed"
);
anyhow::ensure!(
process_creation(self.process.as_raw_handle())? == self.start,
"Windows peer generation changed"
);
Ok(())
}
}
fn process_creation(process: windows_sys::Win32::Foundation::HANDLE) -> Result<String> {
use windows_sys::Win32::Foundation::FILETIME;
use windows_sys::Win32::System::Threading::GetProcessTimes;
let (mut creation, mut exit, mut kernel, mut user) = (
FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
},
FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
},
FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
},
FILETIME {
dwLowDateTime: 0,
dwHighDateTime: 0,
},
);
anyhow::ensure!(
unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) } != 0,
"Windows process creation identity unavailable"
);
Ok(format!(
"windows:{}:{}",
creation.dwHighDateTime, creation.dwLowDateTime
))
}
/// One current-user ACL builder, immediately adopted by private storage and
/// named-pipe creation. Both commit the same protected owner/DACL policy.
pub struct OwnerOnlyAcl {
user: CurrentWindowsUser,
acl: WindowsLocalAllocation,
}
impl OwnerOnlyAcl {
pub fn new(access: u32, inherit_to_children: bool) -> Result<Self> {
use windows_sys::Win32::Security::Authorization::{
EXPLICIT_ACCESS_W, SET_ACCESS, SetEntriesInAclW, TRUSTEE_IS_SID, TRUSTEE_IS_USER,
TRUSTEE_W,
};
use windows_sys::Win32::Security::{NO_INHERITANCE, SUB_CONTAINERS_AND_OBJECTS_INHERIT};
let user = CurrentWindowsUser::open()?;
let entry = EXPLICIT_ACCESS_W {
grfAccessPermissions: access,
grfAccessMode: SET_ACCESS,
grfInheritance: if inherit_to_children {
SUB_CONTAINERS_AND_OBJECTS_INHERIT
} else {
NO_INHERITANCE
},
Trustee: TRUSTEE_W {
pMultipleTrustee: std::ptr::null_mut(),
MultipleTrusteeOperation: 0,
TrusteeForm: TRUSTEE_IS_SID,
TrusteeType: TRUSTEE_IS_USER,
ptstrName: user.sid().cast(),
},
};
let mut acl = std::ptr::null_mut();
let status = unsafe { SetEntriesInAclW(1, &entry, std::ptr::null(), &mut acl) };
anyhow::ensure!(
status == 0,
"building current-user-only ACL failed ({status})"
);
Ok(Self {
user,
acl: WindowsLocalAllocation(acl.cast()),
})
}
pub(crate) fn user_sid(&self) -> windows_sys::Win32::Security::PSID {
self.user.sid()
}
pub(crate) fn acl(&self) -> *mut windows_sys::Win32::Security::ACL {
self.acl.0.cast()
}
pub fn with_security_attributes<T>(
&self,
create: impl FnOnce(*mut core::ffi::c_void) -> Result<T>,
) -> Result<T> {
use windows_sys::Win32::Security::{
InitializeSecurityDescriptor, SE_DACL_PROTECTED, SECURITY_ATTRIBUTES,
SECURITY_DESCRIPTOR, SetSecurityDescriptorControl, SetSecurityDescriptorDacl,
SetSecurityDescriptorOwner,
};
let mut descriptor = SECURITY_DESCRIPTOR::default();
let pointer = (&mut descriptor as *mut SECURITY_DESCRIPTOR).cast();
anyhow::ensure!(
unsafe { InitializeSecurityDescriptor(pointer, 1) } != 0,
"initializing owner security descriptor"
);
anyhow::ensure!(
unsafe { SetSecurityDescriptorOwner(pointer, self.user.sid(), 0) } != 0,
"setting owner SID"
);
anyhow::ensure!(
unsafe { SetSecurityDescriptorDacl(pointer, 1, self.acl(), 0) } != 0,
"setting owner-only DACL"
);
anyhow::ensure!(
unsafe { SetSecurityDescriptorControl(pointer, SE_DACL_PROTECTED, SE_DACL_PROTECTED) }
!= 0,
"protecting owner-only DACL"
);
let mut attributes = SECURITY_ATTRIBUTES {
nLength: std::mem::size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: pointer,
bInheritHandle: 0,
};
create((&mut attributes as *mut SECURITY_ATTRIBUTES).cast())
}
}
File diff suppressed because it is too large. Load diff
+67 -1598
View File
File diff suppressed because it is too large. Load diff
+4 -6
View File
@@ -8,12 +8,10 @@
//! `SessionId`. Many sessions can attach to one thread over time, but only
//! one `Session` drives a turn for a given `ThreadId` at a time.
//!
//! The thread manager (`ThreadManager` in `crate::lib`) already can start a
//! session with no TUI attached (`spawn_thread_with_history`); this file
//! formalizes the types that make that first-class and moves the former
//! `crates/tui/src/core/session.rs` state (model, reasoning_effort,
//! `AppendLog`, `PrefixStabilityManager`, `frozen_prefix`,
//! `messages_revision`) into `crates/core` so both TUI and headless share it.
//! The canonical RuntimeThreadManager creates sessions without a TUI and drives
//! the same Engine turn loop used by mounted clients. These types preserve the
//! shared model, append log, prefix stability and message revision state; the
//! compatibility Core Runtime owns only configuration, hooks and job bookkeeping.
use std::path::PathBuf;
+621 -57
View File
@@ -46,8 +46,8 @@
//! `request_subagent_model_response_with_retries(…)` and the RLM loop calls
//! `client.create_message_boxed(…)` and runs its code rounds through
//! `repl.run(…)`. CI said "exactly one turn loop" while three existed. The
//! markers above now cover both spellings, and both loops are named interim
//! exceptions below instead of invisible ones.
//! markers cover both spellings. Both the child and RLM producer/code loops
//! now enter the canonical Engine; neither has a migration exception.
//!
//! # Known limitations
//!
@@ -62,7 +62,7 @@
//! - It reports one loop per (file, enclosing function). A function with two
//! turn loops in it is one finding, not two.
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
/// A loop that is permitted to drive model/tool rounds, and the reason.
@@ -79,44 +79,12 @@ struct AllowedTurnLoop {
why: &'static str,
}
const ALLOWED_TURN_LOOPS: &[AllowedTurnLoop] = &[
AllowedTurnLoop {
path: "crates/tui/src/core/engine/turn_loop.rs",
owner: "run_turn",
why: "THE turn loop. `Engine::run_turn` is the single agent loop; \
const ALLOWED_TURN_LOOPS: &[AllowedTurnLoop] = &[AllowedTurnLoop {
path: "crates/tui/src/core/engine/turn_loop.rs",
owner: "run_turn",
why: "THE turn loop. `Engine::run_turn` is the single agent loop; \
docs/ARCHITECTURE.md and AGENTS.md both name it as the owner.",
},
AllowedTurnLoop {
path: "crates/tui/src/acp_server.rs",
owner: "run_agentic_prompt_turn",
why: "INTERIM EXCEPTION (#6088). ACP IDE sessions do not run on the \
full thread/turn runtime yet, so `run_agentic_prompt_turn` \
drives its own bounded tool-round loop. #5835 (IDE stage 2) \
converges them onto `Engine::run_turn`; when it lands, delete \
the loop and this entry together.",
},
AllowedTurnLoop {
path: "crates/tui/src/tools/subagent/mod.rs",
owner: "run_subagent",
why: "INTERIM EXCEPTION (#6504). Sub-agents drive their own model/tool \
rounds through `request_subagent_model_response_with_retries` \
instead of `Engine::run_turn`. It was invisible to this guard \
until #6511 widened the model-call marker; #6504 converges the \
child onto the Engine. Delete the loop and this entry together.",
},
AllowedTurnLoop {
path: "crates/tui/src/rlm/turn.rs",
owner: "run_rlm_turn_impl",
why: "INTERIM EXCEPTION (#6511). The recursive sub-RLM (`rlm_query` \
from the Python REPL, `rlm/bridge.rs::dispatch_rlm`) runs \
paper Algorithm 1: root model writes code, the REPL runs it, \
the result is appended to its own history. It is bounded by \
MAX_RLM_ITERATIONS, forwards its events to the parent stream, \
keeps its whole history, and never returns an empty answer \
silently; converging it onto `Engine::run_turn` is the \
remaining work. Delete the loop and this entry together.",
},
];
}];
// ---------------------------------------------------------------------------
// Detection
@@ -275,8 +243,8 @@ fn sanitize(src: &str) -> String {
/// Strip `#[cfg(test)]`-gated items. A test harness that drives rounds is not
/// a shipped turn loop.
fn strip_cfg_test(src: &str) -> String {
let chars: Vec<char> = src.chars().collect();
let mut out = chars.clone();
let chars: Vec<char> = sanitize(src).chars().collect();
let mut out: Vec<char> = src.chars().collect();
let mut search_from = 0usize;
let needle = "cfg(test)";
while let Some(rel) = src[search_from..].find(needle) {
@@ -292,10 +260,36 @@ fn strip_cfg_test(src: &str) -> String {
}
let hash_ci = src[..hash].chars().count();
let mut i = src[..search_from].chars().count();
while i < chars.len() && chars[i] != '{' && chars[i] != ';' {
// Skip the cfg attribute's close, then find this item's boundary.
// Commas end gated struct fields/initializers, while commas inside a
// function signature or generic type do not. Braces in strings and
// comments were already blanked by the same existing sanitizer.
while i < chars.len() && chars[i] != ']' {
i += 1;
}
if i >= chars.len() || chars[i] == ';' {
i += 1;
let mut nesting = 0usize;
let mut angles = 0usize;
while i < chars.len() {
match chars[i] {
'(' | '[' => nesting += 1,
')' | ']' => nesting = nesting.saturating_sub(1),
'<' if nesting == 0 => angles += 1,
'>' if nesting == 0 => angles = angles.saturating_sub(1),
'{' | ';' | ',' if nesting == 0 && angles == 0 => break,
_ => {}
}
i += 1;
}
if i >= chars.len() {
continue;
}
if matches!(chars[i], ';' | ',') {
for slot in out.iter_mut().take(i + 1).skip(hash_ci) {
if *slot != '\n' {
*slot = ' ';
}
}
continue;
}
let mut depth = 0usize;
@@ -495,26 +489,292 @@ fn enclosing_fn(chars: &[char], at: usize) -> String {
best.unwrap_or_else(|| "<unknown>".to_string())
}
/// Find every turn loop in one Rust source, by shape.
fn detect_turn_loops(src: &str, rel_path: &str) -> Vec<TurnLoopSite> {
/// A resolved local method, including the syntactic impl receiver. Matching
/// `self.phase()` by its spelling alone would join unrelated impls.
#[derive(Clone)]
struct LocalMethod {
receiver: String,
name: String,
body: String,
open: usize,
close: usize,
}
fn matching_brace(chars: &[char], open: usize) -> usize {
let mut depth = 0usize;
for (at, ch) in chars.iter().enumerate().skip(open) {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return at;
}
}
_ => {}
}
}
panic!("unbalanced source block at {open}");
}
/// Reuse the sanitized, balanced source rather than phase names or markers.
/// Only inherent impls with literal receiver names are resolved. Unknown or
/// ambiguous calls are refused when needed, rather than guessed across types.
fn local_methods(prepared: &str) -> Vec<LocalMethod> {
let chars: Vec<char> = prepared.chars().collect();
let mut methods = Vec::new();
for (byte, _) in prepared.match_indices("impl ") {
if byte > 0 && is_ident_char(prepared[..byte].chars().next_back().unwrap()) {
continue;
}
let start = prepared[..byte].chars().count();
let Some(open_rel) = chars[start..].iter().position(|c| *c == '{') else {
continue;
};
let open = start + open_rel;
let header: String = chars[start + 5..open].iter().collect();
// Trait impls and generic/path receivers require more resolution than
// this literal local graph provides; direct shape detection remains.
let receiver = header.trim();
if receiver.is_empty() || !receiver.chars().all(is_ident_char) {
continue;
}
let close = matching_brace(&chars, open);
let impl_body: String = chars[open + 1..close].iter().collect();
for (fn_byte, _) in impl_body.match_indices("fn ") {
if fn_byte > 0 && is_ident_char(impl_body[..fn_byte].chars().next_back().unwrap()) {
continue;
}
let rest = &impl_body[fn_byte + 3..];
let name_end = rest.find(|c: char| !is_ident_char(c)).unwrap_or(rest.len());
if name_end == 0 {
continue;
}
let fn_start = open + 1 + impl_body[..fn_byte].chars().count();
let Some(body_rel) = chars[fn_start..close].iter().position(|c| *c == '{') else {
continue;
};
let body_open = fn_start + body_rel;
let signature: String = chars[fn_start..body_open].iter().collect();
if signature.contains(';')
|| !signature
.split(|c: char| !is_ident_char(c))
.any(|v| v == "self")
{
continue;
}
let body_close = matching_brace(&chars, body_open);
methods.push(LocalMethod {
receiver: receiver.to_string(),
name: rest[..name_end].to_string(),
body: chars[body_open..=body_close].iter().collect(),
open: body_open,
close: body_close,
});
}
}
methods
}
type MethodGraph = BTreeMap<(String, String), Vec<String>>;
fn method_graph(sources: &[String]) -> MethodGraph {
let mut graph = MethodGraph::new();
for (index, source) in sources.iter().enumerate() {
let prepared = sanitize(&strip_cfg_test(source));
let compact: String = prepared.chars().filter(|c| !c.is_whitespace()).collect();
let tokens: Vec<&str> = prepared
.split(|c: char| !is_ident_char(c))
.filter(|s| !s.is_empty())
.collect();
for method in local_methods(&prepared) {
if index + 1 != sources.len() {
// A child contributes to its parent's inherent impl only
// through a literal parent binding, with no local same-named
// type. Unrelated imports/local types are scanned at their
// own file but cannot impersonate the parent's phase owner.
let parent_binding = compact.contains("usesuper::*;")
|| compact.contains(&format!("usesuper::{};", method.receiver));
let local_type = tokens.windows(2).any(|pair| {
matches!(pair[0], "struct" | "enum" | "type" | "union")
&& pair[1] == method.receiver
});
if !parent_binding || local_type {
continue;
}
}
graph
.entry((method.receiver, method.name))
.or_default()
.push(method.body);
}
}
graph
}
/// A helper's own loops are independently scanned at their lexical owner.
/// Do not transfer a delegated loop's effects into its caller and thereby
/// conceal a second owner. Ordinary blocks/async expressions stay intact.
fn without_owned_loops(body: &str) -> String {
let mut chars: Vec<char> = body.chars().collect();
for (_, open, close) in loop_bodies(&chars) {
for ch in &mut chars[open..=close] {
if *ch != '\n' {
*ch = ' ';
}
}
}
chars.into_iter().collect()
}
/// Once a `self` call resolves locally, its method name is not evidence of a
/// provider call: inspect its real body. For example an actual snapshot
/// `*_before_complete` helper must not turn a human-operation dispatcher into
/// a model loop merely because it has the old broad suffix. Unresolved calls
/// keep the original conservative marker rule.
fn without_resolved_model_names(body: &str, receiver: &str, graph: &MethodGraph) -> String {
let mut edits = Vec::new();
for (at, _) in body.match_indices('(') {
let before = body[..at].trim_end();
let start = before
.rfind(|c: char| !is_ident_char(c))
.map_or(0, |i| i + 1);
let name = &before[start..];
if is_model_call(name)
&& method_receiver(body, start) == Some("self")
&& graph.contains_key(&(receiver.to_string(), name.to_string()))
{
edits.push((start, start + name.len()));
}
}
let mut result = body.as_bytes().to_vec();
for (start, end) in edits {
result[start..end].fill(b' ');
}
String::from_utf8(result).unwrap()
}
fn resolved_loop_body(
body: &str,
receiver: &str,
graph: &MethodGraph,
delegates: &BTreeSet<(String, String)>,
) -> (String, Vec<String>) {
let mut resolved = without_resolved_model_names(body, receiver, graph);
let mut pending = vec![body.to_string()];
let mut visited = BTreeSet::new();
let mut ambiguous = Vec::new();
while let Some(current) = pending.pop() {
for (at, _) in current.match_indices('(') {
let before = current[..at].trim_end();
let name_start = before
.rfind(|c: char| !is_ident_char(c))
.map_or(0, |i| i + 1);
let name = &before[name_start..];
if method_receiver(&current, name_start) != Some("self")
|| !visited.insert(name.to_string())
{
continue;
}
let key = (receiver.to_string(), name.to_string());
if delegates.contains(&key) {
continue;
}
let Some(bodies) = graph.get(&key) else {
continue; // external/parent methods cannot be invented here
};
if bodies.len() != 1 {
ambiguous.push(format!("{receiver}::{name}"));
}
// Conditional platform impls may have several definitions. Union
// their possible effects conservatively, then refuse ambiguity
// only if those effects could constitute a turn loop.
for body in bodies {
let contribution = without_owned_loops(body);
resolved.push_str(&without_resolved_model_names(
&contribution,
receiver,
graph,
));
pending.push(contribution);
}
}
}
(resolved, ambiguous)
}
/// Entry dispatchers that call an independently owned turn loop are
/// delegation, not phases. Find those owners by the same shape rule, then
/// propagate only this boundary through actual same-receiver call edges.
fn delegated_loop_boundaries(graph: &MethodGraph) -> BTreeSet<(String, String)> {
let mut boundaries = BTreeSet::new();
for (key, bodies) in graph {
for body in bodies {
let chars: Vec<char> = body.chars().collect();
for (_, open, close) in loop_bodies(&chars) {
let direct: String = chars[open..=close].iter().collect();
let (resolved, _) = resolved_loop_body(&direct, &key.0, graph, &BTreeSet::new());
if drives_a_model(&resolved)
&& dispatches_tool_calls(&resolved)
&& assembles_prompt_history(&resolved)
{
boundaries.insert(key.clone());
}
}
}
}
loop {
let before = boundaries.len();
for (key, bodies) in graph {
if bodies.iter().any(|body| {
called_identifiers(body).any(|name| {
body.match_indices(name)
.any(|(at, _)| method_receiver(body, at) == Some("self"))
&& boundaries.contains(&(key.0.clone(), name.to_string()))
})
}) {
boundaries.insert(key.clone());
}
}
if boundaries.len() == before {
return boundaries;
}
}
}
/// Find every turn loop by shape, following only resolved same-receiver local
/// calls in its declared source closure. No method name is an exemption.
fn detect_turn_loops_with_graph(
src: &str,
rel_path: &str,
graph: &MethodGraph,
) -> Vec<TurnLoopSite> {
let prepared = sanitize(&strip_cfg_test(src));
let methods = local_methods(&prepared);
let delegates = delegated_loop_boundaries(graph);
let chars: Vec<char> = prepared.chars().collect();
let mut sites: Vec<TurnLoopSite> = Vec::new();
for (keyword, open, close) in loop_bodies(&chars) {
let body: String = chars[open..=close.max(open)].iter().collect();
if !drives_a_model(&body) {
continue;
}
if !dispatches_tool_calls(&body) {
continue;
}
if !assembles_prompt_history(&body) {
let direct: String = chars[open..=close.max(open)].iter().collect();
let (body, ambiguous) = methods
.iter()
.find(|m| m.open <= open && close <= m.close)
.map_or_else(
|| (direct.clone(), Vec::new()),
|m| resolved_loop_body(&direct, &m.receiver, graph, &delegates),
);
if !(drives_a_model(&body)
&& dispatches_tool_calls(&body)
&& assembles_prompt_history(&body))
{
continue;
}
assert!(
ambiguous.is_empty(),
"ambiguous local turn-loop phase resolution: {ambiguous:?}"
);
let owner = enclosing_fn(&chars, open);
let line = chars[..open].iter().filter(|c| **c == '\n').count() + 1;
// One finding per (file, enclosing function): nested loops inside one
// turn loop are the same turn loop.
if sites.iter().any(|s| s.owner == owner) {
continue;
}
@@ -528,6 +788,71 @@ fn detect_turn_loops(src: &str, rel_path: &str) -> Vec<TurnLoopSite> {
sites
}
fn detect_turn_loops(src: &str, rel_path: &str) -> Vec<TurnLoopSite> {
detect_turn_loops_with_graph(src, rel_path, &method_graph(&[src.to_string()]))
}
/// Follow ordinary literal out-of-line modules, not every file in a directory.
/// This is a finite local closure. Test-only declarations are blanked first;
/// missing declared files fail instead of silently dropping a phase.
fn local_module_sources(file: &Path, seen: &mut BTreeSet<PathBuf>, out: &mut Vec<String>) {
let file = file.canonicalize().expect("declared module source exists");
if !seen.insert(file.clone()) {
return;
}
let source = std::fs::read_to_string(&file).expect("read declared module source");
let prepared = sanitize(&strip_cfg_test(&source));
let base = if matches!(
file.file_name().and_then(|n| n.to_str()),
Some("lib.rs" | "main.rs" | "mod.rs")
) {
file.parent().unwrap().to_path_buf()
} else {
file.with_extension("")
};
for (at, _) in prepared.match_indices("mod ") {
if at > 0 && is_ident_char(prepared[..at].chars().next_back().unwrap()) {
continue;
}
// Inline-module declarations need their own lexical directory and
// are not folded into this file's local method namespace.
if prepared[..at].chars().fold(0isize, |depth, ch| match ch {
'{' => depth + 1,
'}' => depth - 1,
_ => depth,
}) != 0
{
continue;
}
let rest = prepared[at + 4..].trim_start();
let end = rest.find(|c: char| !is_ident_char(c)).unwrap_or(rest.len());
if end == 0 || !rest[end..].trim_start().starts_with(';') {
continue;
}
// An explicit path is not guessed as a conventional module. Leave it
// unresolved so the required owner's stale-entry assertion can fail.
let line_prefix = prepared[..at]
.rsplit_once([';', '{', '}'])
.map_or(&prepared[..at], |(_, p)| p);
if line_prefix.contains("path") {
continue;
}
let named = base.join(format!("{}.rs", &rest[..end]));
let directory = base.join(&rest[..end]).join("mod.rs");
let child = match (named.is_file(), directory.is_file()) {
(true, false) => named,
(false, true) => directory,
_ => panic!(
"missing or ambiguous declared module {} in {}",
&rest[..end],
file.display()
),
};
local_module_sources(&child, seen, out);
}
out.push(source);
}
// ---------------------------------------------------------------------------
// Source discovery
// ---------------------------------------------------------------------------
@@ -616,7 +941,27 @@ fn workspace_declares_exactly_one_turn_loop() {
continue;
};
scanned += 1;
sites.extend(detect_turn_loops(&text, &rel_slash(&root, file)));
let prepared = sanitize(&strip_cfg_test(&text));
let chars: Vec<char> = prepared.chars().collect();
let needs_local_closure = loop_bodies(&chars).iter().any(|(_, open, close)| {
let body: String = chars[*open..=*close].iter().collect();
body.contains("self")
});
if needs_local_closure {
let mut sources = Vec::new();
local_module_sources(file, &mut BTreeSet::new(), &mut sources);
let resolved = std::panic::catch_unwind(|| {
detect_turn_loops_with_graph(
&text,
&rel_slash(&root, file),
&method_graph(&sources),
)
})
.unwrap_or_else(|_| panic!("local turn-loop resolution failed in {}", file.display()));
sites.extend(resolved);
} else {
sites.extend(detect_turn_loops(&text, &rel_slash(&root, file)));
}
}
assert!(
scanned > 100,
@@ -787,3 +1132,222 @@ fn core_does_not_reintroduce_a_placeholder_engine_module() {
second `engine`."
);
}
#[test]
fn detector_follows_resolved_phases_across_declared_modules() {
let root = r#"impl DifferentName { async fn outer(&mut self) {
loop { self.request_phase().await; self.apply_phase().await; }
} }"#;
let request = r#"use super::*; impl DifferentName { async fn request_phase(&mut self) {
messages.push(input); client.create_message_stream(request).await;
} }"#;
let apply = r#"use super::*; impl DifferentName { async fn apply_phase(&mut self) {
self.execute_tool_calls(calls).await;
} }"#;
let graph = method_graph(&[request.into(), apply.into(), root.into()]);
let sites = detect_turn_loops_with_graph(root, "arbitrary.rs", &graph);
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].owner, "outer");
// A missing phase cannot produce a green approximation of the owner.
let missing = method_graph(&[request.into(), root.into()]);
assert!(detect_turn_loops_with_graph(root, "arbitrary.rs", &missing).is_empty());
}
#[test]
fn detector_local_call_cycles_are_finite_and_do_not_join_unrelated_impls() {
let src = r#"
impl A {
async fn renamed(&mut self) { loop { self.first().await; } }
async fn first(&mut self) { self.second().await; }
async fn second(&mut self) { self.first().await;
client.create_message_stream(req).await;
self.execute_tool_calls(calls).await; messages.push(msg);
}
}
impl B { fn first(&mut self) { history.push(msg); } }
"#;
let sites = detect_turn_loops(src, "cycles.rs");
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].owner, "renamed");
let unrelated = r#"
impl A { fn outer(&mut self) { loop { self.first(); } }
fn first(&mut self) { messages.push(msg); } }
impl B { fn first(&mut self) {
client.create_message_stream(req); execute_tool_calls(calls);
} }
"#;
assert!(detect_turn_loops(unrelated, "unrelated.rs").is_empty());
}
#[test]
fn detector_does_not_hide_two_phased_owners_or_a_foreign_inline_loop() {
let src = r#"impl Owner {
fn one(&mut self) { loop { self.model(); self.results(); } }
fn two(&mut self) { loop { self.model(); self.results(); } }
fn model(&mut self) { client.create_message_stream(req); }
fn results(&mut self) { execute_tool_calls(calls); history.push(msg); }
}
fn foreign() { loop { client.create_message_stream(req);
execute_tool_calls(calls); history.push(msg); } }
"#;
let sites = detect_turn_loops(src, "more_than_one.rs");
let owners: BTreeSet<&str> = sites.iter().map(|s| s.owner.as_str()).collect();
assert_eq!(owners, BTreeSet::from(["one", "two", "foreign"]));
}
#[test]
fn detector_does_not_transfer_a_delegated_loop_to_its_caller() {
let src = r#"impl Owner {
fn transport(&mut self) { loop { self.other_owner(); } }
fn other_owner(&mut self) { loop { self.request(); self.results(); } }
fn request(&mut self) { client.create_message_stream(req); }
fn results(&mut self) { execute_tool_calls(calls); history.push(msg); }
}"#;
let sites = detect_turn_loops(src, "delegated.rs");
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].owner, "other_owner");
}
#[test]
fn detector_ignores_test_only_effects_and_refuses_ambiguous_methods() {
let root = r#"impl Owner { fn outer(&mut self) { loop { self.phase(); } } }
#[cfg(test)] impl Owner { fn phase(&mut self) {
client.create_message_stream(req); execute_tool_calls(calls); messages.push(msg);
} }"#;
assert!(detect_turn_loops(root, "tests.rs").is_empty());
let one = "use super::*; impl Owner { fn phase(&mut self) { client.create_message_stream(req); execute_tool_calls(calls); messages.push(msg); } }";
let graph = method_graph(&[one.into(), one.into(), root.into()]);
assert!(
std::panic::catch_unwind(|| detect_turn_loops_with_graph(root, "ambiguous.rs", &graph))
.is_err()
);
}
#[test]
fn local_module_closure_reads_only_declared_production_sources() {
let dir = std::env::temp_dir().join(format!(
"cw-turn-phases-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(dir.join("outer")).unwrap();
let root = dir.join("outer.rs");
std::fs::write(&root, "mod phase; #[cfg(test)] mod absent; impl Owner { fn outer(&mut self) { loop { self.phase(); } } }").unwrap();
std::fs::write(dir.join("outer/phase.rs"), "use super::*; impl Owner { fn phase(&mut self) { client.create_message_stream(req); execute_tool_calls(calls); messages.push(msg); } }").unwrap();
std::fs::write(
dir.join("outer/undeclared.rs"),
"impl Owner { fn phase(&mut self) { } }",
)
.unwrap();
let mut sources = Vec::new();
local_module_sources(&root, &mut BTreeSet::new(), &mut sources);
assert_eq!(sources.len(), 2);
assert_eq!(
detect_turn_loops_with_graph(&sources[1], "outer.rs", &method_graph(&sources)).len(),
1
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn gated_fields_and_initializers_do_not_unbalance_the_local_graph() {
let src = r#"struct Owner {
#[cfg(test)] hidden: std::collections::BTreeMap<String, String>,
actual: bool,
}
impl Owner {
fn new() -> Self { Self { #[cfg(test)] hidden: value, actual: true } }
#[cfg(test)] fn fixture(&self, first: bool, second: bool) {
let text = "{not a brace}";
}
fn outer(&mut self) { loop { self.phase(); } }
fn phase(&mut self) { client.create_message_stream(req); execute_tool_calls(calls); history.push(msg); }
}"#;
let sites = detect_turn_loops(src, "field.rs");
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].owner, "outer");
}
#[test]
fn resolved_completion_named_snapshot_helper_is_not_a_model_call() {
let src = r#"impl Owner {
fn operations(&mut self) { loop { self.snapshot_before_complete(); self.results(); } }
fn snapshot_before_complete(&mut self) { persist_snapshot(); }
fn results(&mut self) { execute_tool_calls(calls); history.push(msg); }
}"#;
assert!(detect_turn_loops(src, "completion.rs").is_empty());
// An unresolved provider helper remains conservatively visible, and a
// locally resolved provider helper is followed to its real client call.
let unknown = src.replace(
"fn snapshot_before_complete(&mut self) { persist_snapshot(); }",
"",
);
assert_eq!(detect_turn_loops(&unknown, "unknown.rs").len(), 1);
let actual = src.replace("persist_snapshot();", "client.create_message_stream(req);");
assert_eq!(detect_turn_loops(&actual, "actual.rs").len(), 1);
}
#[test]
fn same_named_child_type_or_unrelated_import_cannot_impersonate_a_phase() {
let root = "impl Owner { fn outer(&mut self) { loop { self.phase(); } } }";
let effects = "impl Owner { fn phase(&mut self) { client.create_message_stream(req); execute_tool_calls(calls); history.push(msg); } }";
let shadow = format!("use super::*; struct Owner; {effects}");
let unrelated = format!("use unrelated::Owner; {effects}");
for child in [shadow, unrelated, effects.to_string()] {
let graph = method_graph(&[child, root.to_string()]);
assert!(detect_turn_loops_with_graph(root, "owner.rs", &graph).is_empty());
}
// False-green counterpart: an unrelated child's non-provider method
// cannot erase a real unresolved model marker on the root receiver.
let actual_root = "impl Owner { fn outer(&mut self) { loop { self.create_message_stream(req); execute_tool_calls(calls); history.push(msg); } } }";
for child in [
"use super::*; struct Owner; impl Owner { fn create_message_stream(&mut self) { } }",
"use unrelated::Owner; impl Owner { fn create_message_stream(&mut self) { } }",
] {
let graph = method_graph(&[child.to_string(), actual_root.to_string()]);
assert_eq!(
detect_turn_loops_with_graph(actual_root, "actual.rs", &graph).len(),
1
);
}
for import in ["use super::*;", "use super::Owner;"] {
let child = format!("{import} {effects}");
let graph = method_graph(&[child, root.to_string()]);
assert_eq!(
detect_turn_loops_with_graph(root, "owner.rs", &graph).len(),
1
);
}
}
#[test]
fn rlm_cannot_reintroduce_client_only_authority_or_a_direct_provider_producer() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("tui/src");
for path in [
"rlm/bridge.rs",
"rlm/turn.rs",
"tools/rlm.rs",
"core/engine/rlm_host.rs",
] {
let source = std::fs::read_to_string(root.join(path)).unwrap();
let production = sanitize(&strip_cfg_test(&source));
for retired in [
"ModelClientRlmAdapter",
"RlmLlmClient",
"run_rlm_turn_impl",
"create_message_boxed",
"create_message_stream",
] {
assert!(
!production.contains(retired),
"{path} reintroduced retired model authority {retired}"
);
}
}
}
+15 -14
View File
@@ -664,7 +664,7 @@
"CmdHotbarDescription": "Obre la configuració de Hotbar",
"CmdSetupDescription": "Obre el Setup amb la constitució primer",
"CmdSubagentsDescription": "Drecera de compatibilitat per a /fleet workers (subagents de la sessió actual)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Activa o desactiva l'assessor en segon pla per a aquesta sessió",
"CmdSystemDescription": "Mostra el prompt de sistema actual",
"CmdTaskDescription": "Gestiona les tasques en segon pla",
"CmdTokensDescription": "Mostra l'ús de tokens de la sessió",
@@ -1585,16 +1585,16 @@
"CtxInspUsedTokens": "~{used}/{max} tokens",
"CtxInspAutoCompactAt": "autocompacta al {threshold}%",
"CtxInspRowTokens": "{tokens} tokens · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "compactació",
"CtxInspRowAnchors": "àncores",
"CtxInspCompactionNever": "cap compactació en aquesta sessió",
"CtxInspCompactionDetail": "{path}. {before} → {after} missatges. Última ronda: {round} missatges, {tools} resultats d'eina{assistant}.",
"CtxInspCompactionRestored": "hi ha un punt de control. Última ronda: {round} missatges, {tools} resultats d'eina{assistant}.",
"CtxInspCompactionPathSummary": "passada de resum",
"CtxInspCompactionPathPrune": "només poda",
"CtxInspCompactionAssistantKept": ", resposta de l'assistent conservada",
"CtxInspAnchorsNone": "cap fitxer /anchor",
"CtxInspAnchorsPresent": "/anchor fixat ({chars} caràcters)",
"RouteSurfaceTitle": "ruta · {view}",
"RouteBrowseCatalog": "explora el catàleg",
"RouteActionType": "Escriu",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Es mostren les sessions arxivades",
"SessionsHidingArchived": "S'amaguen les sessions arxivades",
"SessionsArchivedCompact": "arxivada",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Obre el detall del raonament del torn seleccionat o actual",
"KbTurnInspector": "Obre l'inspector del torn",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Obre l'inspector del torn sencer",
"CmdAutomationDescription": "Gestiona les automatitzacions programades duradores — el subcomandament list obre el gestor d'automatitzacions",
"AutomationUsage": "Ús: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "El gestor d’automatitzacions no està disponible en aquesta sessió.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "S'ha substituït l'inici de sessió anterior de Codewhale a {provider} ({account}).",
"AuthReplacedPreviousSignIn": "S'ha substituït l'inici de sessió anterior de Codewhale a {provider}.",
"AuthSameAccountAsBefore": "És el mateix compte que abans; per fer servir un altre compte de {provider}, tanca'n la sessió al navegador (o fes servir una finestra privada) i torna a iniciar la sessió.",
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN o CODEX_ACCESS_TOKEN està definit en aquest entorn i té prioritat sobre aquest inici de sessió; les sol·licituds continuaran fent servir el compte d'aquest testimoni fins que el suprimeixis."
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN o CODEX_ACCESS_TOKEN està definit en aquest entorn i té prioritat sobre aquest inici de sessió; les sol·licituds continuaran fent servir el compte d'aquest testimoni fins que el suprimeixis.",
"PinnedPromptJumpToMessage": "Ves a aquest missatge"
}
+15 -14
View File
@@ -664,7 +664,7 @@
"CmdHotbarDescription": "Hotbar-Setup öffnen",
"CmdSetupDescription": "Setup mit Verfassung zuerst öffnen",
"CmdSubagentsDescription": "Kompatibilitäts-Kurzbefehl für /fleet workers (Sub-Agenten der aktuellen Sitzung)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Hintergrund-Berater für diese Sitzung ein- oder ausschalten",
"CmdSystemDescription": "Aktuellen Systemprompt anzeigen",
"CmdTaskDescription": "Hintergrundaufgaben verwalten",
"CmdTokensDescription": "Token-Verbrauch der Sitzung anzeigen",
@@ -1585,16 +1585,16 @@
"CtxInspUsedTokens": "~{used}/{max} Tokens",
"CtxInspAutoCompactAt": "Auto-Compact bei {threshold}%",
"CtxInspRowTokens": "{tokens} Tokens · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "Komprimierung",
"CtxInspRowAnchors": "Anker",
"CtxInspCompactionNever": "in dieser Sitzung noch keine Komprimierung",
"CtxInspCompactionDetail": "{path}. {before} → {after} Nachrichten. Letzte Runde: {round} Nachrichten, {tools} Tool-Ergebnisse{assistant}.",
"CtxInspCompactionRestored": "Checkpoint vorhanden. Letzte Runde: {round} Nachrichten, {tools} Tool-Ergebnisse{assistant}.",
"CtxInspCompactionPathSummary": "Zusammenfassungsdurchlauf",
"CtxInspCompactionPathPrune": "nur Kürzen",
"CtxInspCompactionAssistantKept": ", Assistentenantwort behalten",
"CtxInspAnchorsNone": "keine /anchor-Datei",
"CtxInspAnchorsPresent": "/anchor angeheftet ({chars} Zeichen)",
"RouteSurfaceTitle": "Route · {view}",
"RouteBrowseCatalog": "Katalog durchsuchen",
"RouteActionType": "Typ",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Archivierte Sitzungen werden angezeigt",
"SessionsHidingArchived": "Archivierte Sitzungen ausgeblendet",
"SessionsArchivedCompact": "archiviert",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Reasoning-Details zum ausgewählten oder aktuellen Zug öffnen",
"KbTurnInspector": "Turn Inspector öffnen",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Inspektor für den gesamten Zug öffnen",
"CmdAutomationDescription": "Dauerhafte geplante Automatisierungen verwalten — der Unterbefehl list öffnet den Automatisierungs-Manager",
"AutomationUsage": "Verwendung: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Der Automatisierungsmanager ist in dieser Sitzung nicht verfügbar.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "Die vorherige Codewhale-Anmeldung bei {provider} ({account}) wurde ersetzt.",
"AuthReplacedPreviousSignIn": "Die vorherige Codewhale-Anmeldung bei {provider} wurde ersetzt.",
"AuthSameAccountAsBefore": "Das ist dasselbe Konto wie zuvor. Um ein anderes {provider}-Konto zu verwenden, melde dich im Browser davon ab (oder nutze ein privates Fenster) und melde dich erneut an.",
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN oder CODEX_ACCESS_TOKEN ist in dieser Umgebung gesetzt und hat Vorrang vor dieser Anmeldung; Anfragen nutzen weiter das Konto dieses Tokens, bis du die Variable entfernst."
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN oder CODEX_ACCESS_TOKEN ist in dieser Umgebung gesetzt und hat Vorrang vor dieser Anmeldung; Anfragen nutzen weiter das Konto dieses Tokens, bis du die Variable entfernst.",
"PinnedPromptJumpToMessage": "Zu dieser Nachricht springen"
}
+2 -1
View File
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "Replaced the previous Codewhale {provider} sign-in ({account}).",
"AuthReplacedPreviousSignIn": "Replaced the previous Codewhale {provider} sign-in.",
"AuthSameAccountAsBefore": "This is the same account as before; to use another {provider} account, sign out of it in the browser (or use a private window) and sign in again.",
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN or CODEX_ACCESS_TOKEN is set in this environment and outranks this sign-in; requests keep using that token's account until you unset it."
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN or CODEX_ACCESS_TOKEN is set in this environment and outranks this sign-in; requests keep using that token's account until you unset it.",
"PinnedPromptJumpToMessage": "Jump to this message"
}
+15 -14
View File
@@ -668,7 +668,7 @@
"CmdHotbarDescription": "Abrir configuración de Hotbar",
"CmdSetupDescription": "Abrir configuración con constitución primero",
"CmdSubagentsDescription": "Atajo de compatibilidad para /fleet workers (subagentes de la sesión actual)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Activar o desactivar el asesor en segundo plano para esta sesión",
"CmdSystemDescription": "Mostrar el prompt de sistema actual",
"CmdTaskDescription": "Gestionar tareas en segundo plano",
"CmdTokensDescription": "Mostrar el uso de tokens de la sesión",
@@ -1608,16 +1608,16 @@
"CtxInspUsedTokens": "~{used}/{max} tokens",
"CtxInspAutoCompactAt": "auto-compactación al {threshold}%",
"CtxInspRowTokens": "{tokens} tokens · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "compactación",
"CtxInspRowAnchors": "anclas",
"CtxInspCompactionNever": "sin compactación en esta sesión",
"CtxInspCompactionDetail": "{path}. {before} → {after} mensajes. Última ronda: {round} mensajes, {tools} resultados de herramienta{assistant}.",
"CtxInspCompactionRestored": "checkpoint presente. Última ronda: {round} mensajes, {tools} resultados de herramienta{assistant}.",
"CtxInspCompactionPathSummary": "pasada de resumen",
"CtxInspCompactionPathPrune": "solo poda",
"CtxInspCompactionAssistantKept": ", respuesta del asistente conservada",
"CtxInspAnchorsNone": "sin archivo /anchor",
"CtxInspAnchorsPresent": "/anchor fijado ({chars} caracteres)",
"RouteSurfaceTitle": "ruta · {view}",
"RouteBrowseCatalog": "explorar catálogo",
"RouteActionType": "Escribir",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Mostrando sesiones archivadas",
"SessionsHidingArchived": "Ocultando sesiones archivadas",
"SessionsArchivedCompact": "archivada",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Abrir el detalle del razonamiento del turno seleccionado o actual",
"KbTurnInspector": "Abrir Inspector de turno",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Abrir el inspector del turno completo",
"CmdAutomationDescription": "Administrar automatizaciones programadas duraderas — el subcomando list abre el gestor de automatizaciones",
"AutomationUsage": "Uso: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "El administrador de automatizaciones no está disponible en esta sesión.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "Se reemplazó el inicio de sesión anterior de Codewhale en {provider} ({account}).",
"AuthReplacedPreviousSignIn": "Se reemplazó el inicio de sesión anterior de Codewhale en {provider}.",
"AuthSameAccountAsBefore": "Es la misma cuenta que antes; para usar otra cuenta de {provider}, cierra sesión en el navegador (o usa una ventana privada) y vuelve a iniciar sesión.",
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN o CODEX_ACCESS_TOKEN está definido en este entorno y tiene prioridad sobre este inicio de sesión; las solicitudes seguirán usando la cuenta de ese token hasta que lo quites."
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN o CODEX_ACCESS_TOKEN está definido en este entorno y tiene prioridad sobre este inicio de sesión; las solicitudes seguirán usando la cuenta de ese token hasta que lo quites.",
"PinnedPromptJumpToMessage": "Ir a este mensaje"
}
+15 -14
View File
@@ -664,7 +664,7 @@
"CmdHotbarDescription": "Ouvrir la configuration Hotbar",
"CmdSetupDescription": "Ouvrir la configuration axée constitution",
"CmdSubagentsDescription": "Raccourci de compatibilité pour /fleet workers (sous-agents de la session actuelle)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Activer ou désactiver le conseiller en arrière-plan pour cette session",
"CmdSystemDescription": "Afficher le prompt système actuel",
"CmdTaskDescription": "Gérer les tâches en arrière-plan",
"CmdTokensDescription": "Afficher l'usage de tokens de la session",
@@ -1585,16 +1585,16 @@
"CtxInspUsedTokens": "~{used}/{max} tokens",
"CtxInspAutoCompactAt": "auto-compaction à {threshold} %",
"CtxInspRowTokens": "{tokens} tokens · {percent} %",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "compression",
"CtxInspRowAnchors": "ancres",
"CtxInspCompactionNever": "aucune compression dans cette session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Dernier tour : {round} messages, {tools} résultats d'outil{assistant}.",
"CtxInspCompactionRestored": "point de contrôle présent. Dernier tour : {round} messages, {tools} résultats d'outil{assistant}.",
"CtxInspCompactionPathSummary": "passe de résumé",
"CtxInspCompactionPathPrune": "élagage seul",
"CtxInspCompactionAssistantKept": ", réponse de l'assistant conservée",
"CtxInspAnchorsNone": "aucun fichier /anchor",
"CtxInspAnchorsPresent": "/anchor épinglé ({chars} caractères)",
"RouteSurfaceTitle": "route · {view}",
"RouteBrowseCatalog": "parcourir le catalogue",
"RouteActionType": "Saisir",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Sessions archivées affichées",
"SessionsHidingArchived": "Sessions archivées masquées",
"SessionsArchivedCompact": "archivée",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Ouvrir le détail du raisonnement du tour sélectionné ou en cours",
"KbTurnInspector": "Ouvrir l'inspecteur de tour",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Ouvrir l'inspecteur du tour complet",
"CmdAutomationDescription": "Gérer les automatisations planifiées durables — la sous-commande list ouvre le gestionnaire d'automatisations",
"AutomationUsage": "Utilisation : /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Le gestionnaire d’automatisations n’est pas disponible dans cette session.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "La connexion Codewhale précédente à {provider} ({account}) a été remplacée.",
"AuthReplacedPreviousSignIn": "La connexion Codewhale précédente à {provider} a été remplacée.",
"AuthSameAccountAsBefore": "C'est le même compte qu'avant ; pour utiliser un autre compte {provider}, déconnectez-vous-en dans le navigateur (ou utilisez une fenêtre privée), puis reconnectez-vous.",
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN ou CODEX_ACCESS_TOKEN est défini dans cet environnement et prime sur cette connexion ; les requêtes continuent d'utiliser le compte de ce jeton tant que vous ne l'avez pas supprimé."
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN ou CODEX_ACCESS_TOKEN est défini dans cet environnement et prime sur cette connexion ; les requêtes continuent d'utiliser le compte de ce jeton tant que vous ne l'avez pas supprimé.",
"PinnedPromptJumpToMessage": "Aller à ce message"
}
+15 -14
View File
@@ -664,7 +664,7 @@
"CmdHotbarDescription": "Hotbar सेटअप खोलें",
"CmdSetupDescription": "संविधान-प्रथम सेटअप खोलें",
"CmdSubagentsDescription": "/fleet workers के लिए संगतता शॉर्टकट (वर्तमान सत्र के उप-एजेंट)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "इस सत्र के लिए बैकग्राउंड सलाहकार चालू/बंद करें",
"CmdSystemDescription": "वर्तमान सिस्टम प्रॉम्प्ट दिखाएँ",
"CmdTaskDescription": "बैकग्राउंड कार्य प्रबंधित करें",
"CmdTokensDescription": "सत्र का टोकन उपयोग दिखाएँ",
@@ -1585,16 +1585,16 @@
"CtxInspUsedTokens": "~{used}/{max} टोकन",
"CtxInspAutoCompactAt": "{threshold}% पर ऑटो-कम्पैक्ट",
"CtxInspRowTokens": "{tokens} टोकन · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "संपीड़न",
"CtxInspRowAnchors": "एंकर",
"CtxInspCompactionNever": "इस सत्र में अभी तक संपीड़न नहीं हुआ",
"CtxInspCompactionDetail": "{path}। {before} → {after} संदेश। पिछला राउंड: {round} संदेश, {tools} टूल परिणाम{assistant}।",
"CtxInspCompactionRestored": "चेकपॉइंट मौजूद है। पिछला राउंड: {round} संदेश, {tools} टूल परिणाम{assistant}।",
"CtxInspCompactionPathSummary": "सारांश पास",
"CtxInspCompactionPathPrune": "केवल छँटाई",
"CtxInspCompactionAssistantKept": ", असिस्टेंट का उत्तर रखा गया",
"CtxInspAnchorsNone": "कोई /anchor फ़ाइल नहीं",
"CtxInspAnchorsPresent": "/anchor पिन किया गया ({chars} अक्षर)",
"RouteSurfaceTitle": "रूट · {view}",
"RouteBrowseCatalog": "कैटलॉग ब्राउज़ करें",
"RouteActionType": "टाइप करें",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "संग्रहित सत्र दिखाए जा रहे हैं",
"SessionsHidingArchived": "संग्रहित सत्र छिपाए गए",
"SessionsArchivedCompact": "संग्रहित",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "चुने गए या मौजूदा टर्न का रीज़निंग विवरण खोलें",
"KbTurnInspector": "Turn Inspector खोलें",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "पूरे टर्न का इंस्पेक्टर खोलें",
"CmdAutomationDescription": "स्थायी अनुसूचित स्वचालन प्रबंधित करें — list उपकमांड स्वचालन प्रबंधक खोलता है",
"AutomationUsage": "उपयोग: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "इस सत्र में स्वचालन प्रबंधक उपलब्ध नहीं है।",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "पिछला Codewhale {provider} साइन-इन ({account}) बदल दिया गया।",
"AuthReplacedPreviousSignIn": "पिछला Codewhale {provider} साइन-इन बदल दिया गया।",
"AuthSameAccountAsBefore": "यह पहले वाला ही खाता है; किसी दूसरे {provider} खाते का उपयोग करने के लिए ब्राउज़र में इससे साइन आउट करें (या निजी विंडो खोलें) और फिर से साइन इन करें।",
"AuthEnvTokenOutranksSignIn": "इस परिवेश में OPENAI_CODEX_ACCESS_TOKEN या CODEX_ACCESS_TOKEN सेट है और यह इस साइन-इन पर प्राथमिकता रखता है; जब तक आप इसे हटा नहीं देते, अनुरोध उसी टोकन के खाते का उपयोग करते रहेंगे।"
"AuthEnvTokenOutranksSignIn": "इस परिवेश में OPENAI_CODEX_ACCESS_TOKEN या CODEX_ACCESS_TOKEN सेट है और यह इस साइन-इन पर प्राथमिकता रखता है; जब तक आप इसे हटा नहीं देते, अनुरोध उसी टोकन के खाते का उपयोग करते रहेंगे।",
"PinnedPromptJumpToMessage": "इस संदेश पर जाएँ"
}
+15 -14
View File
@@ -664,7 +664,7 @@
"CmdHotbarDescription": "Buka penyiapan Hotbar",
"CmdSetupDescription": "Buka penyiapan konstitusi-dulu",
"CmdSubagentsDescription": "Pintasan kompatibilitas untuk /fleet workers (subagen sesi saat ini)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Aktifkan atau nonaktifkan penasihat latar belakang untuk sesi ini",
"CmdSystemDescription": "Tampilkan prompt sistem saat ini",
"CmdTaskDescription": "Kelola tugas latar belakang",
"CmdTokensDescription": "Tampilkan penggunaan token sesi",
@@ -1585,16 +1585,16 @@
"CtxInspUsedTokens": "~{used}/{max} token",
"CtxInspAutoCompactAt": "auto-compact pada {threshold}%",
"CtxInspRowTokens": "{tokens} token · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "pemadatan",
"CtxInspRowAnchors": "jangkar",
"CtxInspCompactionNever": "belum ada pemadatan di sesi ini",
"CtxInspCompactionDetail": "{path}. {before} → {after} pesan. Putaran terakhir: {round} pesan, {tools} hasil tool{assistant}.",
"CtxInspCompactionRestored": "checkpoint tersedia. Putaran terakhir: {round} pesan, {tools} hasil tool{assistant}.",
"CtxInspCompactionPathSummary": "proses ringkasan",
"CtxInspCompactionPathPrune": "hanya pemangkasan",
"CtxInspCompactionAssistantKept": ", respons asisten dipertahankan",
"CtxInspAnchorsNone": "tidak ada file /anchor",
"CtxInspAnchorsPresent": "/anchor disematkan ({chars} karakter)",
"RouteSurfaceTitle": "rute · {view}",
"RouteBrowseCatalog": "telusuri katalog",
"RouteActionType": "Jenis",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Menampilkan sesi terarsip",
"SessionsHidingArchived": "Menyembunyikan sesi terarsip",
"SessionsArchivedCompact": "diarsipkan",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Buka detail penalaran untuk giliran terpilih atau saat ini",
"KbTurnInspector": "Buka Turn Inspector",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Buka inspektor untuk seluruh giliran",
"CmdAutomationDescription": "Kelola otomatisasi terjadwal yang tahan lama — subperintah list membuka pengelola otomatisasi",
"AutomationUsage": "Penggunaan: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Pengelola otomatisasi tidak tersedia dalam sesi ini.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "Menggantikan login Codewhale {provider} sebelumnya ({account}).",
"AuthReplacedPreviousSignIn": "Menggantikan login Codewhale {provider} sebelumnya.",
"AuthSameAccountAsBefore": "Ini akun yang sama seperti sebelumnya; untuk memakai akun {provider} lain, keluar dari akun ini di browser (atau gunakan jendela pribadi) lalu masuk lagi.",
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN atau CODEX_ACCESS_TOKEN diatur di lingkungan ini dan lebih diutamakan daripada login ini; permintaan tetap memakai akun token tersebut sampai Anda menghapusnya."
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN atau CODEX_ACCESS_TOKEN diatur di lingkungan ini dan lebih diutamakan daripada login ini; permintaan tetap memakai akun token tersebut sampai Anda menghapusnya.",
"PinnedPromptJumpToMessage": "Lompat ke pesan ini"
}
+15 -14
View File
@@ -668,7 +668,7 @@
"CmdHotbarDescription": "Hotbar設定を開く",
"CmdSetupDescription": "憲法ファーストのセットアップを開く",
"CmdSubagentsDescription": "/fleet workers への互換ショートカット(現在のセッションのサブエージェント)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "このセッションのバックグラウンドアドバイザーのオン・オフを切り替え",
"CmdSystemDescription": "現在のシステムプロンプトを表示",
"CmdTaskDescription": "バックグラウンドタスクを管理",
"CmdTokensDescription": "セッションのトークン使用量を表示",
@@ -1608,16 +1608,16 @@
"CtxInspUsedTokens": "~{used}/{max} トークン",
"CtxInspAutoCompactAt": "{threshold}% で自動圧縮",
"CtxInspRowTokens": "{tokens} トークン · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "圧縮",
"CtxInspRowAnchors": "アンカー",
"CtxInspCompactionNever": "このセッションではまだ圧縮していません",
"CtxInspCompactionDetail": "{path}。{before} → {after} 件のメッセージ。直前のラウンド: {round} 件のメッセージ、{tools} 件のツール結果{assistant}。",
"CtxInspCompactionRestored": "チェックポイントあり。直前のラウンド: {round} 件のメッセージ、{tools} 件のツール結果{assistant}。",
"CtxInspCompactionPathSummary": "要約パス",
"CtxInspCompactionPathPrune": "刈り込みのみ",
"CtxInspCompactionAssistantKept": "、アシスタントの応答を保持",
"CtxInspAnchorsNone": "/anchor ファイルなし",
"CtxInspAnchorsPresent": "/anchor をピン留め済み({chars} 文字)",
"RouteSurfaceTitle": "ルート · {view}",
"RouteBrowseCatalog": "カタログを閲覧",
"RouteActionType": "入力",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "アーカイブ済みセッションを表示中",
"SessionsHidingArchived": "アーカイブ済みセッションを非表示",
"SessionsArchivedCompact": "アーカイブ済み",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "選択中または現在のターンの推論の詳細を開く",
"KbTurnInspector": "ターン インスペクターを開く",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "ターン全体のインスペクターを開く",
"CmdAutomationDescription": "永続的なスケジュール自動化を管理 — list サブコマンドで自動化マネージャーを開く",
"AutomationUsage": "使い方: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "このセッションでは自動化マネージャーを利用できません。",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "以前の Codewhale の {provider} サインイン({account})を置き換えました。",
"AuthReplacedPreviousSignIn": "以前の Codewhale の {provider} サインインを置き換えました。",
"AuthSameAccountAsBefore": "以前と同じアカウントです。別の {provider} アカウントを使うには、ブラウザーでこのアカウントからサインアウトして(またはプライベートウィンドウを使って)、もう一度サインインしてください。",
"AuthEnvTokenOutranksSignIn": "この環境には OPENAI_CODEX_ACCESS_TOKEN または CODEX_ACCESS_TOKEN が設定されており、このサインインより優先されます。設定を解除するまで、リクエストはそのトークンのアカウントを使い続けます。"
"AuthEnvTokenOutranksSignIn": "この環境には OPENAI_CODEX_ACCESS_TOKEN または CODEX_ACCESS_TOKEN が設定されており、このサインインより優先されます。設定を解除するまで、リクエストはそのトークンのアカウントを使い続けます。",
"PinnedPromptJumpToMessage": "このメッセージへジャンプ"
}
+15 -14
View File
@@ -668,7 +668,7 @@
"CmdHotbarDescription": "핫바 설정을 엽니다",
"CmdSetupDescription": "헌법 우선 설정을 엽니다",
"CmdSubagentsDescription": "/fleet workers용 호환 단축 명령 (현재 세션의 하위 에이전트)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "이 세션의 백그라운드 어드바이저를 켜거나 끕니다",
"CmdSystemDescription": "현재 시스템 프롬프트를 표시합니다",
"CmdTaskDescription": "백그라운드 작업을 관리합니다",
"CmdTokensDescription": "세션 토큰 사용량을 표시합니다",
@@ -1608,16 +1608,16 @@
"CtxInspUsedTokens": "~{used}/{max} 토큰",
"CtxInspAutoCompactAt": "{threshold}%에서 자동 압축",
"CtxInspRowTokens": "{tokens} 토큰 · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "압축",
"CtxInspRowAnchors": "앵커",
"CtxInspCompactionNever": "이 세션에서는 아직 압축하지 않음",
"CtxInspCompactionDetail": "{path}. 메시지 {before}개 → {after}개. 마지막 라운드: 메시지 {round}개, 도구 결과 {tools}개{assistant}.",
"CtxInspCompactionRestored": "체크포인트 있음. 마지막 라운드: 메시지 {round}개, 도구 결과 {tools}개{assistant}.",
"CtxInspCompactionPathSummary": "요약 패스",
"CtxInspCompactionPathPrune": "정리만",
"CtxInspCompactionAssistantKept": ", 어시스턴트 응답 유지",
"CtxInspAnchorsNone": "/anchor 파일 없음",
"CtxInspAnchorsPresent": "/anchor 고정됨 ({chars}자)",
"RouteSurfaceTitle": "라우트 · {view}",
"RouteBrowseCatalog": "카탈로그 탐색",
"RouteActionType": "입력",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "보관된 세션을 표시하는 중",
"SessionsHidingArchived": "보관된 세션을 숨기는 중",
"SessionsArchivedCompact": "보관됨",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "선택한 턴 또는 현재 턴의 추론 세부 정보 열기",
"KbTurnInspector": "턴 인스펙터 열기",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "턴 전체 인스펙터를 엽니다",
"CmdAutomationDescription": "영구 예약 자동화 관리 — list 하위 명령은 자동화 관리자를 엽니다",
"AutomationUsage": "사용법: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "이 세션에서는 자동화 관리자를 사용할 수 없습니다.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "이전 Codewhale {provider} 로그인({account})을 대체했습니다.",
"AuthReplacedPreviousSignIn": "이전 Codewhale {provider} 로그인을 대체했습니다.",
"AuthSameAccountAsBefore": "이전과 같은 계정입니다. 다른 {provider} 계정을 사용하려면 브라우저에서 이 계정에서 로그아웃하거나 시크릿 창을 연 뒤 다시 로그인하세요.",
"AuthEnvTokenOutranksSignIn": "이 환경에 OPENAI_CODEX_ACCESS_TOKEN 또는 CODEX_ACCESS_TOKEN이 설정되어 있어 이 로그인보다 우선합니다. 설정을 해제할 때까지 요청은 해당 토큰의 계정을 계속 사용합니다."
"AuthEnvTokenOutranksSignIn": "이 환경에 OPENAI_CODEX_ACCESS_TOKEN 또는 CODEX_ACCESS_TOKEN이 설정되어 있어 이 로그인보다 우선합니다. 설정을 해제할 때까지 요청은 해당 토큰의 계정을 계속 사용합니다.",
"PinnedPromptJumpToMessage": "이 메시지로 이동"
}
+15 -14
View File
@@ -668,7 +668,7 @@
"CmdHotbarDescription": "Abrir configuração da Hotbar",
"CmdSetupDescription": "Abrir configuração com constituição primeiro",
"CmdSubagentsDescription": "Atalho de compatibilidade para /fleet workers (subagentes da sessão atual)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Ativar ou desativar o consultor em segundo plano nesta sessão",
"CmdSystemDescription": "Exibir o prompt de sistema atual",
"CmdTaskDescription": "Gerenciar tarefas em segundo plano",
"CmdTokensDescription": "Exibir o uso de tokens da sessão",
@@ -1608,16 +1608,16 @@
"CtxInspUsedTokens": "~{used}/{max} tokens",
"CtxInspAutoCompactAt": "auto-compactação em {threshold}%",
"CtxInspRowTokens": "{tokens} tokens · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "compactação",
"CtxInspRowAnchors": "âncoras",
"CtxInspCompactionNever": "nenhuma compactação nesta sessão",
"CtxInspCompactionDetail": "{path}. {before} → {after} mensagens. Última rodada: {round} mensagens, {tools} resultados de ferramenta{assistant}.",
"CtxInspCompactionRestored": "checkpoint presente. Última rodada: {round} mensagens, {tools} resultados de ferramenta{assistant}.",
"CtxInspCompactionPathSummary": "passada de resumo",
"CtxInspCompactionPathPrune": "só poda",
"CtxInspCompactionAssistantKept": ", resposta do assistente mantida",
"CtxInspAnchorsNone": "nenhum arquivo /anchor",
"CtxInspAnchorsPresent": "/anchor fixado ({chars} caracteres)",
"RouteSurfaceTitle": "rota · {view}",
"RouteBrowseCatalog": "navegar no catálogo",
"RouteActionType": "Digite",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Mostrando sessões arquivadas",
"SessionsHidingArchived": "Ocultando sessões arquivadas",
"SessionsArchivedCompact": "arquivada",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Abrir o detalhe do raciocínio do turno selecionado ou atual",
"KbTurnInspector": "Abrir Inspetor de turno",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Abrir o inspetor do turno completo",
"CmdAutomationDescription": "Gerenciar automações agendadas duráveis — o subcomando list abre o gerenciador de automações",
"AutomationUsage": "Uso: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "O gerenciador de automações não está disponível nesta sessão.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "Substituiu o login anterior do Codewhale no {provider} ({account}).",
"AuthReplacedPreviousSignIn": "Substituiu o login anterior do Codewhale no {provider}.",
"AuthSameAccountAsBefore": "Esta é a mesma conta de antes; para usar outra conta do {provider}, saia dela no navegador (ou use uma janela privada) e entre novamente.",
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN ou CODEX_ACCESS_TOKEN está definido neste ambiente e tem prioridade sobre este login; as solicitações continuam usando a conta desse token até você removê-lo."
"AuthEnvTokenOutranksSignIn": "OPENAI_CODEX_ACCESS_TOKEN ou CODEX_ACCESS_TOKEN está definido neste ambiente e tem prioridade sobre este login; as solicitações continuam usando a conta desse token até você removê-lo.",
"PinnedPromptJumpToMessage": "Ir para esta mensagem"
}
+15 -14
View File
@@ -664,7 +664,7 @@
"CmdHotbarDescription": "Открыть настройку Hotbar",
"CmdSetupDescription": "Открыть настройку, начиная с конституции",
"CmdSubagentsDescription": "Совместимый ярлык для /fleet workers (субагенты текущего сеанса)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Включить или выключить фонового советника для этой сессии",
"CmdSystemDescription": "Показать текущий системный промпт",
"CmdTaskDescription": "Управление фоновыми задачами",
"CmdTokensDescription": "Показать использование токенов за сессию",
@@ -1585,16 +1585,16 @@
"CtxInspUsedTokens": "~{used}/{max} токенов",
"CtxInspAutoCompactAt": "автоуплотнение при {threshold}%",
"CtxInspRowTokens": "{tokens} токенов · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "сжатие",
"CtxInspRowAnchors": "якоря",
"CtxInspCompactionNever": "в этой сессии сжатия ещё не было",
"CtxInspCompactionDetail": "{path}. Сообщений: {before} → {after}. Последний раунд: сообщений — {round}, результатов инструментов — {tools}{assistant}.",
"CtxInspCompactionRestored": "есть контрольная точка. Последний раунд: сообщений — {round}, результатов инструментов — {tools}{assistant}.",
"CtxInspCompactionPathSummary": "проход со сводкой",
"CtxInspCompactionPathPrune": "только обрезка",
"CtxInspCompactionAssistantKept": ", ответ ассистента сохранён",
"CtxInspAnchorsNone": "нет файла /anchor",
"CtxInspAnchorsPresent": "/anchor закреплён (символов: {chars})",
"RouteSurfaceTitle": "маршрут · {view}",
"RouteBrowseCatalog": "каталог",
"RouteActionType": "Тип",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Показаны архивные сеансы",
"SessionsHidingArchived": "Архивные сеансы скрыты",
"SessionsArchivedCompact": "в архиве",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Открыть подробности рассуждений для выбранного или текущего хода",
"KbTurnInspector": "Открыть инспектор хода",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Открыть инспектор всего хода",
"CmdAutomationDescription": "Управление постоянными запланированными автоматизациями — подкоманда list открывает менеджер автоматизаций",
"AutomationUsage": "Использование: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Менеджер автоматизаций недоступен в этом сеансе.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "Предыдущий вход Codewhale в {provider} ({account}) заменён.",
"AuthReplacedPreviousSignIn": "Предыдущий вход Codewhale в {provider} заменён.",
"AuthSameAccountAsBefore": "Это тот же аккаунт, что и раньше; чтобы использовать другой аккаунт {provider}, выйдите из него в браузере (или откройте приватное окно) и войдите снова.",
"AuthEnvTokenOutranksSignIn": "В этой среде задана переменная OPENAI_CODEX_ACCESS_TOKEN или CODEX_ACCESS_TOKEN, и она важнее этого входа; запросы будут использовать аккаунт этого токена, пока вы её не удалите."
"AuthEnvTokenOutranksSignIn": "В этой среде задана переменная OPENAI_CODEX_ACCESS_TOKEN или CODEX_ACCESS_TOKEN, и она важнее этого входа; запросы будут использовать аккаунт этого токена, пока вы её не удалите.",
"PinnedPromptJumpToMessage": "Перейти к этому сообщению"
}
+15 -14
View File
@@ -664,7 +664,7 @@
"CmdHotbarDescription": "Відкрити налаштування Hotbar",
"CmdSetupDescription": "Відкрити налаштування, що починається з конституції",
"CmdSubagentsDescription": "Сумісне скорочення для /fleet workers (субагенти поточного сеансу)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Увімкнути або вимкнути фонового радника для цього сеансу",
"CmdSystemDescription": "Показати поточний системний промпт",
"CmdTaskDescription": "Керувати фоновими завданнями",
"CmdTokensDescription": "Показати використання токенів за сеанс",
@@ -1585,16 +1585,16 @@
"CtxInspUsedTokens": "~{used}/{max} токенів",
"CtxInspAutoCompactAt": "автостиснення на {threshold}%",
"CtxInspRowTokens": "{tokens} токенів · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "стиснення",
"CtxInspRowAnchors": "якорі",
"CtxInspCompactionNever": "у цьому сеансі стиснення ще не було",
"CtxInspCompactionDetail": "{path}. Повідомлень: {before} → {after}. Останній раунд: повідомлень — {round}, результатів інструментів — {tools}{assistant}.",
"CtxInspCompactionRestored": "є контрольна точка. Останній раунд: повідомлень — {round}, результатів інструментів — {tools}{assistant}.",
"CtxInspCompactionPathSummary": "прохід зі зведенням",
"CtxInspCompactionPathPrune": "лише обрізання",
"CtxInspCompactionAssistantKept": ", відповідь асистента збережено",
"CtxInspAnchorsNone": "немає файлу /anchor",
"CtxInspAnchorsPresent": "/anchor закріплено (символів: {chars})",
"RouteSurfaceTitle": "маршрут · {view}",
"RouteBrowseCatalog": "переглянути каталог",
"RouteActionType": "Тип",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Показано архівні сеанси",
"SessionsHidingArchived": "Архівні сеанси приховано",
"SessionsArchivedCompact": "в архіві",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Відкрити подробиці міркувань для вибраного або поточного кроку",
"KbTurnInspector": "Відкрити інспектор кроку",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Відкрити інспектор усього кроку",
"CmdAutomationDescription": "Керування постійними запланованими автоматизаціями — підкоманда list відкриває менеджер автоматизацій",
"AutomationUsage": "Використання: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Менеджер автоматизацій недоступний у цьому сеансі.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "Попередній вхід Codewhale у {provider} ({account}) замінено.",
"AuthReplacedPreviousSignIn": "Попередній вхід Codewhale у {provider} замінено.",
"AuthSameAccountAsBefore": "Це той самий обліковий запис, що й раніше; щоб використати інший обліковий запис {provider}, вийдіть із нього в браузері (або відкрийте приватне вікно) й увійдіть знову.",
"AuthEnvTokenOutranksSignIn": "У цьому середовищі задано OPENAI_CODEX_ACCESS_TOKEN або CODEX_ACCESS_TOKEN, і ця змінна має пріоритет над цим входом; запити й далі використовуватимуть обліковий запис цього токена, доки ви її не видалите."
"AuthEnvTokenOutranksSignIn": "У цьому середовищі задано OPENAI_CODEX_ACCESS_TOKEN або CODEX_ACCESS_TOKEN, і ця змінна має пріоритет над цим входом; запити й далі використовуватимуть обліковий запис цього токена, доки ви її не видалите.",
"PinnedPromptJumpToMessage": "Перейти до цього повідомлення"
}
+15 -14
View File
@@ -668,7 +668,7 @@
"CmdHotbarDescription": "Mở thiết lập Hotbar",
"CmdSetupDescription": "Mở thiết lập ưu tiên hiến pháp",
"CmdSubagentsDescription": "Phím tắt tương thích cho /fleet workers (tác nhân phụ của phiên hiện tại)",
"CmdAdvisorDescription": "Toggle background advisor watcher on/off for this session",
"CmdAdvisorDescription": "Bật hoặc tắt cố vấn chạy nền cho phiên này",
"CmdSystemDescription": "Hiển thị prompt hệ thống hiện tại",
"CmdTaskDescription": "Quản lý các nhiệm vụ chạy ngầm",
"CmdTokensDescription": "Hiển thị lượng token đã sử dụng cho phiên",
@@ -1608,16 +1608,16 @@
"CtxInspUsedTokens": "~{used}/{max} token",
"CtxInspAutoCompactAt": "tự động nén ở {threshold}%",
"CtxInspRowTokens": "{tokens} token · {percent}%",
"CtxInspRowCompaction": "compaction",
"CtxInspRowAnchors": "anchors",
"CtxInspCompactionNever": "no compaction this session",
"CtxInspCompactionDetail": "{path}. {before} → {after} messages. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionRestored": "checkpoint present. Last round: {round} messages, {tools} tool results{assistant}.",
"CtxInspCompactionPathSummary": "summary pass",
"CtxInspCompactionPathPrune": "prune-only",
"CtxInspCompactionAssistantKept": ", assistant kept",
"CtxInspAnchorsNone": "no /anchor file",
"CtxInspAnchorsPresent": "/anchor pinned ({chars} characters)",
"CtxInspRowCompaction": "nén",
"CtxInspRowAnchors": "neo",
"CtxInspCompactionNever": "phiên này chưa nén lần nào",
"CtxInspCompactionDetail": "{path}. {before} → {after} tin nhắn. Vòng gần nhất: {round} tin nhắn, {tools} kết quả công cụ{assistant}.",
"CtxInspCompactionRestored": "có checkpoint. Vòng gần nhất: {round} tin nhắn, {tools} kết quả công cụ{assistant}.",
"CtxInspCompactionPathSummary": "bước tóm tắt",
"CtxInspCompactionPathPrune": "chỉ cắt gọn",
"CtxInspCompactionAssistantKept": ", giữ lại phản hồi của trợ lý",
"CtxInspAnchorsNone": "không có tệp /anchor",
"CtxInspAnchorsPresent": "đã ghim /anchor ({chars} ký tự)",
"RouteSurfaceTitle": "tuyến · {view}",
"RouteBrowseCatalog": "duyệt danh mục",
"RouteActionType": "Gõ",
@@ -1896,9 +1896,9 @@
"SessionsShowingArchived": "Đang hiển thị phiên đã lưu trữ",
"SessionsHidingArchived": "Đang ẩn phiên đã lưu trữ",
"SessionsArchivedCompact": "đã lưu trữ",
"KbReasoningDetail": "Open reasoning detail for the selected or current turn",
"KbReasoningDetail": "Mở chi tiết suy luận của lượt đang chọn hoặc lượt hiện tại",
"KbTurnInspector": "Mở Trình kiểm tra lượt (Turn Inspector)",
"CmdTurnInspectDescription": "Open the whole-turn inspector",
"CmdTurnInspectDescription": "Mở trình kiểm tra toàn bộ lượt",
"CmdAutomationDescription": "Quản lý tự động hóa theo lịch bền vững — lệnh con list mở trình quản lý tự động hóa",
"AutomationUsage": "Cách dùng: /automation [list|show <id>|print <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
"AutomationManagerUnavailable": "Trình quản lý tự động hóa không khả dụng trong phiên này.",
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "Đã thay thế lần đăng nhập {provider} trước đó của Codewhale ({account}).",
"AuthReplacedPreviousSignIn": "Đã thay thế lần đăng nhập {provider} trước đó của Codewhale.",
"AuthSameAccountAsBefore": "Đây vẫn là tài khoản cũ; để dùng tài khoản {provider} khác, hãy đăng xuất tài khoản này trong trình duyệt (hoặc dùng cửa sổ riêng tư) rồi đăng nhập lại.",
"AuthEnvTokenOutranksSignIn": "Môi trường này đã đặt OPENAI_CODEX_ACCESS_TOKEN hoặc CODEX_ACCESS_TOKEN, và biến đó được ưu tiên hơn lần đăng nhập này; các yêu cầu vẫn dùng tài khoản của token đó cho đến khi bạn bỏ đặt nó."
"AuthEnvTokenOutranksSignIn": "Môi trường này đã đặt OPENAI_CODEX_ACCESS_TOKEN hoặc CODEX_ACCESS_TOKEN, và biến đó được ưu tiên hơn lần đăng nhập này; các yêu cầu vẫn dùng tài khoản của token đó cho đến khi bạn bỏ đặt nó.",
"PinnedPromptJumpToMessage": "Nhảy đến tin nhắn này"
}
+2 -1
View File
@@ -2446,5 +2446,6 @@
"AuthReplacedPreviousSignInAs": "已替换之前的 Codewhale {provider} 登录({account})。",
"AuthReplacedPreviousSignIn": "已替换之前的 Codewhale {provider} 登录。",
"AuthSameAccountAsBefore": "这与之前是同一个账户;如需使用其他 {provider} 账户,请先在浏览器中退出该账户(或使用无痕窗口),然后重新登录。",
"AuthEnvTokenOutranksSignIn": "此环境中设置了 OPENAI_CODEX_ACCESS_TOKEN 或 CODEX_ACCESS_TOKEN,其优先级高于本次登录;在取消设置之前,请求将继续使用该令牌对应的账户。"
"AuthEnvTokenOutranksSignIn": "此环境中设置了 OPENAI_CODEX_ACCESS_TOKEN 或 CODEX_ACCESS_TOKEN,其优先级高于本次登录;在取消设置之前,请求将继续使用该令牌对应的账户。",
"PinnedPromptJumpToMessage": "跳转到这条消息"
}
+2 -1
View File
@@ -2446,5 +2446,6 @@
"AuthSameAccountAsBefore": "這與先前是同一個帳戶;若要使用其他 {provider} 帳戶,請先在瀏覽器中登出該帳戶(或使用無痕視窗),然後重新登入。",
"AuthEnvTokenOutranksSignIn": "此環境已設定 OPENAI_CODEX_ACCESS_TOKEN 或 CODEX_ACCESS_TOKEN,其優先順序高於本次登入;在取消設定之前,請求會繼續使用該權杖的帳戶。",
"TranscriptThought": "思考完畢",
"TranscriptThoughtFor": "思考耗時 {duration}"
"TranscriptThoughtFor": "思考耗時 {duration}",
"PinnedPromptJumpToMessage": "跳轉到這則訊息"
}
+36
View File
@@ -2683,6 +2683,9 @@ pub enum MessageId {
AuthReplacedPreviousSignIn,
AuthSameAccountAsBefore,
AuthEnvTokenOutranksSignIn,
/// Hover label for the pinned user-prompt header above the transcript:
/// clicking the header jumps to the user message it names.
PinnedPromptJumpToMessage,
}
#[allow(dead_code)]
@@ -5136,6 +5139,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::AuthReplacedPreviousSignIn,
MessageId::AuthSameAccountAsBefore,
MessageId::AuthEnvTokenOutranksSignIn,
MessageId::PinnedPromptJumpToMessage,
];
pub fn tr(locale: Locale, id: MessageId) -> Cow<'static, str> {
@@ -5563,6 +5567,38 @@ mod tests {
}
}
#[test]
fn context_inspector_and_command_hints_are_explicitly_localized() {
let ids = [
MessageId::CtxInspRowCompaction,
MessageId::CtxInspRowAnchors,
MessageId::CtxInspCompactionNever,
MessageId::CtxInspCompactionDetail,
MessageId::CtxInspCompactionRestored,
MessageId::CtxInspCompactionPathSummary,
MessageId::CtxInspCompactionPathPrune,
MessageId::CtxInspCompactionAssistantKept,
MessageId::CtxInspAnchorsNone,
MessageId::CtxInspAnchorsPresent,
MessageId::KbReasoningDetail,
MessageId::CmdTurnInspectDescription,
MessageId::CmdAdvisorDescription,
];
for locale in Locale::shipped_complete() {
if *locale == Locale::En {
continue;
}
for id in ids {
assert_ne!(
tr(*locale, id),
tr(Locale::En, id),
"{} ships the English text for {id:?}",
locale.tag()
);
}
}
}
fn raw_locale_messages(locale: Locale) -> serde_json::Map<String, serde_json::Value> {
serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(locale_json_source(
locale,
+2 -6
View File
@@ -5,20 +5,16 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Provider request/response models and the offline model metadata catalog for Codewhale."
description = "Provider request/response models and intrinsic metadata projections for Codewhale."
[features]
# Exposes the catalog test hooks (`test_catalog_lock`,
# `replace_active_catalog_for_test`) that used to be reachable as `#[cfg(test)]`
# items inside `codewhale-tui`. Enabled only from dev-dependencies.
# Exposes compaction comparison helpers only to dev-dependencies.
test-support = []
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
codewhale-config = { path = "../config", version = "0.10.1" }
codewhale-core = { path = "../core", version = "0.10.1" }
serde.workspace = true
@@ -1,722 +0,0 @@
{
"entries": {
"claude-fable-5": {
"context_window": 1000000,
"id": "claude-fable-5",
"input_usd_per_million": 10.0,
"max_output": 128000,
"modalities": [
"text"
],
"output_usd_per_million": 50.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"claude-haiku-4-5": {
"context_window": 200000,
"id": "claude-haiku-4-5",
"input_usd_per_million": 1.0,
"max_output": 64000,
"modalities": [
"text"
],
"output_usd_per_million": 5.0,
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": false
},
"claude-opus-4-8": {
"context_window": 1000000,
"id": "claude-opus-4-8",
"input_usd_per_million": 5.0,
"max_output": 128000,
"modalities": [
"text"
],
"output_usd_per_million": 25.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"claude-opus-5": {
"context_window": 1000000,
"id": "claude-opus-5",
"input_usd_per_million": 5.0,
"max_output": 128000,
"modalities": [
"text"
],
"output_usd_per_million": 25.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"claude-sonnet-4-6": {
"context_window": 1000000,
"id": "claude-sonnet-4-6",
"input_usd_per_million": 3.0,
"max_output": 128000,
"modalities": [
"text"
],
"output_usd_per_million": 15.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"claude-sonnet-5": {
"context_window": 1000000,
"id": "claude-sonnet-5",
"input_usd_per_million": 2.0,
"max_output": 128000,
"modalities": [
"text"
],
"output_usd_per_million": 10.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"deepseek-v4-flash": {
"context_window": 1000000,
"id": "deepseek-v4-flash",
"max_output": 384000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"deepseek-flash": {
"context_window": 1000000,
"id": "deepseek-flash",
"max_output": 384000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"deepseek-v4-flash-vision-exp": {
"context_window": 1000000,
"id": "deepseek-v4-flash-vision-exp",
"max_output": 384000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"deepseek-v4-pro": {
"context_window": 1000000,
"id": "deepseek-v4-pro",
"max_output": 384000,
"modalities": [
"text"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"fugu-ultra": {
"context_window": 1000000,
"id": "fugu-ultra",
"input_usd_per_million": 5.0,
"max_output": 131000,
"modalities": [
"text"
],
"output_usd_per_million": 30.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"fugu-ultra-20260615": {
"context_window": 1000000,
"id": "fugu-ultra-20260615",
"input_usd_per_million": 5.0,
"max_output": 131000,
"modalities": [
"text"
],
"output_usd_per_million": 30.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"glm-5.2": {
"context_window": 1000000,
"id": "glm-5.2",
"input_usd_per_million": 1.4,
"max_output": 131072,
"modalities": [
"text"
],
"output_usd_per_million": 4.4,
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"glm-5.3": {
"context_window": 1000000,
"id": "glm-5.3",
"max_output": 131072,
"modalities": [
"text"
],
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"glm-5.3-flash": {
"context_window": 1000000,
"id": "glm-5.3-flash",
"input_usd_per_million": 0.15,
"max_output": 131072,
"modalities": [
"text",
"image",
"video"
],
"output_usd_per_million": 0.5,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"gpt-5-codex": {
"context_window": 400000,
"id": "gpt-5-codex",
"input_usd_per_million": 1.25,
"max_output": 128000,
"modalities": [
"text"
],
"output_usd_per_million": 10.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"gpt-5.3-codex": {
"context_window": 400000,
"id": "gpt-5.3-codex",
"input_usd_per_million": 1.75,
"max_output": 128000,
"modalities": [
"text"
],
"output_usd_per_million": 14.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"gpt-5.6": {
"context_window": 1050000,
"id": "gpt-5.6",
"max_output": 128000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"provider_model_id": "gpt-5.6-sol",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"gpt-5.6-luna": {
"context_window": 1050000,
"id": "gpt-5.6-luna",
"max_output": 128000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"gpt-5.6-sol": {
"context_window": 1050000,
"id": "gpt-5.6-sol",
"max_output": 128000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"gpt-5.6-terra": {
"context_window": 1050000,
"id": "gpt-5.6-terra",
"max_output": 128000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"kimi-k2.7-code": {
"context_window": 262144,
"id": "kimi-k2.7-code",
"input_usd_per_million": 0.95,
"max_output": 32768,
"modalities": [
"text"
],
"output_usd_per_million": 4.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"kimi-k3": {
"context_window": 1048576,
"id": "kimi-k3",
"input_usd_per_million": 3.0,
"max_output": 131072,
"modalities": [
"text",
"image",
"video"
],
"output_usd_per_million": 15.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"mimo-v2.5": {
"context_window": 1000000,
"id": "mimo-v2.5",
"max_output": 131072,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"mimo-v2.5-pro": {
"context_window": 1000000,
"id": "mimo-v2.5-pro",
"max_output": 131072,
"modalities": [
"text",
"image",
"audio"
],
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"mimo-v2.5-pro-ultraspeed": {
"context_window": 1000000,
"id": "mimo-v2.5-pro-ultraspeed",
"max_output": 131072,
"modalities": [
"text"
],
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"minimax-m2.7": {
"context_window": 204800,
"id": "minimax-m2.7",
"input_usd_per_million": 0.3,
"max_output": 131072,
"modalities": [
"text"
],
"output_usd_per_million": 1.2,
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"minimax/minimax-m2.7": {
"context_window": 204800,
"id": "minimax/minimax-m2.7",
"input_usd_per_million": 0.3,
"max_output": 131072,
"modalities": [
"text"
],
"output_usd_per_million": 1.2,
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"minimax/minimax-m3": {
"context_window": 1000000,
"id": "minimax/minimax-m3",
"max_output": 524288,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"moonshotai/kimi-k2.7-code": {
"context_window": 262144,
"id": "moonshotai/kimi-k2.7-code",
"input_usd_per_million": 0.95,
"max_output": 32768,
"modalities": [
"text"
],
"output_usd_per_million": 4.0,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"muse-spark-1.1": {
"context_window": 1000000,
"id": "muse-spark-1.1",
"max_output": 32000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"muse-spark-1.2": {
"context_window": 1000000,
"id": "muse-spark-1.2",
"max_output": 32000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"muse-spark-1.2-contributor": {
"context_window": 1000000,
"id": "muse-spark-1.2-contributor",
"max_output": 32000,
"modalities": [
"text",
"image"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"qwen/qwen3.6-flash": {
"context_window": 1000000,
"id": "qwen/qwen3.6-flash",
"max_output": 65536,
"modalities": [
"text"
],
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"step-3.7-flash": {
"context_window": 256000,
"id": "step-3.7-flash",
"input_usd_per_million": 0.2,
"modalities": [
"text",
"image",
"video"
],
"output_usd_per_million": 1.15,
"provenance": "bundled",
"supported_parameters": [
"tools",
"tool_choice",
"reasoning_effort"
],
"supports_reasoning": true,
"max_output": 256000
},
"trinity-large-thinking": {
"context_window": 262144,
"id": "trinity-large-thinking",
"max_output": 262144,
"modalities": [
"text"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"trinity-mini": {
"context_window": 128000,
"id": "trinity-mini",
"modalities": [
"text"
],
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"z-ai/glm-5.2": {
"context_window": 1000000,
"id": "z-ai/glm-5.2",
"input_usd_per_million": 1.4,
"max_output": 131072,
"modalities": [
"text"
],
"output_usd_per_million": 4.4,
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"z-ai/glm-5.3": {
"context_window": 1000000,
"id": "z-ai/glm-5.3",
"max_output": 131072,
"modalities": [
"text"
],
"provenance": "bundled",
"supported_parameters": [],
"supports_reasoning": true
},
"z-ai/glm-5.3-flash": {
"context_window": 1000000,
"id": "z-ai/glm-5.3-flash",
"input_usd_per_million": 0.15,
"max_output": 131072,
"modalities": [
"text",
"image",
"video"
],
"output_usd_per_million": 0.5,
"provenance": "bundled",
"supported_parameters": [
"reasoning"
],
"supports_reasoning": true
},
"kimi-for-coding": {
"id": "kimi-for-coding",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"kimi-for-coding-highspeed": {
"id": "kimi-for-coding-highspeed",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"kimi-k2.5": {
"id": "kimi-k2.5",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"kimi-k2.6": {
"id": "kimi-k2.6",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.5-flash": {
"id": "qwen3.5-flash",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.5-plus": {
"id": "qwen3.5-plus",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.6-flash": {
"id": "qwen3.6-flash",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.6-plus": {
"id": "qwen3.6-plus",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.7-max": {
"id": "qwen3.7-max",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.7-plus": {
"id": "qwen3.7-plus",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.8-flash": {
"id": "qwen3.8-flash",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.8-max": {
"id": "qwen3.8-max",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"qwen3.8-max-preview": {
"id": "qwen3.8-max-preview",
"provenance": "bundled",
"supports_reasoning": true,
"supported_parameters": [
"reasoning"
]
},
"step-3.5-flash": {
"context_window": 256000,
"id": "step-3.5-flash",
"input_usd_per_million": 0.1,
"modalities": [
"text"
],
"output_usd_per_million": 0.3,
"provenance": "bundled",
"supported_parameters": [
"tools",
"tool_choice"
],
"supports_reasoning": true
},
"step-3.5-flash-2603": {
"context_window": 256000,
"id": "step-3.5-flash-2603",
"input_usd_per_million": 0.1,
"modalities": [
"text"
],
"output_usd_per_million": 0.3,
"provenance": "bundled",
"supported_parameters": [
"tools",
"tool_choice",
"reasoning_effort"
],
"supports_reasoning": true
},
"step-5-preview": {
"context_window": 1000000,
"id": "step-5-preview",
"input_usd_per_million": 1.0,
"modalities": [
"text",
"image",
"video"
],
"output_usd_per_million": 2.7,
"provenance": "bundled",
"supported_parameters": [
"tools",
"tool_choice",
"reasoning_effort"
],
"supports_reasoning": true,
"max_output": 1000000
}
},
"fetched_at": "2026-07-06T00:00:00Z",
"schema_version": 1,
"source": "bundled",
"ttl_secs": 2592000
}
+83 -521
View File
@@ -2,12 +2,10 @@
use serde::{Deserialize, Serialize};
pub mod model_catalog;
/// Context window used only for legacy DeepSeek model IDs that do not name a
/// newer V4 alias and do not carry an explicit `*k` suffix.
pub const LEGACY_DEEPSEEK_CONTEXT_WINDOW_TOKENS: u32 = 128_000;
pub const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS: u32 = 1_000_000;
pub use codewhale_config::catalog::reviewed::constants::DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS;
/// Conservative Kimi Code K3 context baseline. The membership route's real
/// context is plan-tier dependent (verified 2026-07-20 from
/// <https://www.kimi.com/code/docs/en/kimi-code/models>): Moderato gets 256K,
@@ -20,12 +18,12 @@ pub const KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS: u32 = 262_144;
/// Verified 2026-07-20 from <https://platform.kimi.ai/docs/guide/kimi-k3-quickstart>
/// (1,048,576 tokens). Max output is a separate fact below and must never be
/// conflated with this window.
pub const KIMI_K3_CONTEXT_WINDOW_TOKENS: u32 = 1_048_576;
pub use codewhale_config::catalog::reviewed::constants::KIMI_K3_CONTEXT_WINDOW_TOKENS;
/// Conservative K3 default generation ceiling. The direct Kimi API defaults
/// `max_completion_tokens` to 131,072, while its documented route maximum is
/// a separate exact-route fact below. Membership and neighboring routes do
/// not inherit that direct-platform maximum.
pub const KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS: u32 = 131_072;
pub use codewhale_config::catalog::reviewed::constants::KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS;
/// Documented maximum output for the exact direct Kimi K3 API route.
///
/// Source: <https://platform.kimi.ai/docs/guide/kimi-k3-quickstart> (verified 2026-07-20).
@@ -39,21 +37,7 @@ pub const DIRECT_KIMI_K3_MAX_OUTPUT_TOKENS: u32 = 1_048_576;
/// `compaction_threshold_for_model` (#664).
pub const DEFAULT_COMPACTION_TOKEN_THRESHOLD: usize = 102_400;
pub fn canonical_official_deepseek_model_id(model: &str) -> Option<&'static str> {
match model.trim().to_ascii_lowercase().as_str() {
"deepseek-v4-pro"
| "deepseek-v4pro"
| "deepseek-ai/deepseek-v4-pro"
| "deepseek-ai/deepseek-v4pro"
| "deepseek/deepseek-v4-pro"
| "deepseek/deepseek-v4pro" => Some("deepseek-v4-pro"),
"deepseek-v4-flash"
| "deepseek-v4flash"
| "deepseek-ai/deepseek-v4-flash"
| "deepseek-ai/deepseek-v4flash"
| "deepseek/deepseek-v4-flash"
| "deepseek/deepseek-v4flash" => Some("deepseek-v4-flash"),
_ => None,
}
codewhale_config::catalog::reviewed::official_deepseek_model_id(model)
}
#[cfg(any(test, feature = "test-support"))]
@@ -181,275 +165,27 @@ pub struct Usage {
/// Unrecognized DeepSeek family names remain unknown.
#[must_use]
pub fn context_window_for_model(model: &str) -> Option<u32> {
if let Some(window) = crate::model_catalog::resolved_context_window(model) {
return Some(window);
}
let lower = model.to_lowercase();
// Concrete model facts take precedence over vendor-agnostic suffix
// inference (`k3-256k` means 262,144 tokens, not 256,000).
if let Some(window) = known_context_window_for_model(&lower) {
return Some(window);
}
if canonical_official_deepseek_model_id(&lower).is_some() {
return Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS);
}
if let Some(explicit_window) = explicit_context_window_hint(&lower) {
return Some(explicit_window);
}
if is_openai_gpt_55_api_model(&lower) || is_openai_gpt_56_api_model(&lower) {
return Some(1_050_000);
}
if is_openai_codex_model(&lower) {
return Some(400_000);
}
if lower.contains("claude") {
return Some(200_000);
}
None
}
fn known_context_window_for_model(model_lower: &str) -> Option<u32> {
match model_lower {
// OpenAI API model docs, verified 2026-06-12:
// https://developers.openai.com/api/docs/models/gpt-5.5
// Family aliases and snapshots are handled by
// `is_openai_gpt_55_api_model` before this table.
// OpenAI Codex model docs, verified 2026-06-12:
// https://developers.openai.com/api/docs/models/gpt-5-codex
// https://developers.openai.com/api/docs/models/gpt-5.3-codex
"gpt-5-codex" | "gpt-5.3-codex" => Some(400_000),
// Anthropic 4.6+ models carry a 1M window; Haiku stays at 200K (#3014).
// Opus 5 (GA 2026-07-24) is 1M / 128K per
// https://platform.claude.com/docs/en/about-claude/models/overview.
"claude-opus-4-8" | "claude-opus-5" | "claude-sonnet-4-6" | "claude-sonnet-5"
| "claude-fable-5" => Some(1_000_000),
"claude-haiku-4-5" => Some(200_000),
// DeepSeek V4.1 Flash, id verified live on api.deepseek.com /v1/models
// 2026-09-10. The V4 family ships 1M context / 384K output, and the
// vendor's own notice puts V4.1 Flash above V4 Pro on every metric.
// Listed here as an exact-id context fact; its price is time-varying
// and lives in `pricing.rs`, which is why this does not route through
// `canonical_official_deepseek_model_id`.
"deepseek-flash" | "deepseek/deepseek-flash" | "deepseek-ai/deepseek-flash" => {
Some(DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS)
}
"trinity-mini" => Some(128_000),
"arcee-ai/trinity-large-thinking" | "trinity-large-thinking" | "trinity-large-preview" => {
Some(262_144)
}
"google/gemma-4-31b-it"
| "google/gemma-4-31b-it:free"
| "google/gemma-4-26b-a4b-it"
| "google/gemma-4-26b-a4b-it:free"
| "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free"
| "qwen/qwen3.6-35b-a3b"
| "qwen/qwen3.6-max-preview"
| "qwen/qwen3.6-27b"
| "tencent/hy3-preview" => Some(262_144),
// Official Kimi K3 platform pricing (2026-07-20):
// https://platform.kimi.ai/docs/guide/kimi-k3-quickstart — 1,048,576 context
// for the open platform.
"moonshotai/kimi-k3" | "kimi-k3" | "opencode-go/kimi-k3" => {
Some(KIMI_K3_CONTEXT_WINDOW_TOKENS)
}
// Bare `k3` is plan-tier dependent; `k3-256k` is fixed at 256 KiTok.
// Neither may fall through to the generic suffix heuristic.
"k3" | "k3-256k" => Some(KIMI_CODE_K3_CONTEXT_WINDOW_TOKENS),
// `kimi-k2.7-code-highspeed` is the same model on the direct
// platform's high-speed tier (262,144 context), per
// https://platform.kimi.ai/docs/pricing/chat-k27-code (2026-08-17).
"moonshotai/kimi-k2.7-code"
| "moonshotai/kimi-k2.7-code-highspeed"
| "moonshotai/kimi-k2.6"
| "moonshotai/kimi-k2.6:free"
| "kimi-k2.7-code"
| "kimi-k2.7-code-highspeed"
| "kimi-k2.6"
| "kimi-for-coding"
| "kimi-for-coding-highspeed" => Some(262_144),
"minimax-m2.7"
| "minimax/minimax-m2.7"
| "minimax-m2.7-highspeed"
| "minimax-m2.5"
| "minimax-m2.5-highspeed"
| "minimax-m2.1"
| "minimax-m2.1-highspeed"
| "minimax-m2" => Some(204_800),
"z-ai/glm-5.1" | "z-ai/glm-5v-turbo" | "glm-5.1" | "glm-5v-turbo" => Some(202_752),
"z-ai/glm-5-turbo" | "glm-5-turbo" => Some(202_752),
// GLM-5.3 limits are inherited from GLM-5.2 pending official Z.ai
// release metadata (see `INHERITED FROM glm-5.2` in config/models.rs).
// GLM-5.3-Flash is the published 1M multimodal sibling (2026-08-26).
"z-ai/glm-5.2" | "glm-5.2" | "z-ai/glm-5.3" | "glm-5.3" | "z-ai/glm-5.3-flash"
| "glm-5.3-flash" => Some(1_000_000),
"minimax/minimax-m3" | "minimax-m3" | "qwen/qwen3.8-flash" | "qwen/qwen3.6-flash"
| "qwen/qwen3.6-plus" => Some(1_000_000),
// Alibaba Cloud Model Studio (Token Plan console + curated catalog,
// verified 2026-08-03): ~1M context. Never fall through to the 128K
// legacy default — that number is the generation ceiling, not the window.
// Bare `qwen3.8-flash` is the OpenRouter short id (verified 2026-08-26
// against models.dev: 1M context / 131K output); Alibaba first-party
// does not list a flash row, so this is not a Model Studio default.
"qwen3.8-max"
| "qwen3.8-max-preview"
| "qwen3.8-flash"
| "qwen3.7-plus"
| "qwen3.7-max"
| "qwen3.6-flash" => Some(1_000_000),
"nvidia/nemotron-3-ultra-550b-a55b" | "nvidia/nemotron-3-ultra-550b-a55b:free" => {
Some(1_000_000)
}
"xiaomi/mimo-v2.5-pro"
| "xiaomi/mimo-v2.5"
| "mimo-v2.5-pro"
| "mimo-v2.5-pro-ultraspeed"
| "mimo-v2.5" => Some(1_000_000),
"mimo-v2.5-asr"
| "mimo-v2.5-tts"
| "mimo-v2.5-tts-voicedesign"
| "mimo-v2.5-tts-voiceclone"
| "mimo-v2-tts" => Some(8_000),
"grok-4.7" | "grok-4.6" | "grok-4.5" => Some(500_000),
"grok-4.3" => Some(1_000_000),
"grok-build" => Some(512_000),
"grok-composer-2.5-fast" => Some(200_000),
"grok-4.20-0309-reasoning" | "grok-4.20-0309-non-reasoning" => Some(2_000_000),
"muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor" => Some(1_000_000),
// Mistral la Plateforme text/reasoning models: all report 262144
// (256K) tokens on /v1/models as of 2026-08-08. Codestral coding
// model (mistral-code-latest) reports 256000 tokens on the same
// endpoint. IDs and windows verified live against
// https://api.mistral.ai/v1/models rather than model-card slugs.
"mistral-medium-latest"
| "mistral-medium-3-5"
| "mistral-medium-2604"
| "mistral-medium-3.5"
| "mistral-medium-3"
| "mistral-small-latest"
| "mistral-small-2603"
| "magistral-small-latest"
| "mistral-large-latest"
| "mistral-large-2512" => Some(262_144),
"mistral-code-latest" | "codestral-latest" | "codestral" | "mistral-code" => Some(256_000),
// Google Gemini API model pages (verified 2026-08-17): every current
// Gemini 3.x / 2.5 text model lists a 1,048,576-token input limit and
// a 65,536-token output limit.
// https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash
// https://ai.google.dev/gemini-api/docs/models/gemini-3.6-flash
// https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash
// https://ai.google.dev/gemini-api/docs/models/gemini-3.5-flash-lite
// https://ai.google.dev/gemini-api/docs/models/gemini-3.1-pro-preview
// https://ai.google.dev/gemini-api/docs/models/gemini-2.5-pro
// https://ai.google.dev/gemini-api/docs/models/gemini-2.5-flash
// (gemini-3-pro-preview's page carries the same limits but is marked
// shut down since 2026-03-09 on the Gemini API; it stays here only
// because other routes still name it.)
"gemini-3.7-flash"
| "gemini-3.6-flash"
| "gemini-3.5-flash"
| "gemini-3.5-flash-lite"
| "gemini-3.1-pro-preview"
| "gemini-3-pro-preview"
| "gemini-2.5-pro"
| "gemini-2.5-flash" => Some(1_048_576),
// OpenRouter-hosted Dots Studio (RedNote) Dots3-Note preview: the only
// hosted route (single AtlasCloud endpoint) reports 512,000 context /
// 512,000 max completion tokens, https://openrouter.ai/api/v1/models/
// dots-studio/dots-3-note-preview:free/endpoints (2026-08-17).
"dots-studio/dots-3-note-preview:free" => Some(512_000),
_ => None,
}
codewhale_config::catalog::reviewed::intrinsic_model(model)
.and_then(|row| row.context_window)
.or_else(|| {
reviewed_snapshot_model(model).and_then(|id| {
codewhale_config::catalog::reviewed::intrinsic_model(id)
.and_then(|row| row.context_window)
})
})
.or_else(|| explicit_context_window_hint(&model.to_ascii_lowercase()))
}
#[must_use]
pub fn max_output_tokens_for_model(model: &str) -> Option<u32> {
if let Some(max_output) = crate::model_catalog::resolved_max_output(model) {
return Some(max_output);
}
let lower = model.to_lowercase();
if is_openai_gpt_55_api_model(&lower)
|| is_openai_gpt_56_api_model(&lower)
|| is_openai_codex_model(&lower)
{
return Some(128_000);
}
match lower.as_str() {
"gpt-5-codex" | "gpt-5.3-codex" => Some(128_000),
// claude-sonnet-4-6 max output raised 64K -> 128K per
// https://platform.claude.com/docs/en/about-claude/models/overview
// (2026-07-09 audit).
"claude-opus-4-8" | "claude-opus-5" | "claude-sonnet-4-6" | "claude-sonnet-5"
| "claude-fable-5" => Some(128_000),
"claude-haiku-4-5" => Some(64_000),
"arcee-ai/trinity-large-thinking" | "trinity-large-thinking" => Some(262_144),
// Keep the generic/model-id lookup at K3's conservative documented
// default generation ceiling. The exact direct route's 1M maximum is
// applied later with endpoint-aware provenance; membership and
// neighboring routes must not inherit it.
"moonshotai/kimi-k3" | "kimi-k3" | "k3" | "k3-256k" | "opencode-go/kimi-k3" => {
Some(KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS)
}
// Kimi K2.7 Code has a 256K context window but its documented default
// maximum generation is 32K. Keeping those separate prevents the
// input budget from collapsing to the 1K emergency floor (#4368). The
// direct-platform value matches the provider-reported bundled
// catalog. The Kimi Code membership ids (`kimi-for-coding` family)
// are deliberately absent here: the membership catalog is the source
// of truth for their limits and no client-side output ceiling is
// claimed, so they fall back to the generic default.
"moonshotai/kimi-k2.7-code"
| "moonshotai/kimi-k2.7-code-highspeed"
| "moonshotai/kimi-k2.6"
| "kimi-k2.7-code"
| "kimi-k2.7-code-highspeed"
| "kimi-k2.6" => Some(32_768),
"minimax/minimax-m3" | "minimax-m3" => Some(524_288),
// Alibaba's published limit is 65,536 output tokens; the earlier
// 262,140 mirrored the context window (data-entry smell flagged by
// MODEL_PROVIDER_AUDIT A2/D-7, vendor-verified 2026-07-12).
"qwen/qwen3.6-35b-a3b"
| "qwen/qwen3.6-27b"
| "qwen/qwen3.6-flash"
| "qwen/qwen3.6-max-preview"
| "qwen/qwen3.6-plus" => Some(65_536),
// Model Studio: 128K is the generation ceiling, not the context window.
// OpenRouter qwen3.8-flash shares the 131,072 output cap (models.dev
// 2026-08-26); do not inherit qwen3.6-flash's 65,536 ceiling.
"qwen3.8-max" | "qwen3.8-max-preview" | "qwen/qwen3.8-flash" | "qwen3.8-flash" => {
Some(131_072)
}
"qwen3.7-plus" | "qwen3.7-max" | "qwen3.6-flash" => Some(65_536),
"z-ai/glm-5.1" | "z-ai/glm-5.2" | "z-ai/glm-5.3" | "z-ai/glm-5.3-flash"
| "z-ai/glm-5-turbo" | "glm-5.1" | "glm-5.2" | "glm-5.3" | "glm-5.3-flash"
| "glm-5-turbo" => Some(131_072),
"xiaomi/mimo-v2.5-pro"
| "xiaomi/mimo-v2.5"
| "mimo-v2.5-pro"
| "mimo-v2.5-pro-ultraspeed"
| "mimo-v2.5" => Some(131_072),
"mimo-v2.5-asr" => Some(2_048),
"mimo-v2.5-tts"
| "mimo-v2.5-tts-voicedesign"
| "mimo-v2.5-tts-voiceclone"
| "mimo-v2-tts" => Some(8_192),
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free" => Some(65_536),
"nvidia/nemotron-3-ultra-550b-a55b" => Some(16_384),
"nvidia/nemotron-3-ultra-550b-a55b:free" => Some(65_536),
"google/gemma-4-31b-it" => Some(16_384),
"google/gemma-4-31b-it:free" | "google/gemma-4-26b-a4b-it:free" => Some(32_768),
"muse-spark-1.1" | "muse-spark-1.2" | "muse-spark-1.2-contributor" => Some(32_000),
// Gemini API output token limit (see `known_context_window_for_model`).
"gemini-3.7-flash"
| "gemini-3.6-flash"
| "gemini-3.5-flash"
| "gemini-3.5-flash-lite"
| "gemini-3.1-pro-preview"
| "gemini-3-pro-preview"
| "gemini-2.5-pro"
| "gemini-2.5-flash" => Some(65_536),
"dots-studio/dots-3-note-preview:free" => Some(512_000),
_ => None,
}
codewhale_config::catalog::reviewed::intrinsic_model(model)
.and_then(|row| row.generation_default.or(row.max_output))
.or_else(|| {
reviewed_snapshot_model(model).and_then(|id| {
codewhale_config::catalog::reviewed::intrinsic_model(id)
.and_then(|row| row.generation_default.or(row.max_output))
})
})
}
/// Catalog-first reasoning capability. `None` means no catalog row and no
@@ -460,67 +196,14 @@ pub fn max_output_tokens_for_model(model: &str) -> Option<u32> {
/// unknown to `false` for existing stream/UI gates.
#[must_use]
pub fn model_reasoning_capability(model: &str) -> Option<bool> {
if let Some(supports_reasoning) = crate::model_catalog::resolved_supports_reasoning(model) {
return Some(supports_reasoning);
}
let lower = model.to_lowercase();
if canonical_official_deepseek_model_id(&lower).is_some() {
return Some(true);
}
// Bundled Models.dev snapshot (#6032): sourced `reasoning` booleans for
// ids the offline catalog does not carry. Exact-id rows only — the
// resolver never guesses from a prefix.
if let Some(reasoning) =
codewhale_config::catalog::bundled_models_dev_catalog().reasoning_support(&lower)
{
return Some(reasoning);
}
// Remaining prefix/list arms have no bundled-catalog row yet. They stay
// until each family is fully sourced (#6032 part 2). Do not invent rows.
if lower.starts_with("kimi-") {
return Some(true);
}
if lower.starts_with("mistral-medium")
|| lower.starts_with("mistral-small")
|| lower.starts_with("magistral")
{
return Some(true);
}
let listed = matches!(
lower.as_str(),
"arcee-ai/trinity-large-thinking"
| "thinkingmachines/inkling"
| "google/gemma-4-31b-it"
| "google/gemma-4-31b-it:free"
| "google/gemma-4-26b-a4b-it"
| "google/gemma-4-26b-a4b-it:free"
| "moonshotai/kimi-k2.7-code-highspeed"
| "moonshotai/kimi-k2.6"
| "moonshotai/kimi-k2.6:free"
| "minimax-m3"
| "minimax-m2.7-highspeed"
| "minimax-m2.5"
| "minimax-m2.5-highspeed"
| "minimax-m2.1"
| "minimax-m2.1-highspeed"
| "minimax-m2"
| "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free"
| "nvidia/nemotron-3-ultra-550b-a55b"
| "nvidia/nemotron-3-ultra-550b-a55b:free"
| "qwen/qwen3.6-max-preview"
| "qwen/qwen3.6-27b"
| "tencent/hy3-preview"
| "xiaomi/mimo-v2.5-pro"
| "xiaomi/mimo-v2.5"
| "z-ai/glm-5.1"
| "z-ai/glm-5-turbo"
| "glm-5-turbo"
| "grok-build"
| "grok-4.20-0309-reasoning"
) || is_openai_gpt_55_api_model(&lower)
|| is_openai_gpt_56_api_model(&lower)
|| is_openai_codex_model(&lower);
listed.then_some(true)
codewhale_config::catalog::reviewed::intrinsic_model(model)
.and_then(|row| row.reasoning)
.or_else(|| {
reviewed_snapshot_model(model).and_then(|id| {
codewhale_config::catalog::reviewed::intrinsic_model(id)
.and_then(|row| row.reasoning)
})
})
}
#[must_use]
@@ -538,41 +221,18 @@ pub fn effective_muse_wire_id(model: &str) -> &str {
#[must_use]
pub fn model_is_openai_reasoning_family(model: &str) -> bool {
let lower = model.to_lowercase();
is_openai_gpt_55_api_model(&lower)
|| is_openai_gpt_56_api_model(&lower)
|| is_openai_codex_model(&lower)
}
fn is_openai_gpt_55_api_model(model_lower: &str) -> bool {
matches!(model_lower, "gpt-5.5" | "gpt-5.5-pro")
|| has_date_snapshot_suffix(model_lower, "gpt-5.5-")
|| has_date_snapshot_suffix(model_lower, "gpt-5.5-pro-")
let lower = model.to_ascii_lowercase();
codewhale_config::catalog::reviewed::bundled_reviewed()
.openai_reasoning_ids
.contains_key(&lower)
|| reviewed_snapshot_model(&lower).is_some()
}
pub fn is_openai_gpt_56_api_model(model_lower: &str) -> bool {
matches!(
model_lower,
"gpt-5.6" | "gpt-5.6-sol" | "gpt-5.6-terra" | "gpt-5.6-luna"
)
}
fn is_openai_codex_model(model_lower: &str) -> bool {
matches!(
model_lower,
"gpt-5-codex"
| "gpt-5.1-codex"
| "gpt-5.1-codex-mini"
| "gpt-5.1-codex-max"
| "gpt-5.2-codex"
| "gpt-5.3-codex"
| "codex-gpt-5.5"
| "chatgpt-gpt-5.5"
| "gpt-5.5-codex"
| "gpt-5.5-codex-preview"
| "codex-gpt-5.5-preview"
| "chatgpt-gpt-5.5-preview"
)
codewhale_config::catalog::reviewed::bundled_reviewed()
.openai_reasoning_ids
.get(model_lower)
.is_some_and(|kind| kind == "gpt_56")
}
pub fn has_date_snapshot_suffix(model_lower: &str, prefix: &str) -> bool {
@@ -597,12 +257,26 @@ pub fn has_date_snapshot_suffix(model_lower: &str, prefix: &str) -> bool {
/// from the name* — a naming convention the serving engine may ignore is not
/// a fact about the route, and every surface that shows such a window must
/// mark it unverified.
fn reviewed_snapshot_model(model: &str) -> Option<&'static str> {
let lower = model.to_ascii_lowercase();
codewhale_config::catalog::reviewed::bundled_reviewed()
.snapshot_prefixes
.iter()
.find_map(|(prefix, target)| {
has_date_snapshot_suffix(&lower, prefix).then_some(target.as_str())
})
}
#[must_use]
pub fn name_suffix_context_window_hint(model: &str) -> Option<u32> {
if crate::model_catalog::resolved_context_window(model).is_some() {
if codewhale_config::catalog::reviewed::intrinsic_model(model)
.and_then(|row| row.context_window)
.is_some()
|| reviewed_snapshot_model(model).is_some()
{
return None;
}
explicit_context_window_hint(&model.to_lowercase())
explicit_context_window_hint(&model.to_ascii_lowercase())
}
/// Parse an explicit `_Nk` context-window hint from a model name (vendor
@@ -769,7 +443,6 @@ pub struct MessageDelta {
mod tests {
use super::*;
use std::any::TypeId;
use std::collections::BTreeMap;
/// #6032: `model_supports_reasoning` consults the catalog before its
/// hand-maintained pile, so a literal arm that duplicates a catalog row is
@@ -841,7 +514,8 @@ mod tests {
];
for model in removed {
assert_eq!(
crate::model_catalog::resolved_supports_reasoning(model),
codewhale_config::catalog::reviewed::intrinsic_model(model)
.and_then(|row| row.reasoning),
Some(true),
"{model} no longer has a catalog row, but its heuristic arm was \
deleted in #6032 — restore the row, or put the arm back"
@@ -875,8 +549,8 @@ mod tests {
assert_eq!(
catalog.reasoning_support(model),
Some(true),
"{model} lost its bundled Models.dev row — restore the row or put \
its heuristic arm back (#6032)"
"{model} lost its bundled Models.dev row — restore its reviewed \
source row (#6032)"
);
assert!(
model_supports_reasoning(model),
@@ -885,109 +559,31 @@ mod tests {
}
}
/// #6032 part 3 coverage guard: every literal arm remaining in the
/// heuristic pile must be an id the bundled Models.dev snapshot does NOT
/// source, so the pile can only shrink as sourcing grows. If this fails,
/// the named arm is now redundant (or disagrees with the asset) — delete
/// it, do not widen it.
///
/// Still unsourced/unknown as of 2026-09-15:
/// - the literal list below (OpenRouter-style vendor ids for Gemma,
/// Kimi, MiniMax, Nemotron, Qwen, Hunyuan, MiMo, GLM, Grok families);
/// - the `kimi-` / `mistral-medium` / `mistral-small` / `magistral`
/// prefix arms (no asset row states any Mistral-family fact at all);
/// - `is_openai_gpt_55_api_model` / `is_openai_gpt_56_api_model` /
/// `is_openai_codex_model`, which must stay for their date-snapshot
/// and chatgpt/codex variants (`gpt-5.5-2026-06-01`,
/// `codex-gpt-5.5-preview`, …) that the asset does not enumerate.
/// #6032 coverage guard: exact reviewed facts and documented snapshot
/// contracts classify known models. Mistral's documented latest IDs now
/// have explicit reviewed reasoning facts; their family names and private
/// model names do not inherit those facts through a prefix heuristic.
#[test]
fn remaining_reasoning_heuristic_arms_are_unsourced_in_models_dev_bundled_asset() {
let catalog = codewhale_config::catalog::bundled_models_dev_catalog();
let remaining = [
"arcee-ai/trinity-large-thinking",
"thinkingmachines/inkling",
"google/gemma-4-31b-it",
"google/gemma-4-31b-it:free",
"google/gemma-4-26b-a4b-it",
"google/gemma-4-26b-a4b-it:free",
"moonshotai/kimi-k2.7-code-highspeed",
"moonshotai/kimi-k2.6",
"moonshotai/kimi-k2.6:free",
"minimax-m3",
"minimax-m2.7-highspeed",
"minimax-m2.5",
"minimax-m2.5-highspeed",
"minimax-m2.1",
"minimax-m2.1-highspeed",
"minimax-m2",
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
"nvidia/nemotron-3-ultra-550b-a55b",
"nvidia/nemotron-3-ultra-550b-a55b:free",
"qwen/qwen3.6-max-preview",
"qwen/qwen3.6-27b",
"tencent/hy3-preview",
"xiaomi/mimo-v2.5-pro",
"xiaomi/mimo-v2.5",
"z-ai/glm-5.1",
"z-ai/glm-5-turbo",
"glm-5-turbo",
"grok-build",
"grok-4.20-0309-reasoning",
];
for model in remaining {
assert_eq!(
catalog.reasoning_support(model),
None,
"{model} is now sourced by the bundled Models.dev asset — delete \
its heuristic arm instead of keeping both (#6032)"
);
assert_eq!(
model_reasoning_capability(model),
Some(true),
"{model} must still classify as reasoning-capable via the pile"
);
}
// The prefix/function arms are not enumerable literals, so the guard
// samples both directions: asset rows they overlap must agree with
// the arm's claim (`true`), and unsourced variants that still need
// the arm must stay unknown in the asset.
for sourced_and_agreeing in [
"kimi-k2.6",
"kimi-k2.7-code",
"kimi-k2.7-code-highspeed",
"kimi-k3",
"gpt-5.5",
"gpt-5.5-pro",
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.3-codex",
] {
assert_eq!(
catalog.reasoning_support(sourced_and_agreeing),
Some(true),
"{sourced_and_agreeing} now disagrees with its heuristic arm — \
resolve the source before shipping (#6032)"
);
}
for still_arm_only in [
// Known facts are explicit data. Family resemblance is not a sourced
// capability, while the documented date-snapshot contract remains exact.
for model in [
"kimi-k2.9",
"magistral-medium",
"claude-private-server",
"gpt-5.6-private",
] {
assert_eq!(model_reasoning_capability(model), None, "{model}");
}
for model in [
"mistral-medium-latest",
"mistral-small-latest",
"magistral-medium",
"gpt-5.5-2026-06-01",
"gpt-5.1-codex-max",
"codex-gpt-5.5-preview",
"chatgpt-gpt-5.5",
] {
assert_eq!(
catalog.reasoning_support(still_arm_only),
None,
"{still_arm_only} is now sourced — its heuristic arm family may \
be shrinkable (#6032)"
);
assert!(model_supports_reasoning(still_arm_only));
assert_eq!(model_reasoning_capability(model), Some(true), "{model}");
}
}
@@ -1099,15 +695,6 @@ mod tests {
#[test]
fn deepseek_v4_output_caps_require_exact_catalog_metadata() {
let _lock = crate::model_catalog::test_catalog_lock();
let catalog = crate::model_catalog::MergedCatalog::from_sources(
BTreeMap::new(),
None,
crate::model_catalog::bundled_catalog(),
chrono::Utc::now(),
);
let _guard = crate::model_catalog::replace_active_catalog_for_test(catalog);
for model in [
"deepseek-v4.1-flash-expires-on-0910",
"deepseek-v4.1-flash",
@@ -1116,7 +703,7 @@ mod tests {
"deepseek-v4-flash-vendor",
] {
assert!(
crate::model_catalog::resolved_entry(model).is_none(),
codewhale_config::catalog::reviewed::intrinsic_model(model).is_none(),
"{model}"
);
assert_eq!(max_output_tokens_for_model(model), None, "{model}");
@@ -1380,40 +967,14 @@ mod tests {
#[test]
fn model_metadata_catalog_override_flows_through_models_chokepoint() {
let _lock = crate::model_catalog::test_catalog_lock();
let mut overrides = BTreeMap::new();
overrides.insert(
"catalog-only-model".to_string(),
crate::model_catalog::CatalogEntry {
id: "catalog-only-model".to_string(),
context_window: Some(777_000),
max_output: Some(55_000),
supports_reasoning: Some(true),
input_usd_per_million: None,
output_usd_per_million: None,
modalities: Vec::new(),
supported_parameters: Vec::new(),
provider_model_id: None,
provenance: crate::model_catalog::MetadataProvenance::UserOverride,
},
let private=codewhale_config::models_dev::ModelsDevCatalog::parse_json(r#"{"providers":{"private":{"models":{"catalog-only-model":{"limit":{"context":777000,"output":55000},"reasoning":true}}}}}"#).unwrap();
assert!(
codewhale_config::catalog::reviewed::intrinsic_model_in(&private, "catalog-only-model")
.is_none()
);
let catalog = crate::model_catalog::MergedCatalog::from_sources(
overrides,
None,
crate::model_catalog::bundled_catalog(),
chrono::Utc::now(),
);
let _guard = crate::model_catalog::replace_active_catalog_for_test(catalog);
assert_eq!(
context_window_for_model("catalog-only-model"),
Some(777_000)
);
assert_eq!(
max_output_tokens_for_model("catalog-only-model"),
Some(55_000)
);
assert!(model_supports_reasoning("catalog-only-model"));
assert_eq!(context_window_for_model("catalog-only-model"), None);
assert_eq!(max_output_tokens_for_model("catalog-only-model"), None);
assert_eq!(model_reasoning_capability("catalog-only-model"), None);
}
#[test]
@@ -1593,7 +1154,8 @@ mod tests {
assert_eq!(context_window_for_model("z-ai/glm-5-turbo"), Some(202_752));
assert!(model_supports_reasoning("z-ai/glm-5-turbo"));
assert_eq!(
crate::model_catalog::resolved_max_output("kimi-k2.7-code"),
codewhale_config::catalog::reviewed::intrinsic_model("kimi-k2.7-code")
.and_then(|row| row.generation_default.or(row.max_output)),
Some(32_768)
);
assert_eq!(max_output_tokens_for_model("kimi-k2.7-code"), Some(32_768));
-432
View File
@@ -1,432 +0,0 @@
//! Legacy offline model metadata compatibility catalog (#3072).
//!
//! This module adds a secret-free metadata layer in front of the legacy model
//! tables. It is intentionally conservative: startup reads a local cache plus a
//! bundled snapshot, never performs a network refresh, and only overrides a
//! legacy fact when the active catalog entry actually carries that field.
//! The unscoped `catalog/openrouter.json` file cannot safely own a live provider
//! roster; new provider/base-URL-scoped refreshes and runtime consumers use
//! `provider_catalog_live` + `provider_lake`. Keep this reader only until the
//! remaining `models`, `pricing`, and safe-label compatibility callers migrate.
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
use anyhow::Result;
use chrono::{DateTime, Duration, Utc};
use serde::{Deserialize, Serialize};
const BUNDLED_CATALOG_JSON: &str = include_str!("../assets/model_catalog.bundled.json");
const OPENROUTER_CACHE_FILE: &str = "openrouter.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MetadataProvenance {
ProviderApi,
Bundled,
UserOverride,
#[default]
Unknown,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CatalogEntry {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_reasoning: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_usd_per_million: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_usd_per_million: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub modalities: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub supported_parameters: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_model_id: Option<String>,
#[serde(default)]
pub provenance: MetadataProvenance,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CatalogCache {
pub schema_version: u32,
pub source: String,
pub fetched_at: DateTime<Utc>,
pub ttl_secs: u64,
#[serde(default)]
pub entries: BTreeMap<String, CatalogEntry>,
}
impl CatalogCache {
/// Past its TTL, or dated in the future. A future `fetched_at` (a
/// corrupt file, a clock that moved backwards) never ages into the TTL
/// window, so it used to stay "fresh" forever and keep overriding the
/// bundled facts; it is not evidence of freshness.
#[must_use]
pub fn is_stale(&self, now: DateTime<Utc>) -> bool {
if now < self.fetched_at {
return true;
}
let ttl = Duration::seconds(self.ttl_secs.min(i64::MAX as u64) as i64);
now.signed_duration_since(self.fetched_at) > ttl
}
}
#[derive(Debug, Clone)]
pub struct MergedCatalog {
user_overrides: BTreeMap<String, CatalogEntry>,
provider_cache: Option<CatalogCache>,
bundled: CatalogCache,
now: DateTime<Utc>,
}
impl MergedCatalog {
pub fn from_sources(
user_overrides: BTreeMap<String, CatalogEntry>,
provider_cache: Option<CatalogCache>,
bundled: CatalogCache,
now: DateTime<Utc>,
) -> Self {
Self {
user_overrides,
provider_cache,
bundled,
now,
}
}
#[must_use]
pub(crate) fn resolve(&self, model: &str) -> Option<&CatalogEntry> {
if let Some(entry) = entry_for(&self.user_overrides, model) {
return Some(entry);
}
if let Some(provider_cache) = self
.provider_cache
.as_ref()
.filter(|cache| !cache.is_stale(self.now))
&& let Some(entry) = entry_for(&provider_cache.entries, model)
{
return Some(entry);
}
entry_for(&self.bundled.entries, model)
}
/// The offline snapshot is past its own TTL. Rows still resolve — an
/// offline fallback that bricks offline is worse than a stale list — but
/// pickers must not present it as a current catalog.
pub(crate) fn bundled_stale(&self) -> bool {
self.bundled.is_stale(self.now)
}
}
fn entry_for<'a>(
entries: &'a BTreeMap<String, CatalogEntry>,
model: &str,
) -> Option<&'a CatalogEntry> {
entries.get(model).or_else(|| {
let lower = model.to_lowercase();
(lower != model).then(|| entries.get(&lower)).flatten()
})
}
fn active_catalog() -> &'static RwLock<MergedCatalog> {
static ACTIVE: OnceLock<RwLock<MergedCatalog>> = OnceLock::new();
ACTIVE.get_or_init(|| {
RwLock::new(MergedCatalog::from_sources(
BTreeMap::new(),
load_cached(),
bundled_catalog(),
Utc::now(),
))
})
}
#[must_use]
pub fn resolved_entry(model: &str) -> Option<CatalogEntry> {
active_catalog()
.read()
.ok()
.and_then(|catalog| catalog.resolve(model).cloned())
}
#[must_use]
pub fn resolved_context_window(model: &str) -> Option<u32> {
resolved_entry(model).and_then(|entry| entry.context_window)
}
#[must_use]
pub fn resolved_max_output(model: &str) -> Option<u32> {
resolved_entry(model).and_then(|entry| entry.max_output)
}
#[must_use]
pub fn resolved_supports_reasoning(model: &str) -> Option<bool> {
resolved_entry(model).and_then(|entry| entry.supports_reasoning)
}
#[must_use]
#[cfg_attr(test, allow(dead_code))]
pub fn resolved_usd_pricing(model: &str) -> Option<(f64, f64)> {
let entry = resolved_entry(model)?;
Some((entry.input_usd_per_million?, entry.output_usd_per_million?))
}
pub fn bundled_catalog() -> CatalogCache {
serde_json::from_str(BUNDLED_CATALOG_JSON).expect("bundled model catalog must parse")
}
/// Whether the bundled offline snapshot is past its TTL right now.
#[must_use]
#[cfg_attr(test, allow(dead_code))]
pub fn bundled_catalog_is_stale() -> bool {
active_catalog()
.read()
.map(|catalog| catalog.bundled_stale())
.unwrap_or(true)
}
fn catalog_cache_read_path() -> Result<PathBuf> {
Ok(codewhale_config::resolve_state_dir("catalog")?.join(OPENROUTER_CACHE_FILE))
}
pub fn load_cached() -> Option<CatalogCache> {
let path = catalog_cache_read_path().ok()?;
let raw = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&raw).ok()
}
#[cfg(any(test, feature = "test-support"))]
static TEST_CATALOG_LOCK: std::sync::LazyLock<std::sync::Mutex<()>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(()));
#[cfg(any(test, feature = "test-support"))]
pub fn test_catalog_lock() -> std::sync::MutexGuard<'static, ()> {
TEST_CATALOG_LOCK.lock().expect("model catalog test lock")
}
#[cfg(any(test, feature = "test-support"))]
pub struct ActiveCatalogGuard {
previous: MergedCatalog,
}
#[cfg(any(test, feature = "test-support"))]
impl Drop for ActiveCatalogGuard {
fn drop(&mut self) {
let mut active = active_catalog().write().expect("active catalog write lock");
*active = self.previous.clone();
}
}
#[cfg(any(test, feature = "test-support"))]
pub fn replace_active_catalog_for_test(catalog: MergedCatalog) -> ActiveCatalogGuard {
let mut active = active_catalog().write().expect("active catalog write lock");
let previous = active.clone();
*active = catalog;
ActiveCatalogGuard { previous }
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str, context_window: u32, provenance: MetadataProvenance) -> CatalogEntry {
CatalogEntry {
id: id.to_string(),
context_window: Some(context_window),
max_output: Some(context_window / 2),
supports_reasoning: Some(false),
input_usd_per_million: None,
output_usd_per_million: None,
modalities: Vec::new(),
supported_parameters: Vec::new(),
provider_model_id: None,
provenance,
}
}
fn cache(
fetched_at: DateTime<Utc>,
ttl_secs: u64,
entries: BTreeMap<String, CatalogEntry>,
) -> CatalogCache {
CatalogCache {
schema_version: 1,
source: "test".to_string(),
fetched_at,
ttl_secs,
entries,
}
}
#[test]
fn bundled_snapshot_ttl_is_bounded_to_a_month() {
// A ten-year TTL made staleness unfirable and shipped a frozen list
// as current (audit A2). Thirty days is the ceiling.
let bundled = bundled_catalog();
assert!(
bundled.ttl_secs <= 2_678_400,
"bundled ttl_secs {} exceeds 31 days",
bundled.ttl_secs
);
}
#[test]
fn stale_bundled_snapshot_still_resolves() {
let bundled = bundled_catalog();
let some_model = bundled.entries.keys().next().cloned().expect("entries");
let past = bundled.fetched_at + Duration::seconds(bundled.ttl_secs as i64 + 60);
let catalog = MergedCatalog::from_sources(BTreeMap::new(), None, bundled, past);
assert!(catalog.bundled_stale());
assert!(
catalog.resolve(&some_model).is_some(),
"offline fallback must keep resolving"
);
}
#[test]
fn bundled_snapshot_parses_and_is_nonempty() {
let bundled = bundled_catalog();
assert_eq!(bundled.schema_version, 1);
assert!(!bundled.entries.is_empty());
assert_eq!(
bundled.entries["deepseek-v4-pro"].provenance,
MetadataProvenance::Bundled
);
}
#[test]
fn bundled_deepseek_flash_aliases_retain_vision() {
// #6421: the offline model fallback must agree with the provider-owned
// seed; DeepSeek's Vision and Models guides now name deepseek-flash.
let bundled = bundled_catalog();
for model in [
"deepseek-flash",
"deepseek-v4-flash",
"deepseek-v4-flash-vision-exp",
] {
assert!(
bundled.entries[model]
.modalities
.iter()
.any(|m| m == "image"),
"{model} lost its documented image input"
);
}
assert!(
!bundled.entries["deepseek-v4-pro"]
.modalities
.iter()
.any(|m| m == "image"),
"the Flash correction must not widen other model facts"
);
}
#[test]
fn merge_order_is_user_override_then_provider_then_bundled() {
let now = Utc::now();
let mut bundled_entries = BTreeMap::new();
bundled_entries.insert(
"sample/model".to_string(),
entry("sample/model", 1_000, MetadataProvenance::Bundled),
);
let bundled = cache(now, 3600, bundled_entries);
let mut provider_entries = BTreeMap::new();
provider_entries.insert(
"sample/model".to_string(),
entry("sample/model", 2_000, MetadataProvenance::ProviderApi),
);
let provider_cache = cache(now, 3600, provider_entries);
let mut override_entries = BTreeMap::new();
override_entries.insert(
"sample/model".to_string(),
entry("sample/model", 3_000, MetadataProvenance::UserOverride),
);
let merged =
MergedCatalog::from_sources(override_entries, Some(provider_cache), bundled, now);
let resolved = merged.resolve("sample/model").expect("resolved");
assert_eq!(resolved.context_window, Some(3_000));
assert_eq!(resolved.provenance, MetadataProvenance::UserOverride);
}
#[test]
fn stale_cache_is_ignored_for_facts() {
let now = Utc::now();
let mut bundled_entries = BTreeMap::new();
bundled_entries.insert(
"sample/model".to_string(),
entry("sample/model", 1_000, MetadataProvenance::Bundled),
);
let bundled = cache(now, 3600, bundled_entries);
let mut provider_entries = BTreeMap::new();
provider_entries.insert(
"sample/model".to_string(),
entry("sample/model", 9_000, MetadataProvenance::ProviderApi),
);
let provider_cache = cache(now - Duration::seconds(10), 1, provider_entries);
assert!(provider_cache.is_stale(now));
let merged =
MergedCatalog::from_sources(BTreeMap::new(), Some(provider_cache), bundled, now);
let resolved = merged.resolve("sample/model").expect("resolved");
assert_eq!(resolved.context_window, Some(1_000));
assert_eq!(resolved.provenance, MetadataProvenance::Bundled);
}
/// Audit R03-10: a future-dated cache cannot override the bundled facts.
#[test]
fn future_dated_cache_is_stale_and_ignored_for_facts() {
let now = Utc::now();
let mut bundled_entries = BTreeMap::new();
bundled_entries.insert(
"sample/model".to_string(),
entry("sample/model", 1_000, MetadataProvenance::Bundled),
);
let bundled = cache(now, 3600, bundled_entries);
let mut provider_entries = BTreeMap::new();
provider_entries.insert(
"sample/model".to_string(),
entry("sample/model", 9_000, MetadataProvenance::ProviderApi),
);
let future = cache(now + Duration::days(3650), 3600, provider_entries);
assert!(future.is_stale(now));
assert!(!cache(now, 3600, BTreeMap::new()).is_stale(now));
let merged = MergedCatalog::from_sources(BTreeMap::new(), Some(future), bundled, now);
let resolved = merged.resolve("sample/model").expect("resolved");
assert_eq!(resolved.context_window, Some(1_000));
}
#[test]
fn cache_roundtrip_serializes_no_secret_fields() {
let mut entries = BTreeMap::new();
entries.insert(
"sample/model".to_string(),
CatalogEntry {
input_usd_per_million: Some(0.25),
output_usd_per_million: Some(1.25),
..entry("sample/model", 32_000, MetadataProvenance::ProviderApi)
},
);
let cache = cache(Utc::now(), 60, entries);
let json = serde_json::to_string_pretty(&cache).expect("serialize");
let lowered = json.to_lowercase();
for forbidden in ["api_key", "authorization", "token", "secret"] {
assert!(
!lowered.contains(forbidden),
"cache JSON must not contain auth field {forbidden}: {json}"
);
}
let parsed: CatalogCache = serde_json::from_str(&json).expect("roundtrip");
assert_eq!(parsed.entries.len(), 1);
}
}
+18 -1
View File
@@ -124,7 +124,7 @@ pub struct RouteBillingEnvelope {
/// Provider/model route resolved for a model-backed turn.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TurnRoute {
/// `ApiProvider` key (`deepseek`, `openai`, `custom`, ...).
/// `ProviderKind` key (`deepseek`, `openai`, `custom`, ...).
pub provider: String,
/// Exact non-secret configured route key.
pub provider_identity: String,
@@ -356,6 +356,17 @@ pub enum EventMsg {
thread_id: ThreadId,
session_id: SessionId,
},
ToolExecutionStarted {
thread_id: ThreadId,
session_id: SessionId,
tool_call_id: String,
},
ToolResultContent {
thread_id: ThreadId,
session_id: SessionId,
tool_call_id: String,
blocks: Value,
},
ToolCallComplete {
thread_id: ThreadId,
session_id: SessionId,
@@ -853,6 +864,8 @@ impl EventMsg {
Self::ThinkingComplete { .. } => "thinking_complete",
Self::ToolCallStarted { .. } => "tool_call_started",
Self::ToolCallHeartbeat { .. } => "tool_call_heartbeat",
Self::ToolExecutionStarted { .. } => "tool_execution_started",
Self::ToolResultContent { .. } => "tool_result_content",
Self::ToolCallComplete { .. } => "tool_call_complete",
Self::OperationActivityStarted { .. } => "operation_activity_started",
Self::OperationActivityCompleted { .. } => "operation_activity_completed",
@@ -910,6 +923,8 @@ impl EventMsg {
| Self::ThinkingComplete { thread_id, .. }
| Self::ToolCallStarted { thread_id, .. }
| Self::ToolCallHeartbeat { thread_id, .. }
| Self::ToolExecutionStarted { thread_id, .. }
| Self::ToolResultContent { thread_id, .. }
| Self::ToolCallComplete { thread_id, .. }
| Self::OperationActivityStarted { thread_id, .. }
| Self::OperationActivityCompleted { thread_id, .. }
@@ -967,6 +982,8 @@ impl EventMsg {
| Self::ThinkingComplete { session_id, .. }
| Self::ToolCallStarted { session_id, .. }
| Self::ToolCallHeartbeat { session_id, .. }
| Self::ToolExecutionStarted { session_id, .. }
| Self::ToolResultContent { session_id, .. }
| Self::ToolCallComplete { session_id, .. }
| Self::OperationActivityStarted { session_id, .. }
| Self::OperationActivityCompleted { session_id, .. }
+224
View File
@@ -39,6 +39,221 @@ pub trait Status {
fn is_paused(&self) -> bool;
}
/// A single message entry in a conversation thread.
///
/// Messages form a tree structure via [`parent_entry_id`](Self::parent_entry_id),
/// enabling conversation branching and forking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageRecord {
/// Auto-incremented unique identifier for this message.
pub id: i64,
/// ID of the thread this message belongs to.
pub thread_id: String,
/// Role of the message sender (e.g. `"user"`, `"assistant"`, `"system"`).
pub role: String,
/// Text content of the message.
pub content: String,
/// Optional structured item payload (tool calls, tool results, etc.).
pub item: Option<Value>,
/// Unix timestamp (seconds) when the message was created.
pub created_at: i64,
/// ID of the parent message, forming a tree structure. `None` for root messages.
pub parent_entry_id: Option<i64>,
}
/// A complete immutable legacy SQLite graph, not a limit or active-branch projection.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyThreadHistory {
pub version: u32,
pub state_store_id: String,
pub thread_id: String,
pub current_leaf_id: Option<i64>,
pub messages: Vec<MessageRecord>,
/// Captured in the same SQLite snapshot; imported Active goals stay paused.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub goal: Option<ThreadGoal>,
}
pub const MAX_CANONICAL_HISTORY_ENTRIES: usize = 16_384;
pub const MAX_CANONICAL_HISTORY_BYTES: usize = 8 * 1024 * 1024;
/// An authenticated import proposal carries conversation data only. Routing and
/// permissions remain the current owner's create-thread policy.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanonicalHistoryImportRequest {
pub version: u32,
pub operation_key: String,
pub expected_data_dir: PathBuf,
pub expected_execution_scope: String,
/// Existing bare compatibility links attach their full source graph here;
/// the owner verifies the actual bound canonical document, never remints it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_runtime_thread_id: Option<String>,
pub workspace: PathBuf,
pub model: Option<String>,
pub history: LegacyThreadHistory,
}
/// Durable result of the actual canonical owner operation. Its scope fields are
/// captured from RuntimeStoreBinding; historical receipts never authenticate a
/// current process or convey credentials.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanonicalThreadReceipt {
pub version: u32,
pub data_dir: PathBuf,
pub execution_scope: String,
pub operation_key: String,
pub request_digest: String,
pub history_digest: String,
pub runtime_thread_id: String,
pub session_id: String,
}
/// A complete read projection from the actual Engine and its existing saved
/// journal. `session` retains the established SavedSession wire shape, including
/// every branch; it is data, never a policy or operation-commit receipt.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanonicalThreadSnapshot {
pub version: u32,
pub data_dir: PathBuf,
pub execution_scope: String,
pub runtime_thread_id: String,
pub saved_session_id: Option<String>,
pub saved_document_digest: Option<String>,
pub document_digest: String,
pub session_goal_digest: String,
pub session: Value,
}
/// One client-captured intent. The key is retained after an uncertain reply;
/// retrying it never allocates another canonical identity.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanonicalThreadMutationRequest {
pub version: u32,
pub operation_key: String,
pub expected_data_dir: PathBuf,
pub expected_execution_scope: String,
pub workspace: PathBuf,
pub mutation: CanonicalThreadMutation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub enum CanonicalThreadMutation {
/// The existing CreateThreadRequest shape, checked by the owner. Conversation
/// data and an imported document never supply a routing or permission ceiling.
Create { config: Value },
Resume {
source: CanonicalHistorySource,
#[serde(default)]
options: CanonicalHistoryOptions,
},
Fork {
source: CanonicalHistorySource,
#[serde(default)]
options: CanonicalHistoryOptions,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_entry_id: Option<String>,
},
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanonicalHistoryOptions {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub offered_history: Vec<Value>,
#[serde(default, skip_serializing_if = "Value::is_null")]
pub overrides: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_path: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expected_session_goal_digest: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CanonicalHistorySource {
Thread {
runtime_thread_id: String,
expected_document_digest: String,
},
/// Local mounted handoff supplies the already protected complete document;
/// the actual owner reopens it and compares the same whole-document digest.
SavedSession {
session: Value,
expected_document_digest: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CanonicalThreadOperationKind {
Create,
Resume,
Fork,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanonicalThreadOperationAssociation {
pub kind: CanonicalThreadOperationKind,
pub source_runtime_thread_id: Option<String>,
pub source_session_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanonicalThreadOperationLookup {
pub version: u32,
pub operation_key: String,
pub expected_data_dir: PathBuf,
pub expected_execution_scope: String,
pub workspace: PathBuf,
}
/// Explicit completion of an already-prepared retained intent, with no source proposal.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CanonicalThreadOperationRecovery {
pub operation: CanonicalThreadOperationLookup,
pub association: CanonicalThreadOperationAssociation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
pub enum CanonicalThreadOperationStatus {
Absent,
Pending {
receipt: CanonicalThreadReceipt,
association: CanonicalThreadOperationAssociation,
},
Committed {
receipt: CanonicalThreadReceipt,
association: CanonicalThreadOperationAssociation,
},
}
/// Closed routing receipt captured from the held Runtime owner, never a bearer.
/// Authentication also requires the actual connected kernel peer identity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RuntimeOwnerReceipt {
pub version: u32,
pub data_dir: PathBuf,
pub execution_scope: String,
pub lease_generation: String,
pub pid: u32,
pub process_start: String,
pub principal: String,
pub socket_path: PathBuf,
pub config_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope<T> {
pub request_id: String,
@@ -222,6 +437,9 @@ impl ThreadGoal {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadStartParams {
/// Captured once for this user intent and retained for an uncertain reply.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -234,6 +452,9 @@ pub struct ThreadStartParams {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadResumeParams {
/// Captured once for this user intent and retained for an uncertain reply.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_key: Option<String>,
pub thread_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub history: Option<Vec<Value>>,
@@ -263,6 +484,9 @@ pub struct ThreadResumeParams {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ThreadForkParams {
/// Captured once for this user intent and retained for an uncertain reply.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation_key: Option<String>,
pub thread_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<PathBuf>,
+34
View File
@@ -97,6 +97,7 @@ fn mcp_is_error_metadata_fails_closed_when_present_but_not_boolean() {
#[test]
fn thread_resume_params_round_trip() {
let request = ThreadRequest::Resume(ThreadResumeParams {
operation_key: None,
thread_id: "thread-123".to_string(),
history: None,
path: None,
@@ -431,3 +432,36 @@ fn submit_user_input_app_request_round_trip() {
assert_eq!(answers.len(), 1);
assert_eq!(answers[0].label, "All");
}
#[test]
fn retained_thread_operation_keys_are_additive_and_survive_explicit_retry() {
for (method, params) in [
("start", json!({})),
("resume", json!({"thread_id":"thread-123"})),
("fork", json!({"thread_id":"thread-123"})),
] {
let legacy = json!({"type":method,"params":params});
// Use the enum's actual serde tag rather than a second fixture protocol.
let mut wire = match method {
"start" => serde_json::to_value(ThreadRequest::Start(
serde_json::from_value(json!({})).unwrap(),
))
.unwrap(),
"resume" => serde_json::to_value(ThreadRequest::Resume(
serde_json::from_value(legacy["params"].clone()).unwrap(),
))
.unwrap(),
_ => serde_json::to_value(ThreadRequest::Fork(
serde_json::from_value(legacy["params"].clone()).unwrap(),
))
.unwrap(),
};
assert!(wire.get("operation_key").is_none());
wire["operation_key"] = json!("captured-client-intent");
let decoded: ThreadRequest = serde_json::from_value(wire).unwrap();
assert_eq!(
serde_json::to_value(decoded).unwrap()["operation_key"],
"captured-client-intent"
);
}
}
+1 -3
View File
@@ -161,8 +161,7 @@ fn catalog_model_identifier_is_allowlisted(value: &str) -> bool {
{
return false;
}
codewhale_models::model_catalog::resolved_entry(value)
.is_some_and(|entry| entry.id == value || entry.provider_model_id.as_deref() == Some(value))
codewhale_config::catalog::reviewed::public_model_identifier(value)
}
fn phrase_is_allowlisted(value: &str) -> bool {
@@ -442,7 +441,6 @@ mod tests {
#[test]
fn generic_identifiers_reject_all_slashes_and_catalog_models_require_exact_ids() {
let _catalog_guard = codewhale_models::model_catalog::test_catalog_lock();
for path in [
"src/lib.rs",
"docs/PREVIEW_REQUEST.md",
+349 -262
View File
@@ -1,13 +1,13 @@
//! Persistent state management for conversation threads, messages, and jobs.
//! Historical thread recovery, canonical owner receipts, metadata and jobs.
//!
//! The [`StateStore`] is the primary entry point, backed by a SQLite database and an
//! append-only JSONL session index file. It provides CRUD operations for:
//!
//! - **Threads** — conversation metadata, archival, and session indexing.
//! - **Messages** — append-only message storage with tree-structured branching.
//! - **Checkpoints** — named state snapshots for restoring conversation progress.
//! - **Jobs** — background task tracking with status and progress.
//! - **Dynamic tools** — per-thread tool registrations.
//! [`StateStore`] retains legacy conversation graphs as read-only archives.
//! A bounded absent-only restore can admit a complete historical graph in one
//! SQLite transaction; live transcript writes belong to Runtime's Engine and
//! SessionManager. Canonical aliases and durable operations use this same
//! SQLite connection. Thread metadata, jobs, dynamic tools and the session
//! index retain their existing ownership.
mod runtime_aliases;
use std::collections::HashMap;
use std::fs::{self, OpenOptions};
@@ -101,6 +101,21 @@ pub struct ThreadMetadata {
pub current_leaf_id: Option<i64>,
}
/// A complete historical SQLite thread, restored once into an absent target.
///
/// This is recovery/import data, never a live transcript writer. Entry IDs,
/// timestamps, all branches and the exact selected leaf are retained.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LegacyThreadArchive {
pub thread: ThreadMetadata,
pub messages: Vec<MessageRecord>,
#[serde(default)]
pub goal: Option<ThreadGoalRecord>,
#[serde(default)]
pub checkpoints: Vec<CheckpointRecord>,
}
/// A dynamically registered tool associated with a thread.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DynamicToolRecord {
@@ -114,31 +129,12 @@ pub struct DynamicToolRecord {
pub input_schema: Value,
}
/// A single message entry in a conversation thread.
///
/// Messages form a tree structure via [`parent_entry_id`](Self::parent_entry_id),
/// enabling conversation branching and forking.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageRecord {
/// Auto-incremented unique identifier for this message.
pub id: i64,
/// ID of the thread this message belongs to.
pub thread_id: String,
/// Role of the message sender (e.g. `"user"`, `"assistant"`, `"system"`).
pub role: String,
/// Text content of the message.
pub content: String,
/// Optional structured item payload (tool calls, tool results, etc.).
pub item: Option<Value>,
/// Unix timestamp (seconds) when the message was created.
pub created_at: i64,
/// ID of the parent message, forming a tree structure. `None` for root messages.
pub parent_entry_id: Option<i64>,
}
pub use codewhale_protocol::MessageRecord;
/// One message for [`StateStore::append_messages`].
/// Private historical database fixture for State unit tests.
#[cfg(test)]
#[derive(Debug, Clone)]
pub struct NewMessage {
struct NewMessage {
/// Role of the message sender (e.g. `"user"`, `"history"`).
pub role: String,
/// Text content of the message.
@@ -284,7 +280,7 @@ fn session_index_compact_line_threshold() -> usize {
}
/// The `user_version` the last step of `StateStore::migrate_schema` writes.
const SCHEMA_VERSION: u32 = 6;
const SCHEMA_VERSION: u32 = 7;
/// Persistent storage for conversation threads, messages, checkpoints, and jobs.
///
@@ -710,6 +706,16 @@ impl StateStore {
"#,
)
.context("failed to initialize thread runtime link schema")?;
user_version = 6;
}
if user_version < 7 {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS state_store_identity (singleton INTEGER PRIMARY KEY CHECK(singleton = 1), identity TEXT NOT NULL);
INSERT OR IGNORE INTO state_store_identity VALUES(1, lower(hex(randomblob(32))));
CREATE TABLE IF NOT EXISTS thread_runtime_receipts (thread_id TEXT PRIMARY KEY REFERENCES threads(id) ON DELETE CASCADE, receipt_json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS thread_runtime_operations (operation_key TEXT PRIMARY KEY, request_digest TEXT NOT NULL, receipt_json TEXT NOT NULL);
PRAGMA user_version = 7;",
).context("failed to initialize bound canonical Runtime receipts")?;
}
Ok(())
}
@@ -730,9 +736,20 @@ impl StateStore {
/// Record the runtime thread for `thread_id`. The thread must exist; the
/// link is removed with it.
pub fn set_runtime_thread_link(&self, thread_id: &str, runtime_thread_id: &str) -> Result<()> {
let conn = self.conn()?;
conn.execute(
#[cfg(test)]
fn set_runtime_thread_link(&self, thread_id: &str, runtime_thread_id: &str) -> Result<()> {
let mut conn = self.conn()?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let bound: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM thread_runtime_receipts WHERE thread_id = ?1)",
params![thread_id],
|row| row.get(0),
)?;
anyhow::ensure!(
!bound,
"bound canonical aliases cannot be changed by the legacy link writer"
);
tx.execute(
r#"
INSERT INTO thread_runtime_links(thread_id, runtime_thread_id, created_at)
VALUES (?1, ?2, ?3)
@@ -743,73 +760,17 @@ impl StateStore {
params![thread_id, runtime_thread_id, Utc::now().timestamp()],
)
.with_context(|| format!("failed to save runtime link for thread {thread_id}"))?;
tx.commit()?;
Ok(())
}
/// Insert or update thread metadata.
///
/// This does **not** update `current_leaf_id`; use [`append_message`](Self::append_message)
/// or [`set_current_leaf_id`](Self::set_current_leaf_id) for that.
/// This does **not** update legacy history or its selected leaf. Restoring an
/// old transcript requires an absent target and a complete immutable archive.
pub fn upsert_thread(&self, thread: &ThreadMetadata) -> Result<()> {
let conn = self.conn()?;
conn.execute(
r#"
INSERT INTO threads (
id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
git_sha, git_branch, git_origin_url, memory_mode
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21
)
ON CONFLICT(id) DO UPDATE SET
rollout_path=excluded.rollout_path,
preview=excluded.preview,
ephemeral=excluded.ephemeral,
model_provider=excluded.model_provider,
created_at=excluded.created_at,
updated_at=excluded.updated_at,
status=excluded.status,
path=excluded.path,
cwd=excluded.cwd,
cli_version=excluded.cli_version,
source=excluded.source,
title=excluded.title,
sandbox_policy=excluded.sandbox_policy,
approval_mode=excluded.approval_mode,
archived=excluded.archived,
archived_at=excluded.archived_at,
git_sha=excluded.git_sha,
git_branch=excluded.git_branch,
git_origin_url=excluded.git_origin_url,
memory_mode=excluded.memory_mode
"#,
params![
thread.id,
path_to_opt_string(thread.rollout_path.as_deref()),
thread.preview,
bool_to_i64(thread.ephemeral),
thread.model_provider,
thread.created_at,
thread.updated_at,
thread_status_to_str(&thread.status),
path_to_opt_string(thread.path.as_deref()),
thread.cwd.display().to_string(),
thread.cli_version,
session_source_to_str(&thread.source),
thread.name,
thread.sandbox_policy,
thread.approval_mode,
bool_to_i64(thread.archived),
thread.archived_at,
thread.git_sha,
thread.git_branch,
thread.git_origin_url,
thread.memory_mode,
],
)
.context("failed to upsert thread metadata")?;
write_thread_metadata_on(&conn, thread)?;
self.append_thread_name(
&thread.id,
@@ -919,72 +880,10 @@ impl StateStore {
}
/// Insert or replace the persisted goal for a thread.
pub fn upsert_thread_goal(&self, goal: &ThreadGoalRecord) -> Result<()> {
codewhale_protocol::validate_goal_stall_state(
goal.last_gap_fingerprint.as_deref(),
goal.repeated_gap_count,
goal.last_gap_pass,
u32::try_from(goal.continuation_count.max(0)).unwrap_or(u32::MAX),
)
.map_err(anyhow::Error::msg)?;
let pause_reason = goal
.pause_reason
.map(|reason| serde_json::to_string(&reason))
.transpose()?;
#[cfg(test)]
fn upsert_thread_goal(&self, goal: &ThreadGoalRecord) -> Result<()> {
let conn = self.conn()?;
let exists: Option<i64> = conn
.query_row(
"SELECT 1 FROM threads WHERE id = ?1",
params![goal.thread_id],
|row| row.get(0),
)
.optional()
.context("failed to verify thread before saving goal")?;
if exists.is_none() {
anyhow::bail!("thread {} not found", goal.thread_id);
}
conn.execute(
r#"
INSERT INTO thread_goals (
thread_id, goal_id, objective, status, token_budget, tokens_used,
time_used_seconds, continuation_count, created_at, updated_at,
last_gap_fingerprint, repeated_gap_count, last_gap_pass, pause_reason
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT(thread_id) DO UPDATE SET
goal_id=excluded.goal_id,
objective=excluded.objective,
status=excluded.status,
token_budget=excluded.token_budget,
tokens_used=excluded.tokens_used,
time_used_seconds=excluded.time_used_seconds,
continuation_count=excluded.continuation_count,
created_at=excluded.created_at,
updated_at=excluded.updated_at,
last_gap_fingerprint=excluded.last_gap_fingerprint,
repeated_gap_count=excluded.repeated_gap_count,
last_gap_pass=excluded.last_gap_pass,
pause_reason=excluded.pause_reason
"#,
params![
goal.thread_id,
goal.goal_id,
goal.objective,
thread_goal_status_to_str(&goal.status),
goal.token_budget,
goal.tokens_used,
goal.time_used_seconds,
goal.continuation_count,
goal.created_at,
goal.updated_at,
goal.last_gap_fingerprint,
goal.repeated_gap_count,
goal.last_gap_pass,
pause_reason,
],
)
.context("failed to upsert thread goal")?;
Ok(())
write_thread_goal_on(&conn, goal)
}
/// Accrue additional token and wall-clock usage onto a thread's persisted goal.
@@ -1000,9 +899,10 @@ impl StateStore {
/// which is intentionally left to the caller's discretion).
///
/// Returns the updated [`ThreadGoalRecord`], or `Ok(None)` if the thread has no
/// persisted goal. Unlike [`upsert_thread_goal`](Self::upsert_thread_goal) this never
/// persisted goal. Unlike the historical fixture upsert this never
/// creates a goal row; it only accumulates onto an existing one.
pub fn record_thread_goal_usage(
#[cfg(test)]
fn record_thread_goal_usage(
&self,
thread_id: &str,
token_delta: i64,
@@ -1033,7 +933,8 @@ impl StateStore {
/// The older TUI continuation guard is scoped to one engine turn. This
/// counter is intentionally persisted so a resumed goal loop can feed
/// `goal_loop::decide_continuation` with the true cross-turn count.
pub fn record_thread_goal_continuation(
#[cfg(test)]
fn record_thread_goal_continuation(
&self,
thread_id: &str,
now: i64,
@@ -1063,9 +964,22 @@ impl StateStore {
}
/// Read a goal on an already-held connection. The `record_*` mutators call
/// this instead of [`Self::get_thread_goal`], which would re-lock the
/// this instead of the public goal reader, which would re-lock the
/// connection mutex and self-deadlock.
fn read_thread_goal(conn: &Connection, thread_id: &str) -> Result<Option<ThreadGoalRecord>> {
let mut goal = Self::read_thread_goal_snapshot(conn, thread_id)?;
if let Some(goal) = goal.as_mut() {
goal.normalize_restored_stall_state();
}
Ok(goal)
}
/// Immutable migration/CAS snapshot keeps the original persisted status.
/// Restore normalization belongs to the reader/target, never source CAS.
fn read_thread_goal_snapshot(
conn: &Connection,
thread_id: &str,
) -> Result<Option<ThreadGoalRecord>> {
conn.query_row(
r#"
SELECT thread_id, goal_id, objective, status, token_budget, tokens_used,
@@ -1082,7 +996,8 @@ impl StateStore {
}
/// Delete the persisted goal for a thread.
pub fn delete_thread_goal(&self, thread_id: &str) -> Result<bool> {
#[cfg(test)]
fn delete_thread_goal(&self, thread_id: &str) -> Result<bool> {
let conn = self.conn()?;
let changed = conn
.execute(
@@ -1135,20 +1050,6 @@ impl StateStore {
Ok(out)
}
/// Update the current leaf message pointer for a thread.
///
/// This controls which branch of the conversation tree is considered active
/// when listing messages via [`list_messages`](Self::list_messages).
pub fn set_current_leaf_id(&self, thread_id: &str, current_leaf_id: &str) -> Result<()> {
let conn = self.conn()?;
conn.execute(
"UPDATE threads SET current_leaf_id = ?1 WHERE id = ?2",
params![current_leaf_id, thread_id],
)
.context("failed to update thread current leaf id")?;
Ok(())
}
/// Replace the dynamic tools for a thread.
///
/// All existing dynamic tools for the thread are deleted and replaced with the
@@ -1218,7 +1119,8 @@ impl StateStore {
/// The message is linked to the thread's current leaf as its parent, and the
/// thread's `current_leaf_id` is updated to the new message. Returns the ID
/// of the newly created message.
pub fn append_message(
#[cfg(test)]
fn append_message(
&self,
thread_id: &str,
role: &str,
@@ -1244,7 +1146,8 @@ impl StateStore {
/// current leaf), and the leaf ends on the last. All or nothing: a failure
/// part-way leaves the thread exactly as it was, never a partial history.
/// Returns the new message ids in order.
pub fn append_messages(&self, thread_id: &str, messages: &[NewMessage]) -> Result<Vec<i64>> {
#[cfg(test)]
fn append_messages(&self, thread_id: &str, messages: &[NewMessage]) -> Result<Vec<i64>> {
if messages.is_empty() {
return Ok(Vec::new());
}
@@ -1369,68 +1272,11 @@ impl StateStore {
Ok(out)
}
/// Fork the conversation at a specific message.
///
/// Creates a new message whose parent is `message_id` and updates the thread's
/// `current_leaf_id` to the new message. Returns the ID of the new message.
/// This enables branching conversations from any point in the history.
pub fn fork_at_message(
&self,
message_id: &str,
role: &str,
content: &str,
item: Option<Value>,
) -> Result<i64> {
let mut conn = self.conn()?;
let created_at = Utc::now().timestamp();
let item_json = item
.as_ref()
.map(serde_json::to_string)
.transpose()
.context("failed to serialize message item payload")?;
let tx = conn
.transaction()
.context("failed to begin fork message transaction")?;
let thread_id: String = tx
.query_row(
"SELECT thread_id FROM messages WHERE id = ?1",
params![message_id],
|row| row.get(0),
)
.with_context(|| format!("failed to query thread id for message {message_id}"))?;
let next_leaf_id: i64 = tx.query_row(
r#"
INSERT INTO messages(thread_id, role, content, item_json, created_at, parent_entry_id)
SELECT ?1, ?2, ?3, ?4, ?5, ?6
RETURNING id
"#, params![thread_id, role, content, item_json, created_at, message_id], |row| row.get(0)
).with_context(|| format!("failed to fork at message for thread {thread_id:?}"))?;
tx.execute(
r#"
UPDATE threads
SET current_leaf_id = ?1
WHERE id = ?2;
"#,
params![next_leaf_id, thread_id],
)
.with_context(|| {
format!("failed to update thread current leaf id for thread {thread_id:?}")
})?;
tx.commit()
.context("failed to commit fork message transaction")?;
Ok(next_leaf_id)
}
/// Delete all messages belonging to a thread and reset its `current_leaf_id`.
///
/// Returns the number of messages deleted.
pub fn clear_messages(&self, thread_id: &str) -> Result<usize> {
#[cfg(test)]
fn clear_messages(&self, thread_id: &str) -> Result<usize> {
let mut conn = self.conn()?;
let tx = conn
.transaction()
@@ -1463,12 +1309,8 @@ impl StateStore {
///
/// If a checkpoint with the same `thread_id` and `checkpoint_id` already exists,
/// its state and timestamp are overwritten.
pub fn save_checkpoint(
&self,
thread_id: &str,
checkpoint_id: &str,
state: &Value,
) -> Result<()> {
#[cfg(test)]
fn save_checkpoint(&self, thread_id: &str, checkpoint_id: &str, state: &Value) -> Result<()> {
let conn = self.conn()?;
let state_json =
serde_json::to_string(state).context("failed to encode checkpoint state")?;
@@ -1589,19 +1431,6 @@ impl StateStore {
Ok(out)
}
/// Delete a specific checkpoint from a thread.
pub fn delete_checkpoint(&self, thread_id: &str, checkpoint_id: &str) -> Result<()> {
let conn = self.conn()?;
conn.execute(
"DELETE FROM checkpoints WHERE thread_id = ?1 AND checkpoint_id = ?2",
params![thread_id, checkpoint_id],
)
.with_context(|| {
format!("failed to delete checkpoint {checkpoint_id} for thread {thread_id}")
})?;
Ok(())
}
/// Insert or update a background job record.
pub fn upsert_job(&self, job: &JobStateRecord) -> Result<()> {
let conn = self.conn()?;
@@ -1989,6 +1818,135 @@ pub fn default_state_db_path() -> PathBuf {
}
}
fn write_thread_metadata_on(conn: &Connection, thread: &ThreadMetadata) -> Result<()> {
conn.execute(
r#"
INSERT INTO threads (
id, rollout_path, preview, ephemeral, model_provider, created_at, updated_at, status, path, cwd,
cli_version, source, title, sandbox_policy, approval_mode, archived, archived_at,
git_sha, git_branch, git_origin_url, memory_mode
) VALUES (
?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
?11, ?12, ?13, ?14, ?15, ?16, ?17,
?18, ?19, ?20, ?21
)
ON CONFLICT(id) DO UPDATE SET
rollout_path=excluded.rollout_path,
preview=excluded.preview,
ephemeral=excluded.ephemeral,
model_provider=excluded.model_provider,
created_at=excluded.created_at,
updated_at=excluded.updated_at,
status=excluded.status,
path=excluded.path,
cwd=excluded.cwd,
cli_version=excluded.cli_version,
source=excluded.source,
title=excluded.title,
sandbox_policy=excluded.sandbox_policy,
approval_mode=excluded.approval_mode,
archived=excluded.archived,
archived_at=excluded.archived_at,
git_sha=excluded.git_sha,
git_branch=excluded.git_branch,
git_origin_url=excluded.git_origin_url,
memory_mode=excluded.memory_mode
"#,
params![
thread.id,
path_to_opt_string(thread.rollout_path.as_deref()),
thread.preview,
bool_to_i64(thread.ephemeral),
thread.model_provider,
thread.created_at,
thread.updated_at,
thread_status_to_str(&thread.status),
path_to_opt_string(thread.path.as_deref()),
thread.cwd.display().to_string(),
thread.cli_version,
session_source_to_str(&thread.source),
thread.name,
thread.sandbox_policy,
thread.approval_mode,
bool_to_i64(thread.archived),
thread.archived_at,
thread.git_sha,
thread.git_branch,
thread.git_origin_url,
thread.memory_mode,
],
)
.context("failed to upsert thread metadata")?;
Ok(())
}
fn write_thread_goal_on(conn: &Connection, goal: &ThreadGoalRecord) -> Result<()> {
codewhale_protocol::validate_goal_stall_state(
goal.last_gap_fingerprint.as_deref(),
goal.repeated_gap_count,
goal.last_gap_pass,
u32::try_from(goal.continuation_count.max(0)).unwrap_or(u32::MAX),
)
.map_err(anyhow::Error::msg)?;
let pause_reason = goal
.pause_reason
.map(|reason| serde_json::to_string(&reason))
.transpose()?;
let exists: Option<i64> = conn
.query_row(
"SELECT 1 FROM threads WHERE id = ?1",
params![goal.thread_id],
|row| row.get(0),
)
.optional()
.context("failed to verify thread before saving goal")?;
if exists.is_none() {
anyhow::bail!("thread {} not found", goal.thread_id);
}
conn.execute(
r#"
INSERT INTO thread_goals (
thread_id, goal_id, objective, status, token_budget, tokens_used,
time_used_seconds, continuation_count, created_at, updated_at,
last_gap_fingerprint, repeated_gap_count, last_gap_pass, pause_reason
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT(thread_id) DO UPDATE SET
goal_id=excluded.goal_id,
objective=excluded.objective,
status=excluded.status,
token_budget=excluded.token_budget,
tokens_used=excluded.tokens_used,
time_used_seconds=excluded.time_used_seconds,
continuation_count=excluded.continuation_count,
created_at=excluded.created_at,
updated_at=excluded.updated_at,
last_gap_fingerprint=excluded.last_gap_fingerprint,
repeated_gap_count=excluded.repeated_gap_count,
last_gap_pass=excluded.last_gap_pass,
pause_reason=excluded.pause_reason
"#,
params![
goal.thread_id,
goal.goal_id,
goal.objective,
thread_goal_status_to_str(&goal.status),
goal.token_budget,
goal.tokens_used,
goal.time_used_seconds,
goal.continuation_count,
goal.created_at,
goal.updated_at,
goal.last_gap_fingerprint,
goal.repeated_gap_count,
goal.last_gap_pass,
pause_reason,
],
)
.context("failed to upsert thread goal")?;
Ok(())
}
fn bool_to_i64(value: bool) -> i64 {
if value { 1 } else { 0 }
}
@@ -2148,7 +2106,7 @@ fn row_to_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadMetadata> {
fn row_to_thread_goal(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadGoalRecord> {
let status_raw: String = row.get(3)?;
let pause_reason: Option<String> = row.get(13)?;
let mut goal = ThreadGoalRecord {
let goal = ThreadGoalRecord {
thread_id: row.get(0)?,
goal_id: row.get(1)?,
objective: row.get(2)?,
@@ -2186,7 +2144,6 @@ fn row_to_thread_goal(row: &rusqlite::Row<'_>) -> rusqlite::Result<ThreadGoalRec
Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, error)),
)
})?;
goal.normalize_restored_stall_state();
Ok(goal)
}
@@ -3133,4 +3090,134 @@ mod tests {
Some("trigger-name"),
);
}
fn legacy_archive_fixture(id: &str) -> LegacyThreadArchive {
let mut thread = test_thread(id);
thread.current_leaf_id = Some(3);
LegacyThreadArchive {
thread,
messages: [
(1, None, "root"),
(2, Some(1), "inactive"),
(3, Some(1), "active"),
]
.into_iter()
.map(|(entry_id, parent_entry_id, content)| MessageRecord {
id: entry_id,
thread_id: id.into(),
role: "user".into(),
content: content.into(),
item: Some(serde_json::json!({"content":content})),
created_at: 100 + entry_id,
parent_entry_id,
})
.collect(),
goal: Some(test_goal(id, "historical goal")),
checkpoints: vec![CheckpointRecord {
thread_id: id.into(),
checkpoint_id: "retained".into(),
state: serde_json::json!({"receipt":"historical"}),
created_at: 123,
}],
}
}
#[test]
fn legacy_archive_restore_keeps_full_graph_goal_checkpoint_and_refuses_replacement() {
let store = temp_state_store("archive-once");
let archive = legacy_archive_fixture("legacy");
store.restore_legacy_thread_archive(&archive).unwrap();
let snapshot = store.snapshot_legacy_thread_history("legacy").unwrap();
assert_eq!(
serde_json::to_value(&snapshot.messages).unwrap(),
serde_json::to_value(&archive.messages).unwrap()
);
assert_eq!(snapshot.current_leaf_id, Some(3));
assert_eq!(store.list_messages("legacy", None).unwrap().len(), 2);
let goal = snapshot.goal.unwrap();
assert_eq!(goal.status, codewhale_protocol::ThreadGoalStatus::Active);
assert_eq!(goal.tokens_used, archive.goal.as_ref().unwrap().tokens_used);
assert_eq!(goal.updated_at, archive.goal.as_ref().unwrap().updated_at);
let checkpoint = store
.load_checkpoint("legacy", Some("retained"))
.unwrap()
.unwrap();
assert_eq!(checkpoint.created_at, 123);
assert_eq!(checkpoint.state, archive.checkpoints[0].state);
let mut replacement = archive.clone();
replacement.messages[1].content = "changed inactive history".into();
assert!(store.restore_legacy_thread_archive(&replacement).is_err());
assert_eq!(
store
.snapshot_legacy_thread_history("legacy")
.unwrap()
.messages[1]
.content,
"inactive"
);
}
#[test]
fn legacy_archive_restore_refuses_cycles_duplicates_foreign_rows_and_bounds_without_publication()
{
let store = temp_state_store("archive-refusals");
let valid = legacy_archive_fixture("legacy");
let mut invalid = Vec::new();
let mut archive = valid.clone();
archive.messages[0].parent_entry_id = Some(2);
invalid.push(archive);
let mut archive = valid.clone();
archive.messages[1].id = 1;
invalid.push(archive);
let mut archive = valid.clone();
archive.messages[1].parent_entry_id = Some(99);
invalid.push(archive);
let mut archive = valid.clone();
archive.messages[1].thread_id = "foreign".into();
invalid.push(archive);
let mut archive = valid.clone();
archive.thread.current_leaf_id = Some(99);
invalid.push(archive);
let mut archive = valid.clone();
archive.goal.as_mut().unwrap().thread_id = "foreign".into();
invalid.push(archive);
let mut archive = valid.clone();
archive.checkpoints[0].thread_id = "foreign".into();
invalid.push(archive);
let mut archive = valid.clone();
archive.checkpoints.push(archive.checkpoints[0].clone());
invalid.push(archive);
let mut archive = valid.clone();
archive.messages[0].content =
"x".repeat(codewhale_protocol::MAX_CANONICAL_HISTORY_BYTES + 1);
invalid.push(archive);
for archive in invalid {
assert!(store.restore_legacy_thread_archive(&archive).is_err());
assert!(store.get_thread("legacy").unwrap().is_none());
}
store.restore_legacy_thread_archive(&valid).unwrap();
}
#[test]
fn legacy_archive_restore_rolls_back_metadata_messages_goal_on_late_checkpoint_failure() {
let store = temp_state_store("archive-transaction");
store.conn().unwrap().execute_batch("CREATE TRIGGER refuse_archive_checkpoint BEFORE INSERT ON checkpoints BEGIN SELECT RAISE(ABORT, 'fixture checkpoint failure'); END;").unwrap();
let archive = legacy_archive_fixture("legacy");
assert!(store.restore_legacy_thread_archive(&archive).is_err());
let conn = store.conn().unwrap();
for table in ["threads", "messages", "thread_goals", "checkpoints"] {
let count: i64 = conn
.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(
count, 0,
"{table} must roll back with the immutable archive"
);
}
conn.execute_batch("DROP TRIGGER refuse_archive_checkpoint")
.unwrap();
drop(conn);
store.restore_legacy_thread_archive(&archive).unwrap();
}
}
+352
View File
@@ -0,0 +1,352 @@
//! Compatibility receipts on the existing StateStore connection. These records
//! describe committed canonical work; they never execute or own a conversation.
use super::*;
use codewhale_protocol::{
CanonicalThreadReceipt, LegacyThreadHistory, MAX_CANONICAL_HISTORY_BYTES,
MAX_CANONICAL_HISTORY_ENTRIES, RuntimeOwnerReceipt,
};
impl StateStore {
/// Restore an immutable historical archive into an absent SQLite thread.
/// Validation and every row write share one transaction. Existing targets,
/// canonical aliases and partial/cyclic/oversized archives are refused.
pub fn restore_legacy_thread_archive(&self, archive: &LegacyThreadArchive) -> Result<()> {
validate_legacy_archive(archive)?;
let mut conn = self.conn()?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
let occupied: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM threads WHERE id = ?1) OR EXISTS(SELECT 1 FROM thread_runtime_links WHERE thread_id = ?1) OR EXISTS(SELECT 1 FROM thread_runtime_receipts WHERE thread_id = ?1)",
params![archive.thread.id], |row| row.get(0),
)?;
anyhow::ensure!(
!occupied,
"legacy archive target is already present; no replacement or append is permitted"
);
write_thread_metadata_on(&tx, &archive.thread)?;
for message in &archive.messages {
tx.execute(
"INSERT INTO messages(id, thread_id, role, content, item_json, created_at, parent_entry_id) VALUES(?1,?2,?3,?4,?5,?6,?7)",
params![message.id, message.thread_id, message.role, message.content,
message.item.as_ref().map(serde_json::to_string).transpose()?, message.created_at, message.parent_entry_id],
)?;
}
tx.execute(
"UPDATE threads SET current_leaf_id = ?1 WHERE id = ?2",
params![archive.thread.current_leaf_id, archive.thread.id],
)?;
if let Some(goal) = &archive.goal {
write_thread_goal_on(&tx, goal)?;
}
for checkpoint in &archive.checkpoints {
tx.execute("INSERT INTO checkpoints(thread_id, checkpoint_id, state_json, created_at) VALUES(?1,?2,?3,?4)", params![checkpoint.thread_id, checkpoint.checkpoint_id, serde_json::to_string(&checkpoint.state)?, checkpoint.created_at])?;
}
tx.commit()?;
Ok(())
}
/// Read all branches and the active leaf in one SQLite read transaction.
/// Bounds are checked against SQLite lengths before retaining payloads.
pub fn snapshot_legacy_thread_history(&self, thread_id: &str) -> Result<LegacyThreadHistory> {
let mut conn = self.conn()?;
let tx = conn.transaction()?;
let snapshot = snapshot_history_on(&tx, thread_id)?;
tx.commit()?;
Ok(snapshot)
}
pub fn get_canonical_runtime_link(
&self,
thread_id: &str,
owner: &RuntimeOwnerReceipt,
) -> Result<Option<CanonicalThreadReceipt>> {
let conn = self.conn()?;
let record: Option<(String, Option<String>)> = conn.query_row(
"SELECT r.receipt_json, l.runtime_thread_id FROM thread_runtime_receipts r LEFT JOIN thread_runtime_links l ON l.thread_id = r.thread_id WHERE r.thread_id = ?1",
params![thread_id], |row| Ok((row.get(0)?, row.get(1)?)),
).optional()?;
let Some((encoded, linked)) = record else {
return Ok(None);
};
let receipt: CanonicalThreadReceipt = serde_json::from_str(&encoded)?;
validate_canonical_receipt(&receipt)?;
anyhow::ensure!(
Some(receipt.runtime_thread_id.as_str()) == linked.as_deref(),
"legacy link changed behind its bound canonical receipt; recovery required"
);
anyhow::ensure!(
receipt.data_dir == owner.data_dir && receipt.execution_scope == owner.execution_scope,
"thread belongs to a different canonical Runtime store; owner attachment required"
);
Ok(Some(receipt))
}
/// Publish only an already committed owner receipt, comparing the previous
/// link under the same write transaction. Cancellation of the outer waiter
/// cannot roll back a committed canonical operation into a remint.
pub fn publish_canonical_runtime_link(
&self,
thread_id: &str,
expected_previous: Option<&str>,
expected_history: &LegacyThreadHistory,
owner: &RuntimeOwnerReceipt,
receipt: &CanonicalThreadReceipt,
) -> Result<()> {
validate_canonical_receipt(receipt)?;
anyhow::ensure!(
receipt.data_dir == owner.data_dir && receipt.execution_scope == owner.execution_scope,
"canonical receipt does not match the authenticated owner"
);
let encoded = serde_json::to_string(receipt)?;
let mut conn = self.conn()?;
let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
anyhow::ensure!(
expected_history.thread_id == thread_id
&& serde_json::to_value(snapshot_history_on(&tx, thread_id)?)?
== serde_json::to_value(expected_history)?,
"legacy history changed before canonical publication; source and canonical result retained for recovery"
);
let exists: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM threads WHERE id = ?1)",
params![thread_id],
|row| row.get(0),
)?;
anyhow::ensure!(
exists,
"cannot publish canonical link for an absent compatibility thread"
);
let previous: Option<String> = tx
.query_row(
"SELECT runtime_thread_id FROM thread_runtime_links WHERE thread_id = ?1",
params![thread_id],
|row| row.get(0),
)
.optional()?;
let saved: Option<String> = tx
.query_row(
"SELECT receipt_json FROM thread_runtime_receipts WHERE thread_id = ?1",
params![thread_id],
|row| row.get(0),
)
.optional()?;
if saved.as_deref() == Some(&encoded)
&& previous.as_deref() == Some(&receipt.runtime_thread_id)
{
tx.commit()?;
return Ok(());
}
anyhow::ensure!(
saved.is_none(),
"canonical alias is already committed to another operation; recovery required"
);
anyhow::ensure!(
previous.as_deref() == expected_previous,
"compatibility link changed before canonical publication"
);
let operation: Option<(String, String)> = tx.query_row("SELECT request_digest, receipt_json FROM thread_runtime_operations WHERE operation_key = ?1", params![receipt.operation_key], |row| Ok((row.get(0)?, row.get(1)?))).optional()?;
if let Some((digest, saved)) = operation {
anyhow::ensure!(
digest == receipt.request_digest && saved == encoded,
"canonical operation key was used for different data"
);
}
tx.execute("INSERT OR IGNORE INTO thread_runtime_operations(operation_key, request_digest, receipt_json) VALUES(?1, ?2, ?3)", params![receipt.operation_key, receipt.request_digest, encoded])?;
tx.execute("INSERT INTO thread_runtime_links(thread_id, runtime_thread_id, created_at) VALUES(?1, ?2, ?3) ON CONFLICT(thread_id) DO UPDATE SET runtime_thread_id = excluded.runtime_thread_id, created_at = excluded.created_at", params![thread_id, receipt.runtime_thread_id, Utc::now().timestamp()])?;
tx.execute(
"INSERT INTO thread_runtime_receipts(thread_id, receipt_json) VALUES(?1, ?2)",
params![thread_id, encoded],
)?;
tx.commit()?;
Ok(())
}
}
fn validate_canonical_receipt(receipt: &CanonicalThreadReceipt) -> Result<()> {
anyhow::ensure!(
receipt.version == 1
&& receipt.data_dir.is_absolute()
&& !receipt.execution_scope.is_empty()
&& receipt.execution_scope.len() <= 128,
"invalid canonical store receipt"
);
anyhow::ensure!(
!receipt.operation_key.is_empty()
&& receipt.operation_key.len() <= 128
&& !receipt.operation_key.chars().any(char::is_control),
"invalid canonical operation key"
);
for digest in [&receipt.request_digest, &receipt.history_digest] {
anyhow::ensure!(
digest.len() == 64
&& digest
.bytes()
.all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()),
"invalid canonical operation digest"
);
}
for id in [&receipt.runtime_thread_id, &receipt.session_id] {
anyhow::ensure!(
!id.is_empty()
&& id.len() <= 128
&& id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')),
"invalid canonical record identity"
);
}
Ok(())
}
fn snapshot_history_on(conn: &Connection, thread_id: &str) -> Result<LegacyThreadHistory> {
let leaf = conn.query_row(
"SELECT current_leaf_id FROM threads WHERE id = ?1",
params![thread_id],
|row| row.get::<_, Option<i64>>(0),
)?;
let identity: String = conn.query_row(
"SELECT identity FROM state_store_identity WHERE singleton = 1",
[],
|row| row.get(0),
)?;
let (count, bytes): (i64, i64) = conn.query_row(
"SELECT count(*), coalesce(sum(length(cast(role AS BLOB)) + length(cast(content AS BLOB)) + coalesce(length(cast(item_json AS BLOB)), 0) + 128), 0) FROM messages WHERE thread_id = ?1",
params![thread_id], |row| Ok((row.get(0)?, row.get(1)?)),
)?;
anyhow::ensure!(
count >= 0
&& count as u64 <= MAX_CANONICAL_HISTORY_ENTRIES as u64
&& bytes >= 0
&& bytes as u64 <= MAX_CANONICAL_HISTORY_BYTES as u64,
"legacy history exceeds canonical import bounds; source retained for recovery"
);
let mut messages = Vec::with_capacity(count as usize);
{
let mut statement = conn.prepare("SELECT id, role, content, item_json, created_at, parent_entry_id FROM messages WHERE thread_id = ?1 ORDER BY id")?;
let mut rows = statement.query(params![thread_id])?;
while let Some(row) = rows.next()? {
let raw: Option<String> = row.get(3)?;
messages.push(MessageRecord {
id: row.get(0)?,
thread_id: thread_id.into(),
role: row.get(1)?,
content: row.get(2)?,
item: raw
.as_deref()
.map(serde_json::from_str)
.transpose()
.context("unsupported legacy JSON; source retained for recovery")?,
created_at: row.get(4)?,
parent_entry_id: row.get(5)?,
});
}
}
let snapshot = LegacyThreadHistory {
version: 1,
state_store_id: identity,
thread_id: thread_id.into(),
current_leaf_id: leaf,
messages,
goal: StateStore::read_thread_goal_snapshot(conn, thread_id)?
.map(|goal| serde_json::from_value(serde_json::to_value(goal)?))
.transpose()?,
};
anyhow::ensure!(
serde_json::to_vec(&snapshot)?.len() <= MAX_CANONICAL_HISTORY_BYTES,
"encoded legacy history exceeds canonical import bounds; source retained for recovery"
);
Ok(snapshot)
}
fn validate_legacy_archive(archive: &LegacyThreadArchive) -> Result<()> {
anyhow::ensure!(
!archive.thread.id.is_empty()
&& archive.thread.id.len() <= 128
&& !archive.thread.id.chars().any(char::is_control),
"invalid legacy archive thread identity"
);
anyhow::ensure!(
archive
.messages
.len()
.saturating_add(archive.checkpoints.len())
<= MAX_CANONICAL_HISTORY_ENTRIES,
"legacy archive exceeds entry bounds; source retained"
);
struct Measure(usize);
impl std::io::Write for Measure {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if bytes.len() > self.0 {
return Err(std::io::Error::other("legacy archive exceeds byte bounds"));
}
self.0 -= bytes.len();
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
serde_json::to_writer(&mut Measure(MAX_CANONICAL_HISTORY_BYTES), archive)?;
let mut parents = HashMap::new();
for message in &archive.messages {
anyhow::ensure!(
message.thread_id == archive.thread.id && message.id > 0 && !message.role.is_empty(),
"invalid or foreign legacy archive entry"
);
anyhow::ensure!(
parents
.insert(message.id, message.parent_entry_id)
.is_none(),
"duplicate legacy archive entry"
);
}
anyhow::ensure!(
match archive.thread.current_leaf_id {
Some(leaf) => parents.contains_key(&leaf),
None => parents.is_empty(),
},
"legacy archive has no valid selected leaf"
);
let mut completed = std::collections::HashSet::new();
for &id in parents.keys() {
let mut path = std::collections::HashSet::new();
let mut cursor = Some(id);
while let Some(id) = cursor {
if completed.contains(&id) {
break;
}
anyhow::ensure!(path.insert(id), "cyclic legacy archive graph");
cursor = *parents
.get(&id)
.ok_or_else(|| anyhow::anyhow!("missing legacy archive parent"))?;
}
completed.extend(path);
}
if let Some(goal) = &archive.goal {
anyhow::ensure!(
goal.thread_id == archive.thread.id
&& !goal.goal_id.is_empty()
&& !goal.objective.trim().is_empty()
&& goal.tokens_used >= 0
&& goal.time_used_seconds >= 0
&& goal.continuation_count >= 0
&& goal.token_budget.is_none_or(|budget| budget >= 0),
"invalid or foreign legacy archive goal"
);
codewhale_protocol::validate_goal_stall_state(
goal.last_gap_fingerprint.as_deref(),
goal.repeated_gap_count,
goal.last_gap_pass,
u32::try_from(goal.continuation_count).map_err(anyhow::Error::from)?,
)
.map_err(anyhow::Error::msg)?;
}
let mut checkpoints = std::collections::HashSet::new();
for checkpoint in &archive.checkpoints {
anyhow::ensure!(
checkpoint.thread_id == archive.thread.id
&& !checkpoint.checkpoint_id.is_empty()
&& checkpoints.insert(&checkpoint.checkpoint_id),
"invalid, foreign or duplicate legacy archive checkpoint"
);
}
Ok(())
}
+323 -102
View File
@@ -20,7 +20,8 @@ fn assert_workflow_trace_schema(conn: &Connection) {
// `repeated_gap_count`, `last_gap_pass` and `pause_reason` on top of the v4
// continuation-count column and the v3 workflow-trace + thread_goals tables.
// v6 adds `thread_runtime_links` (client thread -> runtime thread).
assert_eq!(user_version, 6);
// v7 adds bound canonical alias/operation receipts on this same connection.
assert_eq!(user_version, 7);
for table in [
"workflow_runs",
@@ -30,6 +31,9 @@ fn assert_workflow_trace_schema(conn: &Connection) {
"teacher_candidates",
"thread_goals",
"thread_runtime_links",
"state_store_identity",
"thread_runtime_receipts",
"thread_runtime_operations",
] {
let exists: bool = conn
.query_row(
@@ -358,124 +362,341 @@ fn init_schema_migration_same_second_messages() {
#[test]
fn test_fork() {
let path = temp_state_path("test_fork");
let store = StateStore::open(Some(path.clone())).expect("open state store");
// Historical branching remains readable in every selected projection; live
// fork/mutation now belongs to the canonical Runtime owner.
for (leaf, expected) in [
(5, vec![0, 1, 2, 3, 4]),
(6, vec![0, 1, 2, 5]),
(7, vec![0, 1, 2, 3, 4, 6]),
] {
let (_path, store, _, _) = canonical_alias_fixture(
&format!("archived-fork-{leaf}"),
(0..7)
.map(|index| {
let parent = match index {
0 => None,
5 => Some(3),
6 => Some(5),
_ => Some(index),
};
legacy_entry(
index + 1,
parent,
&format!("foo{index}"),
&format!("bar{index}"),
)
})
.collect(),
Some(leaf),
None,
);
let messages = store.list_messages("legacy-import", None).unwrap();
assert_eq!(messages.len(), expected.len());
for (message, index) in messages.iter().zip(expected) {
assert_eq!(message.role, format!("foo{index}"));
assert_eq!(message.content, format!("bar{index}"));
assert_eq!(message.thread_id, "legacy-import");
}
assert_eq!(store.list_leaf_messages("legacy-import").unwrap().len(), 2);
let archive = store
.snapshot_legacy_thread_history("legacy-import")
.unwrap();
assert_eq!(
archive.messages.len(),
7,
"all inactive branches are retained"
);
assert_eq!(archive.current_leaf_id, Some(leaf));
}
}
fn legacy_entry(
id: i64,
parent_entry_id: Option<i64>,
role: &str,
content: &str,
) -> codewhale_state::MessageRecord {
codewhale_state::MessageRecord {
id,
thread_id: "legacy-import".into(),
role: role.into(),
content: content.into(),
item: None,
created_at: 1_700_000_000 + id,
parent_entry_id,
}
}
fn canonical_alias_fixture(
label: &str,
messages: Vec<codewhale_state::MessageRecord>,
current_leaf_id: Option<i64>,
goal: Option<codewhale_state::ThreadGoalRecord>,
) -> (
PathBuf,
StateStore,
codewhale_protocol::RuntimeOwnerReceipt,
codewhale_protocol::CanonicalThreadReceipt,
) {
let path = temp_state_path(label);
let store = StateStore::open(Some(path.clone())).expect("store");
let now = chrono::Utc::now().timestamp();
let thread = ThreadMetadata {
id: "thread-test-1".to_string(),
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
preview: "hello".to_string(),
id: "legacy-import".into(),
rollout_path: None,
preview: "import".into(),
ephemeral: false,
model_provider: "deepseek".to_string(),
model_provider: "deepseek".into(),
created_at: now,
updated_at: now,
status: ThreadStatus::Running,
path: Some(PathBuf::from("/tmp/project")),
cwd: PathBuf::from("/tmp/project"),
cli_version: "0.0.0-test".to_string(),
source: SessionSource::Interactive,
name: Some("Test Thread".to_string()),
sandbox_policy: Some("workspace-write".to_string()),
approval_mode: Some("on-request".to_string()),
status: ThreadStatus::Idle,
path: None,
cwd: std::env::temp_dir(),
cli_version: "test".into(),
source: SessionSource::Resume,
name: None,
sandbox_policy: None,
approval_mode: None,
archived: false,
archived_at: None,
git_sha: None,
git_branch: None,
git_origin_url: None,
memory_mode: Some("extended".to_string()),
current_leaf_id: None,
memory_mode: None,
current_leaf_id,
};
store.upsert_thread(&thread).expect("upsert thread");
store
.append_message("thread-test-1", "foo0", "bar0", None)
.expect("append message");
store
.append_message("thread-test-1", "foo1", "bar1", None)
.expect("append message");
store
.append_message("thread-test-1", "foo2", "bar2", None)
.expect("append message");
store
.append_message("thread-test-1", "foo3", "bar3", None)
.expect("append message");
store
.append_message("thread-test-1", "foo4", "bar4", None)
.expect("append message");
let messages = store
.list_messages("thread-test-1", None)
.expect("list messages");
assert_eq!(messages.len(), 5);
let ids = messages
.iter()
.enumerate()
.map(|(i, message)| {
assert_eq!(message.thread_id, "thread-test-1");
assert_eq!(message.role, format!("foo{i}"));
assert_eq!(message.content, format!("bar{i}"));
message.id.to_string()
.restore_legacy_thread_archive(&codewhale_state::LegacyThreadArchive {
thread,
messages,
goal,
checkpoints: Vec::new(),
})
.collect::<Vec<_>>();
.expect("immutable legacy archive");
// This is a database-only test of captured receipt comparison, not proof of
// peer authentication; the actual owner transport is tested in Runtime.
let owner = codewhale_protocol::RuntimeOwnerReceipt {
version: 1,
data_dir: std::env::temp_dir().join("canonical-alias-fixture"),
execution_scope: "held-scope".into(),
lease_generation: "held-generation".into(),
pid: std::process::id(),
process_start: "fixture-start".into(),
principal: "fixture-user".into(),
socket_path: std::env::temp_dir().join("fixture.sock"),
config_path: None,
};
let receipt = codewhale_protocol::CanonicalThreadReceipt {
version: 1,
data_dir: owner.data_dir.clone(),
execution_scope: owner.execution_scope.clone(),
operation_key: format!("import-{label}"),
request_digest: "a".repeat(64),
history_digest: "b".repeat(64),
runtime_thread_id: "thr_reserved".into(),
session_id: "session_reserved".into(),
};
(path, store, owner, receipt)
}
store.upsert_thread(&thread).expect("upsert thread");
#[test]
fn canonical_alias_publication_rejects_changed_full_graph_and_leaf() {
let (path, store, owner, receipt) = canonical_alias_fixture(
"source_cas",
vec![
legacy_entry(1, None, "system", "system context"),
legacy_entry(2, Some(1), "user", "current branch"),
],
Some(2),
None,
);
let captured = store
.snapshot_legacy_thread_history("legacy-import")
.unwrap();
// A historical external process can still tamper with SQLite. The canonical
// alias CAS must refuse it; no product append/leaf writer is exposed.
let conn = Connection::open(&path).unwrap();
conn.execute_batch("INSERT INTO messages(id,thread_id,role,content,created_at,parent_entry_id) VALUES(3,'legacy-import','user','concurrent alternate branch',1700000003,1); UPDATE threads SET current_leaf_id=3 WHERE id='legacy-import';").unwrap();
assert!(
store
.publish_canonical_runtime_link("legacy-import", None, &captured, &owner, &receipt)
.is_err()
);
assert!(
store
.get_canonical_runtime_link("legacy-import", &owner)
.unwrap()
.is_none()
);
let current = store
.snapshot_legacy_thread_history("legacy-import")
.unwrap();
assert_eq!(
current.messages.len(),
3,
"both source branches survive refusal"
);
store
.publish_canonical_runtime_link("legacy-import", None, &current, &owner, &receipt)
.unwrap();
store
.publish_canonical_runtime_link("legacy-import", None, &current, &owner, &receipt)
.unwrap();
assert_eq!(
store
.get_canonical_runtime_link("legacy-import", &owner)
.unwrap(),
Some(receipt)
);
assert!(
store
.restore_legacy_thread_archive(&codewhale_state::LegacyThreadArchive {
thread: store.get_thread("legacy-import").unwrap().unwrap(),
messages: current.messages,
goal: None,
checkpoints: Vec::new(),
})
.is_err(),
"a bound alias cannot be replaced with an archive"
);
}
#[test]
fn canonical_alias_transaction_rolls_back_and_restart_keeps_exact_owner_binding() {
let (path, store, owner, receipt) = canonical_alias_fixture(
"atomic_restart",
vec![legacy_entry(1, None, "user", "retained original")],
Some(1),
None,
);
let captured = store
.snapshot_legacy_thread_history("legacy-import")
.unwrap();
let conn = Connection::open(&path).unwrap();
conn.execute_batch("CREATE TRIGGER fail_canonical_receipt BEFORE INSERT ON thread_runtime_receipts BEGIN SELECT RAISE(ABORT, 'injected publication failure'); END;").unwrap();
assert!(
store
.publish_canonical_runtime_link("legacy-import", None, &captured, &owner, &receipt)
.is_err()
);
for table in [
"thread_runtime_links",
"thread_runtime_operations",
"thread_runtime_receipts",
] {
let count: i64 = conn
.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(count, 0, "{table} rolls back with the failed transaction");
}
conn.execute_batch("DROP TRIGGER fail_canonical_receipt")
.unwrap();
store
.fork_at_message(&ids[2], "foo5", "bar5", None)
.expect("fork at message");
let messages = store
.list_messages("thread-test-1", None)
.expect("list messages");
assert_eq!(messages.len(), 4);
const LIST_1: [i64; 4] = [0, 1, 2, 5];
messages
.iter()
.zip(LIST_1.iter())
.for_each(|(message, &i)| {
assert_eq!(message.thread_id, "thread-test-1");
assert_eq!(message.role, format!("foo{i}"));
assert_eq!(message.content, format!("bar{i}"));
});
let leaves = store
.list_leaf_messages("thread-test-1")
.expect("list leaf messages");
assert_eq!(leaves.len(), 2);
.publish_canonical_runtime_link("legacy-import", None, &captured, &owner, &receipt)
.unwrap();
drop(store);
let reopened = StateStore::open(Some(path)).unwrap();
assert_eq!(
reopened
.get_canonical_runtime_link("legacy-import", &owner)
.unwrap(),
Some(receipt)
);
let mut foreign = owner.clone();
foreign.execution_scope = "another-store-scope".into();
assert!(
reopened
.get_canonical_runtime_link("legacy-import", &foreign)
.is_err()
);
}
store
.set_current_leaf_id("thread-test-1", &ids[4])
.expect("set current leaf id");
store
.append_message("thread-test-1", "foo6", "bar6", None)
.expect("append message");
let messages = store
.list_messages("thread-test-1", None)
.expect("list messages");
assert_eq!(messages.len(), 6);
const LIST_2: [i64; 6] = [0, 1, 2, 3, 4, 6];
#[test]
fn full_legacy_snapshot_keeps_branches_beyond_active_listing_limit() {
let mut messages = vec![legacy_entry(1, None, "user", "root")];
messages
.iter()
.zip(LIST_2.iter())
.for_each(|(message, &i)| {
assert_eq!(message.thread_id, "thread-test-1");
assert_eq!(message.role, format!("foo{i}"));
assert_eq!(message.content, format!("bar{i}"));
});
.extend((0..550).map(|index| {
legacy_entry(index + 2, Some(index + 1), "assistant", &index.to_string())
}));
let alternate = 552;
messages.push(legacy_entry(alternate, Some(1), "user", "alternate"));
let (_path, store, _, _) =
canonical_alias_fixture("full_graph", messages, Some(alternate), None);
let snapshot = store
.snapshot_legacy_thread_history("legacy-import")
.unwrap();
assert_eq!(snapshot.messages.len(), 552);
assert_eq!(snapshot.current_leaf_id, Some(alternate));
assert_eq!(store.list_messages("legacy-import", None).unwrap().len(), 2);
}
let leaves = store
.list_leaf_messages("thread-test-1")
.expect("list leaf messages");
assert_eq!(leaves.len(), 2);
store
.clear_messages("thread-test-1")
.expect("clear messages");
let leaves = store
.list_leaf_messages("thread-test-1")
.expect("list leaf messages");
assert_eq!(leaves.len(), 0);
let thread = store
.get_thread("thread-test-1")
.expect("get thread")
.unwrap();
assert!(thread.current_leaf_id.is_none());
#[test]
fn canonical_goal_snapshot_and_publication_cas_keep_original_source_status_and_timestamps() {
let goal = codewhale_state::ThreadGoalRecord {
thread_id: "legacy-import".into(),
goal_id: "goal-source-revision".into(),
objective: "original goal".into(),
status: codewhale_state::ThreadGoalStatus::Active,
token_budget: Some(1000),
tokens_used: 41,
time_used_seconds: 9,
continuation_count: 2,
created_at: 1_700_000_000,
updated_at: 1_700_000_010,
last_gap_fingerprint: Some("a".repeat(64)),
repeated_gap_count: 3,
last_gap_pass: Some(2),
pause_reason: None,
};
let (path, store, owner, receipt) =
canonical_alias_fixture("goal_source_cas", Vec::new(), None, Some(goal.clone()));
let captured = store
.snapshot_legacy_thread_history("legacy-import")
.unwrap();
let saved = captured.goal.as_ref().unwrap();
assert_eq!(
saved.status,
codewhale_protocol::ThreadGoalStatus::Active,
"source CAS keeps original persisted status rather than target normalization"
);
assert_eq!(saved.updated_at, goal.updated_at);
assert_eq!(
store
.get_thread_goal("legacy-import")
.unwrap()
.unwrap()
.status,
codewhale_state::ThreadGoalStatus::Paused,
"ordinary restored-reader behavior is unchanged"
);
let mut changed = goal;
changed.objective = "goal changed during owner await".into();
changed.updated_at += 1;
let conn = Connection::open(&path).unwrap();
conn.execute(
"UPDATE thread_goals SET objective=?1, updated_at=?2 WHERE thread_id='legacy-import'",
rusqlite::params![changed.objective, changed.updated_at],
)
.unwrap();
assert!(
store
.publish_canonical_runtime_link("legacy-import", None, &captured, &owner, &receipt)
.is_err()
);
assert!(
store
.get_canonical_runtime_link("legacy-import", &owner)
.unwrap()
.is_none()
);
assert_eq!(
store
.get_thread_goal("legacy-import")
.unwrap()
.unwrap()
.objective,
changed.objective
);
}
+2 -2
View File
@@ -758,8 +758,8 @@ pub fn is_reduced_panic_site(value: &str) -> bool {
/// Whether `value` is a provider id this build knows.
///
/// Checked against the **full** provider registry, not
/// `ProviderKind::all()`: that constant is the 36-row *catalog* subset, and
/// `ApiProvider::kind()` legitimately yields dialect kinds
/// `ProviderKind::all()`: that list is the selectable catalog subset, while
/// captured intrinsic kinds legitimately include dialect kinds
/// (`deepseek-anthropic`, the Model Studio plan variants) that are absent from
/// it. Narrowing to the catalog would silently drop a real user's route.
#[must_use]
+2 -2
View File
@@ -471,8 +471,8 @@ fn every_legitimately_recorded_event_survives_the_drain() {
);
// Dialect kinds (`deepseek-anthropic`, the Model Studio plan variants) are
// absent from `ProviderKind::ALL`, which is the 37-row *catalog* subset,
// but `ApiProvider::kind()` yields them for real routes. Narrowing the
// absent from the selectable `ProviderKind::ALL` catalog subset,
// but captured intrinsic kinds include them for real routes. Narrowing the
// provider bound to the catalog would drop those users' `session_end`.
for kind in [
codewhale_config::ProviderKind::DeepseekAnthropic,
+2 -1
View File
@@ -1144,9 +1144,10 @@ note below before upgrading.
### Contributors
Fifteen contributors and issue reporters are credited below, including
Sixteen contributors and issue reporters are credited below, including
@cenab's provider report.
- **[@Guan0923](https://github.com/Guan0923)** — accepted case-insensitive HTTP(S) schemes in `config doctor` without rewriting the configured URL ([#6819](https://github.com/Hmbown/Codewhale/pull/6819)).
- **[@harryvgiunta](https://github.com/harryvgiunta)** — added Yolo-Auto as a bundled OpenAI-compatible host, starting on the vendor's recommended `qwen3.8-flash` model ([#6408](https://github.com/Hmbown/Codewhale/pull/6408)).
- **[@asto18089](https://github.com/asto18089)** — contributed the integrated runtime liveness, context, search, JavaScript execution, stopship scout and pet repairs, preserving their original contributor commits ([#6799](https://github.com/Hmbown/Codewhale/pull/6799)); made context rule and chain-segment source labels repository-relative ([#6739](https://github.com/Hmbown/Codewhale/pull/6739)).
- **[@qiuYliangM](https://github.com/qiuYliangM)** — made provider-bound project instruction and constitution labels stable across directory moves and kept their absolute paths in operator reports ([#6799](https://github.com/Hmbown/Codewhale/pull/6799)).
+8 -16
View File
@@ -6,29 +6,23 @@ rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Terminal UI for open-source and open-weight coding models"
default-run = "codewhale-tui"
autobins = false
[lints]
workspace = true
[features]
default = ["mimalloc-allocator"]
mimalloc-allocator = ["dep:mimalloc"]
default = []
long-running-tests = []
# Opt-in global-allocator swap (#5872): build on rusty_alloc (the pure-Rust
# mimalloc v2.4.5 remake — no C compiler, no build script) instead of the
# default mimalloc. Off by default; the default build is unchanged.
# Build with: cargo build -p codewhale-tui --no-default-features --features rusty-alloc
rusty-alloc = ["dep:rusty_alloc-api"]
# Accepted compatibility feature names. Allocators belong exclusively to the
# canonical codewhale-cli executable; these library aliases have no effect.
mimalloc-allocator = []
rusty-alloc = []
[lib]
name = "codewhale_tui"
path = "src/lib.rs"
[[bin]]
name = "codewhale-tui"
path = "src/main.rs"
[dependencies]
# Independent terminal kit, pinned and vendored for reproducible binary builds.
codewhale-ratatui = { path = "../../vendor/codewhale-ratatui", version = "0.1.0" }
@@ -36,6 +30,7 @@ codewhale-sanitize = { path = "../sanitize", version = "0.10.1" }
codewhale-memory = { path = "../memory", version = "0.10.1" }
ahash = "0.8"
anyhow.workspace = true
codewhale-app-server = { path = "../app-server", version = "0.10.1" }
codewhale-config = { path = "../config", version = "0.10.1" }
codewhale-cloud-facts = { path = "../cloud-facts", version = "0.10.1" }
codewhale-command-contract = { path = "../command-contract", version = "0.10.1" }
@@ -60,7 +55,6 @@ async-trait.workspace = true
base64 = "0.23.1"
axum.workspace = true
clap.workspace = true
clap_complete.workspace = true
colored = "3.0.0"
crossterm = "0.29"
dotenvy = "0.15.7"
@@ -146,8 +140,6 @@ ring = "0.17"
semver.workspace = true
codewhale-localization = { path = "../localization", version = "0.10.1" }
shell-words = "1.1.1"
mimalloc = { workspace = true, optional = true }
rusty_alloc-api = { workspace = true, optional = true }
[build-dependencies]
codewhale-build-support = { path = "../build-support", version = "0.10.1" }
@@ -171,7 +163,7 @@ tokio-tungstenite = "0.30"
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Pipes", "Win32_System_Threading"] }
windows-sys = { version = "0.61.2", features = ["Wdk_Storage_FileSystem", "Win32_System_IO", "Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemServices"] }
[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies]
# JWT verification uses upstream assembly objects when NASM is unavailable.
-1
View File
@@ -7,7 +7,6 @@ fn main() {
);
println!("cargo:rerun-if-env-changed=CARGO_MANIFEST_DIR");
codewhale_build_support::declare_rerun_conditions(&manifest_dir);
codewhale_build_support::configure_windows_main_stack("codewhale-tui");
build_computer_use_helper(&manifest_dir);
codewhale_build_support::emit_build_version(&manifest_dir, env!("CARGO_PKG_VERSION"));
}
+109 -12
View File
@@ -16,8 +16,8 @@ mkdirSync(outdir, { recursive: true })
// `dist/builtin/<id>.mjs`, the file the core activates under the owner id
// `host:<id>`, and `dist/builtin-modules.json` records the SHA-256 of every
// one. The Rust table `BUILTIN_MODULES` (src/extension_host/tier.rs) pins the
// same digests, and a Rust test fails when the two disagree. There are no
// built-in modules yet, so the file lists none and `dist/builtin/` is absent.
// same digests, and a Rust test fails when the two disagree. The committed
// host:mcp module is an explicitly selected SDK adapter over Rust broker sessions.
// They are built before the host bundle, which embeds the digests.
const builtinSource = join(here, 'src/builtin')
const builtinOut = join(outdir, 'builtin')
@@ -29,6 +29,7 @@ const builtinIds = existsSync(builtinSource)
.sort()
: []
const builtinDigests = {}
const builtinMetafiles = []
for (const id of builtinIds) {
// Mirrors `valid_module_id` in tier.rs: the id is also a file name.
if (!/^[a-z][a-z0-9-]{0,63}$/.test(id)) throw new Error(`src/builtin/${id}.ts is not a valid built-in module name`)
@@ -49,16 +50,17 @@ for (const id of builtinIds) {
logLevel: 'warning',
banner: { js: `// codewhale built-in host module ${id} — generated by crates/tui/extension-host/build.mjs; do not edit.` },
})
// LICENSES.txt covers the host bundle only; a module that bundled a package
// would ship code with no notice.
if (Object.keys(built.metafile.inputs).some((input) => input.includes('node_modules/'))) {
throw new Error(`built-in module ${id} bundles a package from node_modules; add its licence notice to the build first`)
}
builtinMetafiles.push(built.metafile)
builtinDigests[id] = createHash('sha256').update(readFileSync(outfile)).digest('hex')
}
writeFileSync(join(outdir, 'builtin-modules.json'), JSON.stringify({ modules: builtinDigests }, null, 2) + '\n')
const compositionAlias = {
'@deepseek-ai/cordis-plugin-loader': join(here, 'src/dsh/upstream/loader/src/index.ts'),
}
const hostBuild = await build({
alias: compositionAlias,
entryPoints: [join(here, 'src/main.ts')],
outfile: join(outdir, 'codewhale-extension-host.mjs'),
absWorkingDir: here,
@@ -82,6 +84,45 @@ const hostBuild = await build({
},
})
// Test-only reviewed hook bridge: Node tests exercise the exact pinned parser.
await build({
entryPoints: [join(here, 'src/dsh/shell-hooks.ts')],
outfile: join(outdir, 'shell-hooks.mjs'),
absWorkingDir: here,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22.19',
sourcemap: false,
minify: false,
legalComments: 'none',
charset: 'utf8',
logLevel: 'warning',
})
// Trusted offline composition reviewer. Embedded beside the host, but has
// no HostRoot, RPC handler, plugin import or expression evaluation entrypoint.
const reviewBuild = await build({
alias: compositionAlias,
entryPoints: [join(here, 'src/dsh/review-main.ts')],
outfile: join(outdir, 'dsh-composition-review.mjs'),
absWorkingDir: here,
metafile: true,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22.19',
sourcemap: false,
minify: false,
legalComments: 'none',
charset: 'utf8',
logLevel: 'warning',
banner: { js: '// trusted nonexecuting DSH composition reviewer; third-party notices: LICENSES.txt' },
})
// Test-only pure adapters: the production harness imports exactly this source.
await build({entryPoints:[join(here,'src/builtin/shared/stock-adapters.ts')],outfile:join(outdir,'stock-adapters.mjs'),bundle:true,platform:'node',format:'esm',target:'node22.19',sourcemap:false,minify:false,legalComments:'none',charset:'utf8',logLevel:'warning'})
// Test-only protocol module (codec + validators) so `node --test` can check
// the corpus against the exact code the host runs, without booting a host.
// Not embedded in the Rust binary.
@@ -116,6 +157,26 @@ await build({
banner: { js: '// generated by crates/tui/extension-host/build.mjs from src/rpc.ts; do not edit.' },
})
// Test-only skill-root shim, bundled so tests remain dependency-free before npm ci.
await build({
entryPoints: [join(here, 'src/shims/skills.ts')],
outfile: join(outdir, 'skills.mjs'),
absWorkingDir: here,
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22.19',
sourcemap: false,
minify: false,
legalComments: 'none',
charset: 'utf8',
logLevel: 'warning',
banner: { js: '// generated by crates/tui/extension-host/build.mjs from src/shims/skills.ts; do not edit.' },
})
// Source-focused raw roster tests use the same discovery/receipt adapter.
await build({entryPoints:[join(here,'src/dsh/agent-presets.ts')],outfile:join(outdir,'agent-presets.mjs'),alias:compositionAlias,bundle:true,platform:'node',format:'esm',target:'node22.19',sourcemap:false,minify:false,legalComments:'none',charset:'utf8',logLevel:'warning'})
// Third-party notices (`dist/LICENSES.txt`), generated from what the bundler
// actually put in the host bundle: every package that contributed an input
// file, with its own LICENSE file. A package added to or dropped from the
@@ -160,11 +221,18 @@ const EXCERPTED = [
},
]
const packages = bundledPackages(hostBuild.metafile).map(({ name, root }) => {
const packageInputs = [hostBuild.metafile, reviewBuild.metafile, ...builtinMetafiles].flatMap(bundledPackages)
const packagesByIdentity = new Map()
for (const { name, root } of packageInputs) {
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
if (typeof pkg.license !== 'string' || pkg.license.length === 0) throw new Error(`${name} declares no license in package.json`)
return { name: pkg.name, version: pkg.version, license: pkg.license, text: readLicense(root, name) }
})
const entry = { name: pkg.name, version: pkg.version, license: pkg.license, text: readLicense(root, name) }
const identity = `${entry.name}@${entry.version}`
const prior = packagesByIdentity.get(identity)
if (prior && (prior.license !== entry.license || prior.text !== entry.text)) throw new Error(`${identity} contributes conflicting licence texts`)
packagesByIdentity.set(identity, entry)
}
const packages = [...packagesByIdentity.values()]
if (packages.length === 0) throw new Error('the host bundle contains no node_modules input: the notice generator is reading the wrong metafile')
const entries = packages.map((pkg) => ({ ...pkg, note: '' }))
@@ -183,10 +251,38 @@ for (const excerpt of EXCERPTED) {
note: `Verbatim excerpts of this package are in ${excerpt.file}, under the same licence text as ${excerpt.licenseOf} above.`,
})
}
for (const [folder, version] of [['loader', '1.0.3'], ['include', '1.0.7'], ['group', '1.0.2']]) {
entries.push({
name: `@deepseek-ai/cordis-plugin-${folder}`,
version,
license: 'MIT',
text: readLicense(join(here, 'src/dsh/upstream', folder), `vendored ${folder}`),
note: 'Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Loader native-internals probe disabled; other runtime semantics reused.',
})
}
entries.push({name:'@deepseek-ai/dsh-agent-presets',version:'0.1.6-alpha.1',license:'MIT',text:readLicense(join(here,'src/dsh/upstream/agent-presets'),'vendored agent-presets'),note:'Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Discovery/metadata/specifier/inventory algorithms use mandatory receipt-backed IO; Core owns selection, sessions and writable roots.'})
for (const name of ['dsh-hook-protocol', 'dsh-hooks-claude-code', 'dsh-hooks-codex']) {
entries.push({
name: `@deepseek-ai/${name}`,
version: '0.1.6-alpha.1',
license: 'MIT',
text: readLicense(join(here, 'src/dsh/upstream/hooks'), `vendored ${name}`),
note: 'Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Pure matcher/codec/merge and configuration parsers reused; local imports and session-writer type declarations adapted. Core retains scheduling and steering.',
})
}
for (const name of ['dsh-persona', 'dsh-system-prompt']) {
entries.push({
name: `@deepseek-ai/${name}`,
version: '0.1.6-alpha.1',
license: 'MIT',
text: readLicense(join(here, 'src/dsh/upstream/hooks'), `adapted ${name}`),
note: 'Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Additive persona configuration and strict simple template mapping adapted in src/dsh/persona.ts and the Rust prompt renderer; source and license receipts: src/dsh/persona.UPSTREAM.json. Core retains prompt assembly and accepted model/workspace values.',
})
}
entries.sort((a, b) => (a.name < b.name ? -1 : 1))
const sections = [
'Third-party software bundled into codewhale-extension-host.mjs.',
'Third-party software bundled into codewhale-extension-host.mjs, dsh-composition-review.mjs and builtin/*.mjs.',
'Generated by crates/tui/extension-host/build.mjs from the bundler metafile; do not edit.',
'',
'Packages:',
@@ -197,4 +293,5 @@ for (const entry of entries) {
if (entry.note) sections.push('', entry.note)
sections.push('', entry.text)
}
writeFileSync(join(outdir, 'LICENSES.txt'), sections.join('\n') + '\n')
// Preserve every notice word while keeping generated text free of trailing whitespace.
writeFileSync(join(outdir, 'LICENSES.txt'), sections.join('\n').replace(/[ \t]+$/gm, '') + '\n')
@@ -0,0 +1,54 @@
// Compile the committed canonical host with an explicitly supplied local Bun.
// --compile-executable-path forbids Bun's implicit runtime download. Target and
// architecture therefore follow that binary; cross targets need a matching
// verified build input, not a runtime fetched during host startup.
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { readFileSync, realpathSync } from 'node:fs'
import { dirname, isAbsolute, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const args = process.argv.slice(2)
const value = (flag) => {
const index = args.indexOf(flag)
if (index < 0 || !args[index + 1] || args[index + 1].startsWith('--')) throw new Error(`required: ${flag} PATH`)
return args[index + 1]
}
const bun = value('--bun')
if (!isAbsolute(bun)) throw new Error('--bun must name an absolute local Bun executable')
const binary = realpathSync(bun)
const output = resolve(value('--output'))
const bundle = resolve(here, 'dist/codewhale-extension-host.mjs')
const sourceSha = createHash('sha256').update(readFileSync(bundle)).digest('hex')
const cleanEnv = { ...process.env, NODE_OPTIONS: '', BUN_OPTIONS: '', BUN_BE_BUN: '0', BUN_JSC_useShadowRealm: '0' }
// Actual selected compiler process facts, including emulation. Never label a
// runtime/image using the architecture of the Node driver or Rust runner.
const facts = spawnSync(binary, ['--no-install', '--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, '-p',
'JSON.stringify({platform:process.platform,arch:process.arch})'],
{ cwd: here, env: cleanEnv, encoding: 'utf8', timeout: 5000, maxBuffer: 16 * 1024 })
if (facts.error || facts.status !== 0) throw facts.error ?? new Error(`compiler runtime identity failed (${facts.status})`)
const runtimeInfo = JSON.parse(facts.stdout)
if (!['linux', 'darwin', 'win32'].includes(runtimeInfo.platform) || !['x64', 'arm64'].includes(runtimeInfo.arch)) throw new Error('unsupported compiler runtime identity')
const run = (argv) => {
const result = spawnSync(binary, argv, { cwd: here, env: cleanEnv, encoding: 'utf8', timeout: 120_000, maxBuffer: 4 * 1024 * 1024 })
if (result.stdout) process.stderr.write(result.stdout)
if (result.stderr) process.stderr.write(result.stderr)
if (result.error || result.status !== 0) throw result.error ?? new Error(`Bun failed (${result.status})`)
}
run([
'--no-install', '--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, 'build', '--compile', `--compile-executable-path=${binary}`,
`--compile-exec-argv=--no-install --no-env-file --config=${process.platform === 'win32' ? 'NUL' : '/dev/null'} --no-addons`,
'--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig',
'--no-compile-autoload-tsconfig', '--no-compile-autoload-package-json',
'--define=CODEWHALE_COMPILED_HOST=true',
`--define=CODEWHALE_COMPILED_BUNDLE_SHA256=${JSON.stringify(sourceSha)}`,
resolve(here, 'src/compiled.mjs'), '--outfile', output,
])
const probe = spawnSync(output, ['--codewhale-host-info'], { env: cleanEnv, encoding: 'utf8', timeout: 5000, maxBuffer: 16 * 1024 })
if (probe.error || probe.status !== 0) throw probe.error ?? new Error(`compiled host probe failed (${probe.status})`)
const info = JSON.parse(probe.stdout)
if (info.kind !== 'codewhale-extension-host' || info.runtime !== 'bun' || info.bundle_sha256 !== sourceSha || typeof info.version !== 'string' || info.platform !== runtimeInfo.platform || info.arch !== runtimeInfo.arch) {
throw new Error('compiled host identity does not match the canonical bundle')
}
console.log(JSON.stringify({ output, ...info, compiler: runtimeInfo }))
+896 -1
View File
@@ -1,12 +1,30 @@
Third-party software bundled into codewhale-extension-host.mjs.
Third-party software bundled into codewhale-extension-host.mjs, dsh-composition-review.mjs and builtin/*.mjs.
Generated by crates/tui/extension-host/build.mjs from the bundler metafile; do not edit.
Packages:
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@modelcontextprotocol/[email protected] (MIT)
@modelcontextprotocol/[email protected] (MIT)
[email protected] (MIT)
[email protected] (MIT)
[email protected] (MIT)
[email protected] (MIT)
[email protected] (MIT)
[email protected] (MIT)
[email protected] (MIT)
========================================================================
@deepseek-ai/[email protected] (MIT)
@@ -34,6 +52,90 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Loader native-internals probe disabled; other runtime semantics reused.
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Loader native-internals probe disabled; other runtime semantics reused.
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Loader native-internals probe disabled; other runtime semantics reused.
MIT License
Copyright (c) 2021-present Shigma
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
@@ -60,6 +162,174 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Discovery/metadata/specifier/inventory algorithms use mandatory receipt-backed IO; Core owns selection, sessions and writable roots.
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Pure matcher/codec/merge and configuration parsers reused; local imports and session-writer type declarations adapted. Core retains scheduling and steering.
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Pure matcher/codec/merge and configuration parsers reused; local imports and session-writer type declarations adapted. Core retains scheduling and steering.
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Pure matcher/codec/merge and configuration parsers reused; local imports and session-writer type declarations adapted. Core retains scheduling and steering.
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Additive persona configuration and strict simple template mapping adapted in src/dsh/persona.ts and the Rust prompt renderer; source and license receipts: src/dsh/persona.UPSTREAM.json. Core retains prompt assembly and accepted model/workspace values.
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
Pinned deepseek-harness 0d1f50007f9bca3f52b06e1c3074fa14d5fb0720. Additive persona configuration and strict simple template mapping adapted in src/dsh/persona.ts and the Rust prompt renderer; source and license receipts: src/dsh/persona.UPSTREAM.json. Core retains prompt assembly and accepted model/workspace values.
MIT License
Copyright (c) 2026 DeepSeek
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/[email protected] (MIT)
========================================================================
@@ -139,3 +409,628 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@modelcontextprotocol/[email protected] (MIT)
========================================================================
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
No rights beyond those granted by the applicable original license are conveyed for such contributions.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
---
MIT License
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Creative Commons Attribution 4.0 International (CC-BY-4.0)
Documentation in this project (excluding specifications) is licensed under
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.
========================================================================
@modelcontextprotocol/[email protected] (MIT)
========================================================================
The MCP project is undergoing a licensing transition from the MIT License to the Apache License, Version 2.0 ("Apache-2.0"). All new code and specification contributions to the project are licensed under Apache-2.0. Documentation contributions (excluding specifications) are licensed under CC-BY-4.0.
Contributions for which relicensing consent has been obtained are licensed under Apache-2.0. Contributions made by authors who originally licensed their work under the MIT License and who have not yet granted explicit permission to relicense remain licensed under the MIT License.
No rights beyond those granted by the applicable original license are conveyed for such contributions.
---
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright
owner or by an individual or Legal Entity authorized to submit on behalf
of the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
---
MIT License
Copyright (c) 2024-2025 Model Context Protocol a Series of LF Projects, LLC.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Creative Commons Attribution 4.0 International (CC-BY-4.0)
Documentation in this project (excluding specifications) is licensed under
CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.
========================================================================
[email protected] (MIT)
========================================================================
MIT License
Copyright (C) 2012-2022 by various contributors (see AUTHORS)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
========================================================================
[email protected] (MIT)
========================================================================
The MIT License
Copyright (c) EventSource GitHub organisation
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
========================================================================
[email protected] (MIT)
========================================================================
MIT License
Copyright (c) 2026 Espen Hovlandsdal <[email protected]>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
[email protected] (MIT)
========================================================================
The MIT License (MIT)
Copyright (c) 2018 Filip Skokan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
[email protected] (MIT)
========================================================================
(The MIT License)
Copyright (C) 2011-2015 by Vitaly Puzrin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
========================================================================
[email protected] (MIT)
========================================================================
MIT License
Copyright (c) 2019
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
[email protected] (MIT)
========================================================================
MIT License
Copyright (c) 2025 Colin McDonnell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -1,3 +1,6 @@
{
"modules": {}
"modules": {
"harness": "8b40ab8734838da9513106ba576b7140656ff254f67c81c0b52365bc2b497aaa",
"mcp": "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55"
}
}
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
File diff suppressed because it is too large. Load diff
+84 -5
View File
@@ -27,12 +27,26 @@ var METHODS = [
{ name: "tool/call", direction: "core_to_host", request: true, params: "ToolCallParams", tiers: ["plugin", "builtin"] },
{ name: "command/run", direction: "core_to_host", request: true, params: "CommandRunParams", tiers: ["plugin", "builtin"] },
{ name: "hook/evaluate", direction: "core_to_host", request: true, params: "HookEvaluateParams", tiers: ["plugin", "builtin"] },
{ name: "mcp/open", direction: "core_to_host", request: true, params: "McpOpenParams", tiers: ["builtin"] },
{ name: "mcp/request", direction: "core_to_host", request: true, params: "McpRequestParams", tiers: ["builtin"] },
{ name: "mcp/close", direction: "core_to_host", request: true, params: "McpCloseParams", tiers: ["builtin"] },
{ name: "harness/run", direction: "core_to_host", request: true, params: "HarnessRunParams", tiers: ["builtin"] },
{ name: "$/cancel", direction: "core_to_host", request: false, params: "CancelParams", tiers: ["plugin", "builtin"] },
{ name: "host/hello", direction: "host_to_core", request: false, params: "HelloParams", tiers: ["plugin", "builtin"] },
{ name: "host/ready", direction: "host_to_core", request: false, params: "EmptyParams", tiers: ["plugin", "builtin"] },
{ name: "registry/register", direction: "host_to_core", request: true, params: "RegisterParams", tiers: ["plugin", "builtin"] },
{ name: "registry/unregister", direction: "host_to_core", request: true, params: "UnregisterParams", tiers: ["plugin", "builtin"] },
{ name: "core/call", direction: "host_to_core", request: true, params: "CoreCallParams", tiers: ["plugin", "builtin"] },
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
{ name: "proc/close", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "net/start", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "net/fetch", direction: "host_to_core", request: true, params: "NetFetchParams", tiers: ["builtin"] },
{ name: "net/read", direction: "host_to_core", request: true, params: "NetReadParams", tiers: ["builtin"] },
{ name: "net/release", direction: "host_to_core", request: true, params: "NetReadParams", tiers: ["builtin"] },
{ name: "net/close", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "exec/redeem", direction: "host_to_core", request: true, params: "ExecutionRedeemParams", tiers: ["builtin"] },
{ name: "ext/faulted", direction: "host_to_core", request: false, params: "FaultedParams", tiers: ["plugin", "builtin"] },
{ name: "log", direction: "host_to_core", request: false, params: "LogParams", tiers: ["plugin", "builtin"] },
{ name: "$/cancel", direction: "host_to_core", request: false, params: "CancelParams", tiers: ["plugin", "builtin"] }
@@ -41,7 +55,7 @@ var SHAPES = {
ActivateParams: {
strict: false,
required: { owner: { ref: "OwnerRef" }, plugin_name: "string", entry: { ref: "EntryRef" } },
optional: { config: "json", data_dir: "string" }
optional: { scope: { ref: "EntryRef" }, config: "json", data_dir: "string" }
},
CancelParams: {
strict: true,
@@ -61,7 +75,7 @@ var SHAPES = {
DeactivateParams: {
strict: false,
required: { owner: { ref: "OwnerRef" } },
optional: {}
optional: { entry: { ref: "EntryRef" } }
},
EmptyParams: {
strict: true,
@@ -73,11 +87,21 @@ var SHAPES = {
required: { path: "string", sha256: "string" },
optional: {}
},
ExecutionRedeemParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, execution_id: "string", ticket: "string" },
optional: {}
},
FaultedParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, error: "string" },
optional: {}
},
HarnessRunParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, execution_id: "string", ticket: "string", deadline_ms: "uint" },
optional: { hook: { ref: "HookDispatchWire" } }
},
HelloParams: {
strict: true,
required: { protocol: { ref: "ProtocolRange" }, host_version: "string", bundle_sha256: "string", runtime: { ref: "HelloRuntime" }, tier: { enum: ["builtin", "plugin"] }, builtin_modules: { items: { ref: "ModuleDigestWire" } } },
@@ -93,6 +117,11 @@ var SHAPES = {
required: { name: "string", call_id: "string", input: "json", mode: "string", workspace: "string", model: "string" },
optional: {}
},
HookDispatchWire: {
strict: true,
required: { event: "string", dialect: "string", point: "string", query: "string" },
optional: { matcher: "string" }
},
HookEvaluateParams: {
strict: true,
required: { handle: "uint", event: "string", payload: { ref: "HookCallPayload" }, deadline_ms: "uint" },
@@ -113,16 +142,66 @@ var SHAPES = {
required: { level: "string", msg: "string" },
optional: { plugin_id: "string" }
},
McpCloseParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", deadline_ms: "uint" },
optional: {}
},
McpHttpHeaders: {
strict: true,
required: {},
optional: { accept: "string", content_type: "string", mcp_session_id: "string", mcp_protocol_version: "string" }
},
McpOpenParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", launch_ticket: "string", transport: "string", initialize_grant: { ref: "McpOperationGrant" }, initialized_grant: { ref: "McpOperationGrant" }, client_version: "string", deadline_ms: "uint" },
optional: {}
},
McpOperationGrant: {
strict: true,
required: { ticket: "string", operation_id: "string", method: "string", params: "json" },
optional: { wire_id: "string" }
},
McpRequestParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", grant: { ref: "McpOperationGrant" }, deadline_ms: "uint" },
optional: {}
},
ModuleDigestWire: {
strict: true,
required: { id: "string", sha256: "string" },
optional: {}
},
NetFetchParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", url: "string", method: "string", headers: { ref: "McpHttpHeaders" } },
optional: { frame: "json", ticket: "string", operation_id: "string" }
},
NetReadParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", response_id: "string" },
optional: {}
},
OwnerRef: {
strict: true,
required: { plugin_id: "string", generation: "uint", owner_token: "string" },
optional: {}
},
ProcLaunchParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", ticket: "string" },
optional: {}
},
ProcSessionParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string" },
optional: {}
},
ProcWriteParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", frame: "json" },
optional: { ticket: "string", operation_id: "string" }
},
ProtocolRange: {
strict: true,
required: { min: "uint", max: "uint" },
@@ -130,8 +209,8 @@ var SHAPES = {
},
RegisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "skill_root"] }, spec: { ref: "RegisterSpecWire" } },
optional: {}
required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "prompt_template", "skill_root", "shell_hook", "mcp_server"] }, spec: { ref: "RegisterSpecWire" } },
optional: { scope: { ref: "EntryRef" } }
},
RegisterSpecWire: {
strict: true,
@@ -281,7 +360,7 @@ function validateMessage(value, direction, tier, methods = METHODS) {
}
if (method === "registry/register") {
const { kind, spec: spec2 } = params;
const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "skill_root") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0;
const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "prompt_template" || kind === "skill_root" || kind === "shell_hook" || kind === "mcp_server") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0;
if (reason !== void 0) throw new ProtocolError(`${method}: ${reason}`);
}
return value;
+84 -5
View File
@@ -24,12 +24,26 @@ var METHODS = [
{ name: "tool/call", direction: "core_to_host", request: true, params: "ToolCallParams", tiers: ["plugin", "builtin"] },
{ name: "command/run", direction: "core_to_host", request: true, params: "CommandRunParams", tiers: ["plugin", "builtin"] },
{ name: "hook/evaluate", direction: "core_to_host", request: true, params: "HookEvaluateParams", tiers: ["plugin", "builtin"] },
{ name: "mcp/open", direction: "core_to_host", request: true, params: "McpOpenParams", tiers: ["builtin"] },
{ name: "mcp/request", direction: "core_to_host", request: true, params: "McpRequestParams", tiers: ["builtin"] },
{ name: "mcp/close", direction: "core_to_host", request: true, params: "McpCloseParams", tiers: ["builtin"] },
{ name: "harness/run", direction: "core_to_host", request: true, params: "HarnessRunParams", tiers: ["builtin"] },
{ name: "$/cancel", direction: "core_to_host", request: false, params: "CancelParams", tiers: ["plugin", "builtin"] },
{ name: "host/hello", direction: "host_to_core", request: false, params: "HelloParams", tiers: ["plugin", "builtin"] },
{ name: "host/ready", direction: "host_to_core", request: false, params: "EmptyParams", tiers: ["plugin", "builtin"] },
{ name: "registry/register", direction: "host_to_core", request: true, params: "RegisterParams", tiers: ["plugin", "builtin"] },
{ name: "registry/unregister", direction: "host_to_core", request: true, params: "UnregisterParams", tiers: ["plugin", "builtin"] },
{ name: "core/call", direction: "host_to_core", request: true, params: "CoreCallParams", tiers: ["plugin", "builtin"] },
{ name: "proc/launch", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "proc/read", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "proc/write", direction: "host_to_core", request: true, params: "ProcWriteParams", tiers: ["builtin"] },
{ name: "proc/close", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "net/start", direction: "host_to_core", request: true, params: "ProcLaunchParams", tiers: ["builtin"] },
{ name: "net/fetch", direction: "host_to_core", request: true, params: "NetFetchParams", tiers: ["builtin"] },
{ name: "net/read", direction: "host_to_core", request: true, params: "NetReadParams", tiers: ["builtin"] },
{ name: "net/release", direction: "host_to_core", request: true, params: "NetReadParams", tiers: ["builtin"] },
{ name: "net/close", direction: "host_to_core", request: true, params: "ProcSessionParams", tiers: ["builtin"] },
{ name: "exec/redeem", direction: "host_to_core", request: true, params: "ExecutionRedeemParams", tiers: ["builtin"] },
{ name: "ext/faulted", direction: "host_to_core", request: false, params: "FaultedParams", tiers: ["plugin", "builtin"] },
{ name: "log", direction: "host_to_core", request: false, params: "LogParams", tiers: ["plugin", "builtin"] },
{ name: "$/cancel", direction: "host_to_core", request: false, params: "CancelParams", tiers: ["plugin", "builtin"] }
@@ -38,7 +52,7 @@ var SHAPES = {
ActivateParams: {
strict: false,
required: { owner: { ref: "OwnerRef" }, plugin_name: "string", entry: { ref: "EntryRef" } },
optional: { config: "json", data_dir: "string" }
optional: { scope: { ref: "EntryRef" }, config: "json", data_dir: "string" }
},
CancelParams: {
strict: true,
@@ -58,7 +72,7 @@ var SHAPES = {
DeactivateParams: {
strict: false,
required: { owner: { ref: "OwnerRef" } },
optional: {}
optional: { entry: { ref: "EntryRef" } }
},
EmptyParams: {
strict: true,
@@ -70,11 +84,21 @@ var SHAPES = {
required: { path: "string", sha256: "string" },
optional: {}
},
ExecutionRedeemParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, execution_id: "string", ticket: "string" },
optional: {}
},
FaultedParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, error: "string" },
optional: {}
},
HarnessRunParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, execution_id: "string", ticket: "string", deadline_ms: "uint" },
optional: { hook: { ref: "HookDispatchWire" } }
},
HelloParams: {
strict: true,
required: { protocol: { ref: "ProtocolRange" }, host_version: "string", bundle_sha256: "string", runtime: { ref: "HelloRuntime" }, tier: { enum: ["builtin", "plugin"] }, builtin_modules: { items: { ref: "ModuleDigestWire" } } },
@@ -90,6 +114,11 @@ var SHAPES = {
required: { name: "string", call_id: "string", input: "json", mode: "string", workspace: "string", model: "string" },
optional: {}
},
HookDispatchWire: {
strict: true,
required: { event: "string", dialect: "string", point: "string", query: "string" },
optional: { matcher: "string" }
},
HookEvaluateParams: {
strict: true,
required: { handle: "uint", event: "string", payload: { ref: "HookCallPayload" }, deadline_ms: "uint" },
@@ -110,16 +139,66 @@ var SHAPES = {
required: { level: "string", msg: "string" },
optional: { plugin_id: "string" }
},
McpCloseParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", deadline_ms: "uint" },
optional: {}
},
McpHttpHeaders: {
strict: true,
required: {},
optional: { accept: "string", content_type: "string", mcp_session_id: "string", mcp_protocol_version: "string" }
},
McpOpenParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", launch_ticket: "string", transport: "string", initialize_grant: { ref: "McpOperationGrant" }, initialized_grant: { ref: "McpOperationGrant" }, client_version: "string", deadline_ms: "uint" },
optional: {}
},
McpOperationGrant: {
strict: true,
required: { ticket: "string", operation_id: "string", method: "string", params: "json" },
optional: { wire_id: "string" }
},
McpRequestParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", grant: { ref: "McpOperationGrant" }, deadline_ms: "uint" },
optional: {}
},
ModuleDigestWire: {
strict: true,
required: { id: "string", sha256: "string" },
optional: {}
},
NetFetchParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", url: "string", method: "string", headers: { ref: "McpHttpHeaders" } },
optional: { frame: "json", ticket: "string", operation_id: "string" }
},
NetReadParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", response_id: "string" },
optional: {}
},
OwnerRef: {
strict: true,
required: { plugin_id: "string", generation: "uint", owner_token: "string" },
optional: {}
},
ProcLaunchParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", ticket: "string" },
optional: {}
},
ProcSessionParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string" },
optional: {}
},
ProcWriteParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, session_id: "string", frame: "json" },
optional: { ticket: "string", operation_id: "string" }
},
ProtocolRange: {
strict: true,
required: { min: "uint", max: "uint" },
@@ -127,8 +206,8 @@ var SHAPES = {
},
RegisterParams: {
strict: true,
required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "skill_root"] }, spec: { ref: "RegisterSpecWire" } },
optional: {}
required: { owner: { ref: "OwnerRef" }, kind: { enum: ["tool", "command", "hook", "prompt_section", "prompt_template", "skill_root", "shell_hook", "mcp_server"] }, spec: { ref: "RegisterSpecWire" } },
optional: { scope: { ref: "EntryRef" } }
},
RegisterSpecWire: {
strict: true,
@@ -237,7 +316,7 @@ function validateMessage(value, direction, tier, methods = METHODS) {
}
if (method === "registry/register") {
const { kind, spec: spec2 } = params;
const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "skill_root") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0;
const reason = kind === "tool" && spec2.input_schema == null ? "a tool registration needs `spec.input_schema`" : kind === "tool" && spec2.argument_hint != null ? "a tool registration has no `spec.argument_hint`" : kind === "command" && spec2.input_schema != null ? "a command registration has no `spec.input_schema`" : (kind === "hook" || kind === "prompt_section" || kind === "prompt_template" || kind === "skill_root" || kind === "shell_hook" || kind === "mcp_server") && (spec2.input_schema != null || spec2.argument_hint != null) ? "a hook, prompt or skill root registration has no input schema or argument hint" : void 0;
if (reason !== void 0) throw new ProtocolError(`${method}: ${reason}`);
}
return value;
Loaded 100 of 999 files, more files were not shown because too many files have changed in this diff. Show more