feat: integrate the verified 0.10.3 combined Engine batch [skip ci]

Lands the 0.10.3 program work that two Codex sessions left uncommitted on
canonical main (chats 01a176ff "Review latest MCP updates" / 01a176e0 "DSH
plugin compatibility"), on top of contributor PR #7086, which merged as itself (5c3d461d8a)
immediately before this commit. The pieces share compiler inputs
(the crate-split moves touch most of them), so they land as one verified
tree rather than as intermediate commits that would not build.

Contents:
- Crate split slices: `codewhale-net`, `codewhale-notify` and
  `codewhale-sandbox` crates; runtime config, config persistence, oauth,
  settings, catalog cache and Codex model roster moved from tui to runtime;
  hooks, extension host config and instruction budget to config; callers
  rewired, re-export facades removed (CW-6129).
- Follow-up repairs to #7086 instruction budgets (per-scope budgets,
  canonical source labels, `instruction_assembly` session snapshot).
- MCP: official SDK 2.4 host backend by default with `server/discover`
  and legacy fallback, metadata/output schema handling, OAuth issuer
  binding, Engine/CU server modernization (CW-6713, CW-7543, CW-7544,
  CW-7546).
- DSH plugin compatibility from the authorized exception lane `26ba80b06a`:
  shared compatibility table, native command lifecycle, reviewed imports,
  Native-only filesystem sandbox (CW-7054).
- Computer Use plugin-only cutover: vendored bundle removed, marketplace
  install from the independent release (CW-6732).
- Image-heavy sessions: 256 KiB read budget, outbound image omission with
  receipts, one smaller retry on HTTP 413 (CW-7550).
- Typed review operation (`core/engine/review.rs`, `runtime_api/review.rs`)
  used by Platform structured reviews.
- Receipt-owned Unix uninstall (Refs #7091).
- Steering (CW-7552) and Linux Shift+Enter (CW-7551, Refs #7093) source
  changes; runtime/manual proof still pending.

Fixes made during takeover verification:
- MCP: Rust now admits the legacy handshake whenever the SDK classifies the
  discovery probe as legacy (any JSON-RPC error, or a result without the
  modern supported version); before, servers answering the probe with a
  result were refused after it.
- MCP: an admitted legacy handshake reports initialize's own failure (e.g.
  401) instead of the probe's -32601.
- MCP: OAuth reactive refresh covers the side-effect-free probe.
- MCP: broker launch failures keep their diagnostic (missing command).
- Tool images must decode before delivery (truncated PNG was accepted).
- THIRD_PARTY_NOTICES lists MCP SDK 2.4.0 and DSH 0.2.1-rc.2 excerpts.
- Changelog entries for MCP, DSH, Computer Use and image validation.
- `#[cfg(test)]` on three test-only items; `cargo fmt --all`; tests updated
  to post-cutover behavior (no built-in CU, `$schema` manifests, canonical
  paths, lazy CU tools, SyncSession resets instruction assembly).

Verified on this exact tree (macOS, source sha 4353faf1 before changelog
edits; changelog/generated-doc edits only after):
- cargo check --workspace --all-targets --locked: exit 0, no warnings
- codewhale-config 806 passed; codewhale-runtime 850 passed;
  codewhale-net 52 passed; codewhale-notify 31 passed; codewhale-cli 501
  passed (nextest, 0 failed)
- codewhale-tui lib: 14515 passed, 0 failed, 21 skipped
- extension host npm test: 420 tests, 413 passed, 0 failed, 7 skipped
- web check-facts OK
Linux x86_64 (Boat VM, same source before the #7097 fold-in):
cargo check --workspace --all-targets --locked exit 0; codewhale-config
808, codewhale-runtime 849, codewhale-net 52, codewhale-notify 31,
codewhale-sandbox 100, codewhale-cli 501 passed, 0 failed. The tui lib
test binary was still compiling on the 16 GB VM at commit time.
#7097 (main) was three-way merged into approval_log.rs and clipboard.rs;
re-check after the fold-in: workspace check exit 0; tui approval_log,
clipboard, session_export, file_mention, pet_watch 213 passed.
Not run: hosted CI, clippy -D warnings, release packaging, live provider
or real MCP servers beyond local fixtures, Deepin run for #7093.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-26 23:28:43 -07:00
1 parent 5c3d461d8a
commit 4cf2d2cfff
748 files changed
+34370 -47487

No files matched your search

-15
View File
@@ -320,21 +320,6 @@ jobs:
(cd "integrations/${bridge}" && npm test)
done
- name: Build computer-use test desktop
# Spawn assertions have short request deadlines; keep the cold image
# build outside those deadlines and fail image preparation explicitly.
timeout-minutes: 10
run: >-
docker build --tag codewhale-cu-linux
--file crates/tui/plugins/computer-use/docker/Dockerfile
crates/tui/plugins/computer-use
- name: Run computer-use plugin suites
# The bundled plugin is dependency-free too; its suites cover the
# manifest contract, the registry, the exec/ssh transport, the four
# platform backends, and the MCP stdio protocol. No GUI input runs.
run: (cd crates/tui/plugins/computer-use && npm test)
- name: Run extension host suites and check the committed bundle
# The TypeScript extension host (experimental, [features]
# extension_host). Tests exercise both the committed bundle and
+1 -2
View File
@@ -45,11 +45,10 @@ jobs:
run: node marketplace/scripts/check-marketplace.mjs
- name: Check bundled catalog snapshot
run: python3 core/scripts/sync-marketplace.py --marketplace marketplace --check
- name: Check current skill and Computer Use mirrors
- name: Check current skill mirrors
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
run: |
node marketplace/scripts/check-marketplace.mjs --core core
node marketplace/scripts/check-cu-sync.mjs --core core
- name: Explain a drift failure
if: failure()
run: |
-1
View File
@@ -39,7 +39,6 @@ jobs:
- { name: web, directory: web }
- { name: telemetry-ingest, directory: telemetry-ingest }
- { name: extension-host, directory: crates/tui/extension-host }
- { name: embedded-computer-use, directory: crates/tui/plugins/computer-use }
- { name: vscode, directory: extensions/vscode }
- { name: feishu-bridge, directory: integrations/feishu-bridge }
- { name: telegram-bridge, directory: integrations/telegram-bridge }
+12 -10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority. (CW-6713)
- **MCP compatibility fixes:** older servers that answer the discovery probe with an error or an unrelated result now connect through the legacy handshake instead of being refused after the probe. A server that rejects sign-in reports its real 401 and the `/mcp login` hint rather than the probe's "method not found". A stale OAuth token is refreshed once when the probe itself is rejected. A server whose command cannot start now says why (for example, command not found) instead of a generic broker refusal.
- **DeepSeek Harness plugins:** reviewed DSH plugins (definition API pinned to DSH 0.2.1-rc.2 in a shared compatibility table) run in the Native extension host. Tool output is validated against its declared schema, unsupported options are refused instead of ignored, and plugin commands can submit a prompt with visible plugin attribution. Terminal command receipts are kept in thread snapshots without entering model history. Changing the compatibility table invalidates earlier trust. Plugin UI views remain disabled. (CW-7054)
- **Computer Use is installed, not bundled:** the Computer Use plugin is no longer vendored into the binary. Install it from the `codewhale` marketplace, which points at its independent release; its consent tools load on first use, and decisions still require a human approval card. (CW-6732)
- **Tool image validation:** an image returned by a tool must decode before it is sent, so truncated or corrupt files are omitted with a receipt even when they are already within the delivery budget.
- **Image-heavy session recovery:** ordinary image reads use a 256 KiB delivery budget. Requests measure serialized bytes, omit older images from an outbound copy with receipts, and retry an initial HTTP 413 once only with a smaller body. Saved images and executed tool actions remain intact. (CW-7550)
- **Unix uninstall:** `codewhale uninstall` previews receipt-owned release files and `--yes` removes only checked files, with the running command last. Both Unix installers record command ownership; self-update renews existing receipts. User data, modified/unreceipted files and package-managed installations are preserved. Windows removal remains pending. (Refs #7091)
- **GitHub Action reviews:** analysis can export a structured Engine review for a separate trusted publisher job. The publisher checks the current PR revision and line anchors, deduplicates its findings, and keeps uncertain or preexisting claims out of inline comments.
- **Telegram recovery:** prompts and their initiating identity are durable before the polling cursor advances. Restart recovery reuses the original Engine operation, questions preserve human ownership, and uncertain final replies remain available through `/status` without an automatic resend.
- **DingTalk bridge:** an optional internal-app Stream adapter reuses the same Engine, shared admission and approval helpers. Tenant/staff allowlists, durable acceptance before acknowledgement and retained uncertain deliveries protect recovery. Managed account linking is separate from this advanced self-hosted bridge.
@@ -17,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **OrcaRouter recommendation:** `/provider` and `/model` offer a shared partner recommendation with the DeepSeek V4.1 Flash 30% top-up-match link, public referral disclosure and localized offer details. F7 or a click opens the existing pager while retaining the current route.
- **Per-scope instruction budgets:** global and project standing instructions now have separate token budgets (100,000 estimated tokens each, capped at 25% of the usable context window), every trim, skip or refusal reaches the model and `/context report`, and `[instruction_budget] policy = "refuse"` stops a request before dispatch. Other tools' instruction files stay opt-in through `project_instruction_imports` or `claude_fallback`, and the table is user config only. Thanks [@7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX) for the design and implementation. (#6526, #7086)
- Long drafts now show a localized external-editor hint when the actual composer
viewport clips their text. The hint uses the shared F4 binding and preserves
queue, running-turn, history, and menu hints (#7049). Thanks @Lstarsky0.
@@ -48,16 +60,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
cancellation wiring stays in place; its stale unused-code suppressions are
removed. No supported command or feature is retired by this cleanup.
- Standing instructions are budgeted per scope (`global` and `project`) in
estimated tokens instead of one 48 KiB byte ceiling shared by everything, and
every trim, skip, and refusal is reported — as a system cell at session start,
on stderr in `exec`, to the model as a runtime-provenance message, and per
source in `/context report`. `AGENTS.md` still wins; a directory without one
now falls back to its `CLAUDE.md` (`claude_fallback = false` opts out).
Configured `instructions = [...]` entries are charged to the global scope.
New `[instruction_budget]` table (user config only) with per-scope token
budgets, percentages of the usable window, `file_max_bytes`, `policy`, the
static-prefix guard, and `claude_fallback` (#6526).
- Local Ollama defaults now come from chat-capable tags, and `/provider ollama`
refreshes the live catalog before switching. Exec and Runtime API defaults use
the same discovery path. Fresh context observations come from loaded models'
Generated
+97 -5
View File
@@ -903,6 +903,7 @@ dependencies = [
"codewhale-lane",
"codewhale-paths",
"codewhale-release",
"codewhale-runtime",
"codewhale-secrets",
"codewhale-state",
"codewhale-telemetry",
@@ -978,6 +979,7 @@ dependencies = [
"libc",
"ring",
"rust_decimal",
"schemars",
"semver",
"serde",
"serde_json",
@@ -1100,6 +1102,43 @@ dependencies = [
"serde_json",
]
[[package]]
name = "codewhale-net"
version = "0.10.2"
dependencies = [
"anyhow",
"chrono",
"codewhale-command-contract",
"codewhale-config",
"codewhale-release",
"codewhale-runtime",
"codewhale-tools",
"http",
"hyper-util",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.21",
"tokio",
"toml 1.1.4+spec-1.1.0",
"tracing",
]
[[package]]
name = "codewhale-notify"
version = "0.10.2"
dependencies = [
"codewhale-config",
"codewhale-runtime",
"codewhale-secrets",
"dirs 7.0.0",
"regex",
"tempfile",
"tracing",
"windows",
]
[[package]]
name = "codewhale-palette"
version = "0.10.2"
@@ -1192,36 +1231,77 @@ version = "0.10.2"
dependencies = [
"ahash",
"anyhow",
"aws-lc-rs",
"base64 0.23.1",
"chrono",
"codewhale-cloud-facts",
"codewhale-command-contract",
"codewhale-config",
"codewhale-core",
"codewhale-execpolicy",
"codewhale-lane",
"codewhale-localization",
"codewhale-memory",
"codewhale-models",
"codewhale-palette",
"codewhale-paths",
"codewhale-protocol",
"codewhale-release",
"codewhale-secrets",
"colored",
"dirs 7.0.0",
"fd-lock",
"futures-util",
"jsonwebtoken",
"libc",
"lru",
"parking_lot",
"portable-pty",
"regex",
"reqwest",
"ring",
"serde",
"serde_json",
"sha2 0.11.1",
"shellexpand",
"tempfile",
"thiserror 2.0.21",
"tokio",
"tokio-util",
"toml 1.1.4+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
"tracing",
"uuid",
"wait-timeout",
"windows",
"windows-sys 0.61.2",
"wiremock",
]
[[package]]
name = "codewhale-sandbox"
version = "0.10.2"
dependencies = [
"anyhow",
"async-trait",
"chrono",
"codewhale-app-server",
"codewhale-execpolicy",
"codewhale-paths",
"codewhale-release",
"codewhale-runtime",
"dirs 7.0.0",
"libc",
"portable-pty",
"reqwest",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"url",
"uuid",
"wait-timeout",
]
[[package]]
@@ -1311,7 +1391,6 @@ dependencies = [
"arboard",
"async-stream",
"async-trait",
"aws-lc-rs",
"axum",
"base64 0.23.1",
"chrono",
@@ -1332,12 +1411,15 @@ dependencies = [
"codewhale-mcp",
"codewhale-memory",
"codewhale-models",
"codewhale-net",
"codewhale-notify",
"codewhale-palette",
"codewhale-paths",
"codewhale-protocol",
"codewhale-ratatui",
"codewhale-release",
"codewhale-runtime",
"codewhale-sandbox",
"codewhale-sanitize",
"codewhale-secrets",
"codewhale-telemetry",
@@ -1355,11 +1437,9 @@ dependencies = [
"futures-util",
"globset",
"htmd",
"hyper-util",
"ignore",
"image",
"jsonschema",
"jsonwebtoken",
"libc",
"liblzma",
"lru",
@@ -1642,6 +1722,7 @@ dependencies = [
"crossterm_winapi",
"derive_more",
"document-features",
"filedescriptor",
"mio",
"parking_lot",
"rustix",
@@ -3984,6 +4065,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "peg"
version = "0.6.3"
@@ -4774,17 +4861,20 @@ dependencies = [
[[package]]
name = "rmcp"
version = "3.5.1"
version = "3.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350"
checksum = "ece64e19631fc38758181e637c15509a2c2709ee53bfde1e1d58f9dc991f919e"
dependencies = [
"async-trait",
"chrono",
"futures",
"indexmap",
"libc",
"oauth2",
"pastey",
"pin-project-lite",
"reqwest",
"schemars",
"serde",
"serde_json",
"thiserror 2.0.21",
@@ -4793,6 +4883,7 @@ dependencies = [
"tokio-util",
"tracing",
"url",
"uuid",
]
[[package]]
@@ -5088,6 +5179,7 @@ version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive",
+3
View File
@@ -17,11 +17,14 @@ members = [
"crates/mcp",
"crates/memory",
"crates/models",
"crates/net",
"crates/notify",
"crates/palette",
"crates/paths",
"crates/protocol",
"crates/release",
"crates/runtime",
"crates/sandbox",
"crates/secrets",
"crates/sanitize",
"crates/state",
+2 -2
View File
@@ -191,10 +191,10 @@ Connect [MCP servers](docs/MCP.md), add [skills](docs/SKILLS.md) and
[plugins](docs/PLUGINS.md), and run [hooks](docs/HOOKS.md) on session and tool
events. Code mode (experimental, on by default) lets the model write one
`execute_tools` program that calls several MCP and plugin tools; each call
still meets your approval rules and keeps its receipt. The bundled Computer
still meets your approval rules and keeps its receipt. The independently installed Computer
Use plugin (experimental, macOS only) can observe
and operate other apps. It stays off until you review its access and enable it
([guide](crates/tui/plugins/computer-use/README.md)).
([guide](https://github.com/codewhale-hq/codewhale-cu-plugin)).
## Ways to run it
+9 -5
View File
@@ -102,12 +102,16 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
## DeepSeek Harness utility packages (`dsh-util-values`, `dsh-tools`) — MIT
## DeepSeek Harness utility packages (`dsh-util-values`, `dsh-tools`, message compatibility) — MIT
The same bundle contains `@deepseek-ai/dsh-util-values` 0.1.7-alpha.2, and
`crates/tui/extension-host/src/dsh/dsh-tools-compat.js` contains verbatim
excerpts of `@deepseek-ai/dsh-tools` 0.1.7-alpha.2 (`lib/index.js`, lines 8-888
and 2528-2558), under the same licence text.
excerpts of `@deepseek-ai/dsh-tools` 0.2.1-rc.2 (`lib/index.js`), under the
same licence text. `crates/tui/extension-host/src/dsh/dsh-message-compat.js`
contains verbatim excerpts of `@deepseek-ai/dsh-brand` 0.2.1-rc.2,
`@deepseek-ai/dsh-llm` 0.2.1-rc.2, `@deepseek-ai/dsh-session` 0.2.1-rc.2,
`@deepseek-ai/dsh-system-prompt` 0.2.1-rc.2 and
`@deepseek-ai/dsh-util-crypto` 0.2.1-rc.2, under the same licence text.
Upstream: <https://github.com/deepseek-ai/deepseek-harness>
(`dsh-util-values` is published from `packages/util/values`)
@@ -148,7 +152,7 @@ carry the upstream licensing-transition statement and full Apache-2.0 text
reproduced below. The other packages are labeled MIT. Their exact distributed
notices are preserved.
### `@modelcontextprotocol/client` 2.3.1
### `@modelcontextprotocol/client` 2.4.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.
@@ -369,7 +373,7 @@ CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.
```
### `@modelcontextprotocol/core` 2.3.1
### `@modelcontextprotocol/core` 2.4.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.
+22 -23
View File
@@ -169,23 +169,24 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc
# project_instruction_imports = ["claude"]
#
# Standing instructions are budgeted per scope, in estimated tokens, and every
# trim or skip is reported at session start, to the model, and in /context.
# trim or skip is reported to the model and in /context.
# `global` covers ~/.codewhale/AGENTS.md (or ~/.agents/AGENTS.md), the global
# instructions.md fallbacks, ~/.claude/CLAUDE.md (last resort) and the
# `instructions = [...]` array. `project` covers the repository-root ->
# workspace AGENTS.md chain, co-read foreign files, and .codewhale/rules/*.md.
# A scope is capped at the smaller of its absolute token budget and its share
# of the usable window (context window minus the response ceiling and a 10%
# safety reserve). User config only: a cloned repository cannot change it.
# instructions.md fallbacks, ~/.claude/CLAUDE.md (last resort, claude_fallback
# only) and the `instructions = [...]` array. `project` covers the
# repository-root -> workspace AGENTS.md chain, co-read foreign files, and
# .codewhale/rules/*.md. A scope is capped at the smaller of its absolute token
# budget and its share of the usable window (context window minus the output
# reservation and a 10% safety reserve). Each file keeps at most its first
# 100 KiB. User config only: a cloned repository cannot change it.
# [instruction_budget]
# global_budget_tokens = 100000 # positive; absolute cap for the global scope
# project_budget_tokens = 100000 # positive; absolute cap for the project scope
# global_budget_percent = 25 # 1..=100, share of the usable window
# project_budget_percent = 25 # 1..=100, share of the usable window
# static_prefix_guard_percent = 80 # 1..=100; guard must leave >=10 points
# file_max_bytes = 8388608 # hard per-file ceiling; larger files skip
# file_max_bytes = 8388608 # 1..=8388608; larger files are skipped
# policy = "trim" # trim | refuse
# claude_fallback = true # AGENTS.md wins; else CLAUDE.md
# claude_fallback = false # opt in: AGENTS.md wins; else CLAUDE.md
# prompt_suggestion = true # opt-in: show ghost-text follow-up question in composer after each turn
# Optional tab/window title shown as `[title] …` in front of the terminal
@@ -1280,20 +1281,18 @@ code_mode = true # execute_tools composes MCP/plugin/native calls; false defers
# a prompt under Full Access, under Bypass, or with a
# matching session grant, which is bound to the
# plugin's reviewed build (an update asks again).
# Sandbox: on macOS the host runs under Seatbelt, and
# on Linux under bubblewrap (/usr/bin/bwrap) when a
# launch-time probe shows it works. Either way: no
# direct network, no reads of ~/.codewhale secrets/
# credentials/config/sessions, and writes only to its
# data dir and temp dirs (plus, on macOS, the Darwin
# user cache, ~/.cargo/registry, ~/.cargo/git and the
# npm cache, which every Seatbelt-sandboxed command
# gets). If bwrap is missing or cannot start (for
# example blocked unprivileged user namespaces), the
# host runs UNSANDBOXED with your user permissions and
# /plugin, `codewhale doctor` and the start diagnostic
# say so with bwrap's own error. On Windows it always
# runs with your user permissions.
# Native code requires a verified OS sandbox: Seatbelt
# on macOS, bubblewrap on Linux, and LPAC/Job on Windows.
# An unavailable Native sandbox refuses activation.
# Unix reads are limited to the selected runtime and
# libraries, canonical host bundle, captured Core-managed
# plugin stores and Native data. Writes stay in Native
# data and private scratch; repository reads and direct
# network are denied. Native plugins share one trusted
# cohort, not separate per-plugin OS sandboxes.
# On macOS a detached child can outlive process-group
# cancellation while retaining the sandbox restrictions.
# Builtin host policy is separate and diagnosed separately.
# Toggling this changes the plugin activation policy,
# so every plugin must be re-reviewed afterwards.
@@ -111,59 +111,22 @@ fn cached_build_scripts_classify_the_current_manifest_and_require_it() {
}
}
#[cfg(unix)]
#[test]
fn macos_helper_compilation_follows_the_relocated_manifest() {
use std::os::unix::fs::PermissionsExt;
fn tui_build_does_not_compile_or_embed_computer_use() {
let fixture = Fixture::new();
let original = fixture.0.path().join("original manifest");
let relocated = fixture.0.path().join("relocated manifest");
let source = Path::new("plugins/computer-use/src/backends/darwin-accessibility.m");
std::fs::create_dir_all(original.join(source).parent().unwrap()).unwrap();
std::fs::write(original.join(source), "fixture source").unwrap();
let executable = fixture.compile("tui", include_str!("../../tui/build.rs"), &original);
std::fs::rename(&original, &relocated).unwrap();
let manifest = fixture.0.path().join("manifest");
std::fs::create_dir(&manifest).unwrap();
let executable = fixture.compile("tui", include_str!("../../tui/build.rs"), &manifest);
let out = fixture.0.path().join("out");
let bin = fixture.0.path().join("bin");
std::fs::create_dir(&out).unwrap();
std::fs::create_dir(&bin).unwrap();
// Exercise the real build-script command arguments without requiring a macOS
// SDK or accessing a signing identity. A missing source fails as clang did.
for (name, script) in [
(
"xcrun",
"#!/bin/sh\nset -eu\nsource=\noutput=\nwhile [ \"$#\" -gt 0 ]; do\ncase \"$1\" in *.m) source=$1 ;; -o) shift; output=$1 ;; esac\nshift\ndone\ntest -f \"$source\"\nprintf %s \"$source\" > \"$output\"\n",
),
(
"codesign",
"#!/bin/sh\nset -eu\nfor last in \"$@\"; do :; done\ntest -s \"$last\"\n",
),
] {
let path = bin.join(name);
std::fs::write(&path, script).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let mut paths = vec![bin];
paths.extend(std::env::split_paths(
&std::env::var_os("PATH").unwrap_or_default(),
));
let output = checked(
run_script(&executable, &relocated, fixture.0.path())
run_script(&executable, &manifest, fixture.0.path())
.env("CARGO_CFG_TARGET_OS", "macos")
.env("CARGO_CFG_TARGET_ARCH", "aarch64")
.env("OUT_DIR", &out)
.env("PATH", std::env::join_paths(paths).unwrap())
.env_remove("CODEWHALE_CU_SIGN_IDENTITY"),
);
assert_eq!(
std::fs::read_to_string(out.join("computer-use-accessibility")).unwrap(),
relocated.join(source).to_string_lossy()
.env("OUT_DIR", &out),
);
assert_eq!(std::fs::read_dir(out).unwrap().count(), 0);
let directives = String::from_utf8_lossy(&output.stdout);
assert!(directives.contains(&format!(
"cargo:rerun-if-changed={}\n",
relocated.join(source).display()
)));
assert!(!directives.contains(original.to_string_lossy().as_ref()));
assert!(!directives.contains("computer-use"));
assert!(!directives.contains("CODEWHALE_CU_SIGN_IDENTITY"));
}
+1
View File
@@ -24,6 +24,7 @@ name = "codewhale"
path = "src/main.rs"
[dependencies]
codewhale-runtime = { path = "../runtime", version = "0.10.2" }
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
+41 -16
View File
@@ -465,6 +465,14 @@ The command prints the completion script to stdout; redirect it to a path your s
after_help = "GitHub Releases is the default source. Supported mirrors are explicit overrides or manifest-failure fallbacks. Checksums are required; older releases never replace a newer build.\n\nThe command prints the executable it will update. If you have multiple installs, run the intended binary by its full path.\n\nNew macOS/Linux install: curl -fsSL https://codewhale.net/install.sh | sh\nInstallation and PATH help: https://github.com/codewhale-hq/CodeWhale/blob/main/docs/INSTALL.md"
)]
Update(UpdateArgs),
#[command(about = "Remove receipt-owned Unix release commands; preserve all user data")]
Uninstall {
#[arg(
long,
help = "Remove the preflighted files; without this flag, print the plan"
)]
yes: bool,
},
/// Export the route catalog (`providers export --json`).
Providers(ProvidersArgs),
}
@@ -2282,6 +2290,17 @@ fn run() -> Result<()> {
|| matches!(cli.command.as_ref(), Some(Commands::AppServer(args)) if args.http),
"An inherited Runtime listener requires serve --http or app-server --http"
);
if let Some(Commands::Uninstall { yes }) = cli.command.as_ref() {
#[cfg(all(unix, not(target_env = "ohos")))]
return update::uninstall::run(*yes);
#[cfg(any(not(unix), target_env = "ohos"))]
{
let _ = yes;
bail!(
"Automatic uninstall is currently supported only for receipt-owned macOS/Linux release installs. Use the installer or package manager that owns this copy; user data is preserved."
);
}
}
preserve_exec_separator(&mut cli, &argv);
capture_exec_startup_options(&mut cli)?;
capture_serve_full_access(&mut cli);
@@ -2773,6 +2792,9 @@ fn run() -> Result<()> {
finish_cli_telemetry(session, &outcome, cli.verbose);
outcome
}
Some(Commands::Uninstall { .. }) => {
unreachable!("uninstall dispatches before config loading")
}
Some(Commands::Providers(args)) => run_providers_command(args),
None => {
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
@@ -4404,7 +4426,7 @@ fn owned_oauth_account(store: &ConfigStore, provider: ProviderKind) -> Result<Op
.oauth_credential_generation
.as_deref()
.context("no sign-in generation configured")?;
codewhale_tui::owned_oauth_account_label(provider, generation)
codewhale_runtime::oauth::owned_oauth_account_label(provider, generation)
}
#[cfg(test)]
@@ -5415,8 +5437,8 @@ fn run_config_command(
}
// A settings.toml key is answered from settings.toml, even when a
// stale config.toml copy that nothing reads is still present.
if codewhale_tui::config_keys::config_key_home(&key)
== codewhale_tui::config_keys::ConfigKeyHome::SettingsToml
if codewhale_runtime::config_keys::config_key_home(&key)
== codewhale_runtime::config_keys::ConfigKeyHome::SettingsToml
{
let value = settings_key_value(store, &key)?;
note_unread_config_copy(store, &key);
@@ -5424,7 +5446,7 @@ fn run_config_command(
return Ok(());
}
if key == "stream" || key.starts_with("stream.") {
let stream = codewhale_tui::config_keys::resolved_stream_config(&store.config)?;
let stream = codewhale_runtime::config_keys::resolved_stream_config(&store.config)?;
let value = if key == "stream" {
&stream
} else {
@@ -5466,11 +5488,11 @@ fn run_config_command(
}
// Refuse a key nothing reads, and send settings.toml keys to
// settings.toml, before config.toml is touched (#6563).
match codewhale_tui::config_keys::config_key_home(&key) {
codewhale_tui::config_keys::ConfigKeyHome::ConfigToml => {
match codewhale_runtime::config_keys::config_key_home(&key) {
codewhale_runtime::config_keys::ConfigKeyHome::ConfigToml => {
// A value typed or validated by its config.toml reader.
if let Some(typed) =
codewhale_tui::config_keys::config_toml_value(&key, &value)?
codewhale_runtime::config_keys::config_toml_value(&key, &value)?
{
store.config.extras.insert(key.trim().to_string(), typed);
store.save()?;
@@ -5478,15 +5500,17 @@ fn run_config_command(
return Ok(());
}
}
codewhale_tui::config_keys::ConfigKeyHome::SettingsToml => {
codewhale_runtime::config_keys::ConfigKeyHome::SettingsToml => {
refuse_workspace_scoped_settings_key(store, &key)?;
let path = codewhale_tui::config_keys::set_settings_value(&key, &value)?;
let path = codewhale_runtime::config_keys::set_settings_value(&key, &value)?;
println!("set {key} in {}", path.display());
note_unread_config_copy(store, &key);
return Ok(());
}
codewhale_tui::config_keys::ConfigKeyHome::Unknown => {
bail!(codewhale_tui::config_keys::unknown_config_key_message(&key));
codewhale_runtime::config_keys::ConfigKeyHome::Unknown => {
bail!(codewhale_runtime::config_keys::unknown_config_key_message(
&key
));
}
}
store.config.set_value(&key, &value)?;
@@ -5595,7 +5619,7 @@ fn run_config_command(
.context("config must be a TOML table")?
.insert(
"stream".to_string(),
codewhale_tui::config_keys::resolved_stream_config(&store.config)?,
codewhale_runtime::config_keys::resolved_stream_config(&store.config)?,
);
print!("{}", toml::to_string_pretty(&document)?);
Ok(())
@@ -5685,7 +5709,8 @@ fn refuse_workspace_scoped_settings_key(store: &ConfigStore, key: &str) -> Resul
/// never a config.toml copy that nothing reads.
fn settings_key_value(store: &ConfigStore, key: &str) -> Result<String> {
refuse_workspace_scoped_settings_key(store, key)?;
codewhale_tui::config_keys::settings_value(key)?.ok_or_else(|| anyhow!("key not found: {key}"))
codewhale_runtime::config_keys::settings_value(key)?
.ok_or_else(|| anyhow!("key not found: {key}"))
}
/// Point at a config.toml copy of a settings.toml key: nothing reads it.
@@ -5724,7 +5749,7 @@ fn apply_per_run_overrides(store: &mut ConfigStore, specs: &[String]) -> Result<
fn run_config_doctor(store: &ConfigStore) -> Result<()> {
println!("# {}", store.path().display());
let mut errors: Vec<String> = Vec::new();
let unread = codewhale_tui::config_keys::unread_config_keys(
let unread = codewhale_runtime::config_keys::unread_config_keys(
store.config.extras.keys().map(String::as_str),
);
for finding in &unread {
@@ -7225,7 +7250,7 @@ mod tests {
&overlays,
)
.unwrap();
let stream = codewhale_tui::config_keys::resolved_stream_config(&store.config).unwrap();
let stream = codewhale_runtime::config_keys::resolved_stream_config(&store.config).unwrap();
assert_eq!(stream["open_timeout_secs"].as_integer(), Some(120));
assert_eq!(stream["max_resumes"].as_integer(), Some(6));
assert_eq!(
@@ -7247,7 +7272,7 @@ mod tests {
"zzz_unknown = 1\ncalm_mode = \"false\"\nmax_subagents = 4\n",
);
let store = ConfigStore::load(Some(path)).expect("load fixture");
let unread = codewhale_tui::config_keys::unread_config_keys(
let unread = codewhale_runtime::config_keys::unread_config_keys(
store.config.extras.keys().map(String::as_str),
);
assert_eq!(unread.len(), 2, "{unread:#?}");
+21 -1
View File
@@ -29,6 +29,8 @@ use std::time::Duration;
use crate::hashing::sha256_hex;
mod compiled_host;
#[cfg(unix)]
pub(crate) mod uninstall;
const GITHUB_LATEST_RELEASE_PAGE_URL: &str =
"https://github.com/codewhale-hq/CodeWhale/releases/latest";
@@ -205,6 +207,8 @@ 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.
#[cfg(unix)]
let mut command_receipt = uninstall::CommandReceipt::capture(&current_exe)?;
let mut host_update = compiled_host::prepare(
&download,
latest_tag,
@@ -222,13 +226,26 @@ pub fn run_update(beta: bool, check_only: bool, proxy_arg: Option<String>) -> Re
validate_update_target(target, &executable_identity)
})
})();
#[cfg(unix)]
let receipt_renewal = command_receipt
.as_mut()
.map(|receipt| receipt.renew(&bytes))
.transpose();
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")?;
}
#[cfg(unix)]
if let Err(renewal) = receipt_renewal {
return Err(error.context(format!(
"command ownership receipt could not be renewed after partial update: {renewal:#}"
)));
}
return Err(error);
}
#[cfg(unix)]
receipt_renewal.context("binaries updated, but command ownership receipt renewal failed; preserved receipt/recovery paths require attention")?;
if let Some(host) = &host_update {
println!(
"Updated the qualified compiled image, notices and relink source beside this CLI; Node remains default."
@@ -1300,7 +1317,10 @@ fn parse_checksum_manifest(text: &str) -> Result<HashMap<String, String>> {
bail!("invalid SHA256 manifest line {}: {trimmed}", index + 1);
}
let (hash, rest) = trimmed.split_at(64);
let (hash, rest) = trimmed
.get(..64)
.zip(trimmed.get(64..))
.with_context(|| format!("invalid SHA256 manifest line {}: {trimmed}", index + 1))?;
if !hash.chars().all(|ch| ch.is_ascii_hexdigit())
|| rest.is_empty()
|| !rest.chars().next().is_some_and(char::is_whitespace)
+38
View File
@@ -265,6 +265,44 @@ impl Prepared {
}
}
#[cfg(unix)]
pub(super) fn owned_for_uninstall(directory: &Path, target: &str) -> Result<Vec<GuardedFile>> {
let Some(mut receipt) = GuardedFile::open_bounded(directory, RECEIPT, 64 * 1024)? else {
return Ok(Vec::new());
};
receipt.verify_owned()?;
let catalog = parse(&receipt.read_bounded(64 * 1024)?, env!("CARGO_PKG_VERSION"))?;
let host = catalog
.hosts
.iter()
.find(|host| host.target == target)
.context("installed compiled-host receipt has no matching target")?;
let mut files = Vec::new();
for (name, expected) in [
("codewhale-extension-host", host.sha256.as_str()),
(
"codewhale-extension-host.LICENSES.txt",
host.notices_sha256.as_str(),
),
(
"codewhale-extension-host.relink-source.tar.gz",
host.source_sha256.as_str(),
),
] {
if let Some(file) = GuardedFile::open(directory, name)? {
if file.sha256() != expected {
bail!(
"Preserved changed compiled-host file: {}",
directory.join(name).display()
);
}
files.push(file);
}
}
files.push(receipt);
Ok(files)
}
pub(super) fn required_for(current_exe: &Path) -> Result<bool> {
let directory = current_exe
.parent()
+259
View File
@@ -0,0 +1,259 @@
use super::*;
use codewhale_tui::delivery_files::GuardedFile;
use std::collections::BTreeMap;
const RECEIPT: &str = ".codewhale-install.sha256";
const MAX_RECEIPT: usize = 512;
pub(super) struct CommandReceipt {
directory: PathBuf,
receipt: GuardedFile,
hashes: BTreeMap<String, String>,
}
impl CommandReceipt {
pub(super) fn capture(executable: &Path) -> Result<Option<Self>> {
let directory = executable
.parent()
.context("executable has no parent")?
.canonicalize()?;
let Some(mut receipt) = GuardedFile::open_bounded(&directory, RECEIPT, MAX_RECEIPT)? else {
return Ok(None);
};
receipt.verify_owned()?;
let bytes = receipt.read_bounded(MAX_RECEIPT)?;
let text = std::str::from_utf8(&bytes).context("command receipt is not UTF-8")?;
let hashes = parse_checksum_manifest(text)?;
if hashes.is_empty()
|| hashes.len() > 2
|| text.lines().count() != hashes.len()
|| hashes
.keys()
.any(|name| !matches!(name.as_str(), "codewhale" | "codew"))
{
bail!(
"Invalid command ownership receipt {}; no files were changed",
directory.join(RECEIPT).display()
);
}
for (name, digest) in &hashes {
if let Some(mut file) = GuardedFile::open(&directory, name)? {
file.verify_owned()?;
if file.sha256() != digest.as_str() {
bail!(
"Preserved modified command {}; receipt no longer matches",
directory.join(name).display()
);
}
}
}
Ok(Some(Self {
directory,
receipt,
hashes: hashes.into_iter().collect(),
}))
}
pub(super) fn renew(&mut self, verified: &[u8]) -> Result<()> {
self.receipt.verify_owned()?;
let new_hash = sha256_hex(verified);
let mut text = String::new();
for (name, old_hash) in &self.hashes {
if let Some(mut file) = GuardedFile::open(&self.directory, name)? {
if file.sha256() != old_hash.as_str() && file.sha256() != new_hash.as_str() {
bail!(
"Cannot renew ownership of modified command {}",
self.directory.join(name).display()
);
}
file.verify_owned()?;
text.push_str(&format!("{} {name}\n", file.sha256()));
}
}
if text.is_empty() {
bail!("No receipt-owned commands remain; ownership receipt was preserved");
}
if self.receipt.read_bounded(MAX_RECEIPT)? == text.as_bytes() {
return Ok(());
}
self.receipt.verify_owned()?;
let mut staged = GuardedFile::stage(&self.directory, text.as_bytes(), false)?;
staged.verify_owned()?;
let backup = GuardedFile::recovery_name();
self.receipt.move_to_vacant(&backup).with_context(|| {
format!(
"receipt retirement failed; recovery at {}",
self.directory.join(self.receipt.name()).display()
)
})?;
if let Err(error) = staged.move_to_vacant(RECEIPT) {
let restored = self.receipt.move_to_vacant(RECEIPT);
bail!(
"receipt publication failed: {error}; restoration: {restored:?}; previous receipt {}, staged receipt {}",
self.directory.join(self.receipt.name()).display(),
self.directory.join(staged.name()).display()
);
}
println!(
"Renewed command ownership receipt; previous receipt retained at {}",
self.directory.join(backup).display()
);
self.receipt = staged;
Ok(())
}
}
pub(crate) fn run(yes: bool) -> Result<()> {
let result = remove(yes);
report_path_copies();
result
}
fn remove(yes: bool) -> Result<()> {
let identity = update_executable_identity()?;
let method = InstallMethod::detect(&identity.path);
println!("Selected executable: {}", identity.path.display());
println!(
"User state, credentials, sessions, workspaces, shell profiles and services are preserved."
);
if !method.supports_self_update() {
println!(
"This copy belongs to {}. Use: {}",
method.label(),
method.uninstall_command()
);
return Ok(());
}
if protected_update_path(&identity.path) {
bail!(
"System/package path was preserved. Identify its owning package manager and remove it through that manager."
);
}
let primary_name = identity
.path
.file_name()
.and_then(|name| name.to_str())
.context("executable basename is not UTF-8")?
.to_string();
if !matches!(primary_name.as_str(), "codewhale" | "codew") {
bail!(
"This command name has no Unix installer ownership contract; preserved {}",
identity.path.display()
);
}
let ownership = CommandReceipt::capture(&identity.path)?.with_context(|| format!(
"No command ownership receipt beside {}. This legacy/manual copy is preserved. Inspect its exact path and provenance before removing it manually; reinstalling into a fresh directory creates a receipted install.", identity.path.display()))?;
if ownership.hashes.get(&primary_name) != Some(&identity.file_hash) {
bail!("The receipt does not own the selected executable; nothing was removed");
}
let directory = ownership.directory.clone();
let mut files = Vec::new();
let mut primary = None;
for name in ownership.hashes.keys() {
if let Some(file) = GuardedFile::open(&directory, name)? {
if ownership.hashes.get(name).map(String::as_str) != Some(file.sha256()) {
bail!(
"Command changed during uninstall preflight; preserved {}",
directory.join(name).display()
);
}
if name == &primary_name {
primary = Some(file);
} else {
files.push(file);
}
}
}
let target =
release_asset_stem_for_prefix("codewhale", std::env::consts::OS, std::env::consts::ARCH);
let companions =
compiled_host::owned_for_uninstall(&directory, target.trim_start_matches("codewhale-"))?;
if companions.is_empty() {
println!("Any unreceipted compiled-host files are preserved.");
}
files.extend(companions);
let mut receipt = ownership.receipt;
let receipt_bytes = receipt.read_bounded(MAX_RECEIPT)?;
files.push(receipt);
files.push(primary.context("selected executable disappeared during preflight")?);
for file in &mut files {
file.verify_owned()?;
println!(
"{} {}",
if yes { "Remove" } else { "Would remove" },
directory.join(file.name()).display()
);
}
validate_primary_update_identity(&identity)?;
if !yes {
println!(
"No files were changed. Run this executable again with uninstall --yes to remove only this plan."
);
return Ok(());
}
let preflight = tempfile::Builder::new()
.prefix(".codewhale-uninstall-preflight-")
.tempdir_in(&directory)?
.keep();
std::fs::remove_dir(&preflight).with_context(|| {
format!(
"uninstall directory preflight failed at {}",
preflight.display()
)
})?;
let mut removed = Vec::new();
for file in &mut files {
let name = file.name().to_string();
let mut unlinked = false;
let outcome = validate_primary_update_identity(&identity)
.and_then(|()| file.remove_checked(&mut unlinked).map_err(Into::into));
if unlinked {
removed.push(name.clone());
}
if let Err(error) = outcome {
if removed.iter().any(|name| name == RECEIPT)
&& !removed.iter().any(|name| name == &primary_name)
{
let restore = (|| {
let mut staged = GuardedFile::stage(&directory, &receipt_bytes, false)?;
staged.verify_owned()?;
staged.move_to_vacant(RECEIPT).with_context(|| {
format!(
"receipt recovery bytes retained at {}",
directory.join(staged.name()).display()
)
})
})();
if let Err(restore) = restore {
eprintln!("Receipt recovery needs attention: {restore:#}");
}
}
bail!(
"Uninstall stopped at {name}: {error:#}. Already unlinked: {}. Any retained recovery locations are reported above; no unknown destination is overwritten.",
removed.join(", ")
);
}
}
println!(
"Removed this receipted Unix installation. Data and other installations are unchanged. Run hash -r in your shell."
);
Ok(())
}
fn report_path_copies() {
let mut seen = std::collections::HashSet::new();
for directory in std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) {
for name in [
"codewhale",
"codew",
"codewhale-tui",
"deepseek",
"deepseek-tui",
] {
let path = directory.join(name);
if seen.insert(path.clone()) && std::fs::symlink_metadata(&path).is_ok() {
println!("Remaining PATH entry (preserved): {}", path.display());
}
}
}
}
+1 -1
View File
@@ -28,7 +28,7 @@ fn passthrough_help_leaves_configuration_and_legacy_credentials_untouched() {
.expect("seed synthetic legacy credential");
let legacy_bytes = fs::read(&legacy).expect("read legacy fixture");
for subcommand in ["exec", "init", "setup", "mcp", "review", "rc"] {
for subcommand in ["exec", "init", "setup", "mcp", "review", "rc", "uninstall"] {
for flag in ["--help", "-h"] {
let output = Command::new(env!("CARGO_BIN_EXE_codewhale"))
.env_clear()
+22 -10
View File
@@ -3,7 +3,7 @@
//! rewrite legacy child-server definitions. The active MCP client is separate.
use std::fs;
use std::io::Write;
use std::io::{BufRead, BufReader, Read, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
@@ -67,23 +67,35 @@ impl Fixture {
.spawn()
.expect("spawn codewhale mcp-server");
{
let stdin = child.stdin.as_mut().expect("mcp-server stdin");
for request in requests {
writeln!(stdin, "{request}").expect("write request");
let mut stdin = child.stdin.take().expect("mcp-server stdin");
let mut stdout = BufReader::new(child.stdout.take().expect("mcp-server stdout"));
let mut responses = Vec::new();
for request in requests {
writeln!(stdin, "{request}").expect("write request");
stdin.flush().expect("flush request");
if let Some(id) = request.get("id") {
let mut line = String::new();
assert!(stdout.read_line(&mut line).expect("read response") > 0);
let response: Value = serde_json::from_str(&line).expect("stdout is only JSON-RPC");
assert_eq!(response.get("id"), Some(id));
responses.push(response);
}
}
drop(stdin);
let mut remainder = String::new();
stdout
.read_to_string(&mut remainder)
.expect("read remaining output");
assert!(
remainder.trim().is_empty(),
"unexpected response: {remainder}"
);
let output = child.wait_with_output().expect("mcp-server output");
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
assert!(
output.status.success(),
"native MCP server failed: {stderr}"
);
let responses = String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| serde_json::from_str::<Value>(line).expect("stdout is only JSON-RPC"))
.collect();
(responses, stderr)
}
}
+5
View File
@@ -10,6 +10,10 @@ description = "Config schema and precedence model for Codewhale"
[lints]
workspace = true
[features]
schema = ["dep:schemars"]
test-support = []
[dependencies]
codewhale-protocol = { path = "../protocol", version = "0.10.2" }
anyhow.workspace = true
@@ -18,6 +22,7 @@ chrono.workspace = true
ring = "0.17.14"
rust_decimal = { version = "1.43.0", default-features = false, features = ["std"] }
semver.workspace = true
schemars = { version = "1.2.1", optional = true, features = ["derive", "preserve_order"] }
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.2" }
codewhale-paths = { path = "../paths", version = "0.10.2" }
codewhale-secrets = { path = "../secrets", version = "0.10.2" }
+88
View File
@@ -0,0 +1,88 @@
use serde::Deserialize;
// === Types ===
/// `[extension_host]`: settings for the experimental TypeScript extension
/// host (`[features] extension_host`). User config only: project-scope config
/// is applied by an explicit allowlist that does not include this table.
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ExtensionHostConfig {
/// Explicit MCP protocol backend. Host is the default and supports stdio,
/// HTTP and SSE independently of optional Native extension activation.
#[serde(default)]
pub mcp_backend: crate::McpBackend,
/// Which runtime runs the host: `node` (the default), `bun`, or `auto`
/// (Bun when a supported one is found and starts, else Node). `bun` and
/// `auto` are opt-ins: Bun is not the default until it is qualified on
/// every platform. An explicit `bun` or `node` never falls back to the
/// other runtime. Unset means `node`, except that a table setting only
/// `bun` means `bun` ([`Self::effective_runtime`]).
#[serde(default)]
pub runtime: Option<ExtensionHostRuntime>,
/// Path to a Node.js runtime (`^22.19 || >=24`). When set it is the only
/// Node candidate: if it does not run or is below the floor, Node
/// resolution fails with that reason instead of searching `PATH`.
/// Unset, every `node` on `PATH` is tried in order, skipping any inside
/// a `node_modules` directory or the working directory.
#[serde(default)]
pub node: Option<String>,
/// Path to a Bun runtime (>= 1.4.0). When set it is the only Bun
/// candidate, as for `node`. Unset, `bun` on `PATH` and then
/// `$BUN_INSTALL/bin` (default `~/.bun/bin`) are tried, with the same
/// skips.
#[serde(default)]
pub bun: Option<String>,
}
impl ExtensionHostConfig {
/// `runtime` as configured, else `bun` when only a Bun path is set (the
/// table names no other runtime), else `node`.
#[must_use]
pub fn effective_runtime(&self) -> ExtensionHostRuntime {
match (self.runtime, &self.node, &self.bun) {
(Some(runtime), _, _) => runtime,
(None, None, Some(_)) => ExtensionHostRuntime::Bun,
(None, _, _) => ExtensionHostRuntime::Node,
}
}
}
/// `[extension_host] runtime`. Node is the default; Bun (`bun`, or `auto`,
/// which prefers it) stays an opt-in until an explicit, recorded cutover.
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum ExtensionHostRuntime {
Auto,
Bun,
#[default]
Node,
}
impl ExtensionHostRuntime {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Bun => "bun",
Self::Node => "node",
}
}
}
/// `[plugins."<name>"]`: per-plugin settings, keyed by the plugin's manifest
/// name. User config only (like `[extension_host]`): project-scope config is
/// applied by an explicit allowlist that does not include this table, so a
/// repository cannot configure the plugins it asks you to trust.
#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct PluginSettings {
/// `[plugins."<name>".config]`: a TOML table delivered as the `config`
/// argument of the plugin's `apply(ctx, config)` when the extension host
/// activates it, validated against the plugin's own `Config` schema when it
/// declares one. A change takes effect at `/plugin reload` (the plugin is
/// re-activated). Values are not secrets storage: they are shown to the
/// plugin's code, and `/plugin show` lists their keys.
#[serde(default)]
pub config: Option<toml::Table>,
}
+580
View File
@@ -0,0 +1,580 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Events that can trigger hook execution
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
/// Triggered when a new session starts
SessionStart,
/// Triggered when a session ends (quit, Ctrl+C)
SessionEnd,
/// Triggered before a user message is sent to the LLM
MessageSubmit,
/// Triggered before a tool is executed
ToolCallBefore,
/// Triggered after a tool completes (success or failure)
ToolCallAfter,
/// Triggered when the user changes modes (Plan, Act, Operate)
ModeChange,
/// Triggered when an error occurs
OnError,
/// Triggered after a turn completes and post-turn state has been updated
TurnEnd,
/// Triggered when a sub-agent is spawned
SubagentSpawn,
/// Triggered when a sub-agent reaches a terminal state
SubagentComplete,
/// Triggered immediately before each `exec_shell` invocation. The hook's
/// stdout is parsed as `KEY=VALUE\n` lines and merged on top of the
/// shell command's environment — useful for ephemeral credentials,
/// per-skill PATH adjustments, or short-lived tokens (#456). Hooks that
/// fail or time out are logged but do *not* abort the shell call; they
/// simply contribute no env vars.
ShellEnv,
/// Triggered when the session becomes idle after real work: a turn
/// finished (or a wait ended) and no prompt, approval, or continuation
/// is outstanding (#6004). Transient tool errors never fire this by
/// themselves; it marks "agent done, waiting for the next instruction".
SessionIdle,
/// Triggered when a turn ends in a terminal failure (#6004). Transient
/// tool failures that the agent absorbs never fire this; only a turn
/// whose final status is failed does. Hook authors that want opencode's
/// grace-period semantics should debounce inside the hook.
SessionError,
/// Triggered when the agent starts waiting on the person: an approval
/// prompt opens, a `request_user_input` question is presented, or a goal
/// continuation is parked between passes (#6004). The payload's `reason`
/// field is `approval`, `user_input`, or `goal_continuation`.
WaitingForUser,
/// Triggered when idle or waiting transitions to active work (#6004).
/// Startup and repeated observations of the same state stay silent.
SessionBusy,
}
/// Every event name the runtime actually fires, in the order `/hooks events`
/// and `docs/HOOKS.md` list them. Tests assert this is exhaustive so a new
/// variant cannot ship without a documented firing point.
#[cfg(any(test, feature = "test-support"))]
pub const ALL_HOOK_EVENTS: [HookEvent; 15] = [
HookEvent::SessionStart,
HookEvent::SessionEnd,
HookEvent::TurnEnd,
HookEvent::MessageSubmit,
HookEvent::ToolCallBefore,
HookEvent::ToolCallAfter,
HookEvent::ModeChange,
HookEvent::OnError,
HookEvent::SubagentSpawn,
HookEvent::SubagentComplete,
HookEvent::ShellEnv,
HookEvent::SessionIdle,
HookEvent::SessionError,
HookEvent::WaitingForUser,
HookEvent::SessionBusy,
];
/// How much a hook's result can change what Codewhale does next.
///
/// This is the steering allowlist. "Observer" is a statement about
/// Codewhale's control flow only — an observer hook is still an arbitrary
/// shell command and can have any external side effect it likes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookSteering {
/// stdout/exit code can replace or block the submitted text.
TransformsSubmittedText,
/// stdout/exit code can allow, deny, ask, rewrite input, or add context.
DecidesToolCall,
/// stdout contributes `KEY=VALUE` pairs to one `exec_shell` invocation.
ContributesShellEnv,
/// stdout is ignored and the result cannot change Codewhale's behavior.
Observer,
}
impl HookEvent {
/// Get string representation for environment variable
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
HookEvent::SessionStart => "session_start",
HookEvent::SessionEnd => "session_end",
HookEvent::MessageSubmit => "message_submit",
HookEvent::ToolCallBefore => "tool_call_before",
HookEvent::ToolCallAfter => "tool_call_after",
HookEvent::ModeChange => "mode_change",
HookEvent::OnError => "on_error",
HookEvent::TurnEnd => "turn_end",
HookEvent::SubagentSpawn => "subagent_spawn",
HookEvent::SubagentComplete => "subagent_complete",
HookEvent::ShellEnv => "shell_env",
HookEvent::SessionIdle => "session_idle",
HookEvent::SessionError => "session_error",
HookEvent::WaitingForUser => "waiting_for_user",
HookEvent::SessionBusy => "session_busy",
}
}
/// The steering contract for this event, as implemented.
#[must_use]
pub fn steering(self) -> HookSteering {
match self {
HookEvent::MessageSubmit => HookSteering::TransformsSubmittedText,
HookEvent::ToolCallBefore => HookSteering::DecidesToolCall,
HookEvent::ShellEnv => HookSteering::ContributesShellEnv,
HookEvent::SessionStart
| HookEvent::SessionEnd
| HookEvent::ToolCallAfter
| HookEvent::ModeChange
| HookEvent::OnError
| HookEvent::TurnEnd
| HookEvent::SubagentSpawn
| HookEvent::SubagentComplete
| HookEvent::SessionIdle
| HookEvent::SessionError
| HookEvent::SessionBusy
| HookEvent::WaitingForUser => HookSteering::Observer,
}
}
/// Whether a hook result for this event can change Codewhale's own
/// behavior. Never read this as "side-effect free" — see [`HookSteering`].
#[must_use]
pub fn can_steer(self) -> bool {
!matches!(self.steering(), HookSteering::Observer)
}
/// Whether this event's context carries a tool name/arguments, so
/// `tool_name` / `tool_category` conditions can ever match.
///
/// `on_error` is included because the tool-failure path in
/// `tui/tool_routing.rs` fires it with the tool name, call id, and result
/// attached. An `on_error` firing that has no tool behind it (a transport
/// or capacity error) simply does not match a tool predicate — it is
/// skipped at dispatch, not rejected at load.
#[must_use]
pub fn provides_tool_identity(self) -> bool {
matches!(
self,
HookEvent::ToolCallBefore
| HookEvent::ToolCallAfter
| HookEvent::ShellEnv
| HookEvent::OnError
)
}
/// Whether this event's context can carry a real process exit code, so
/// `exit_code` conditions can ever match. `tool_call_after` observes every
/// completed tool and `on_error` observes the failing ones; in both cases
/// the code is only present when the tool actually reported one
/// (`exec_shell` and friends).
#[must_use]
pub fn provides_exit_code(self) -> bool {
matches!(self, HookEvent::ToolCallAfter | HookEvent::OnError)
}
/// Whether this event's context carries a mode label, so `mode`
/// conditions can ever match. `shell_env` fires inside the `exec_shell`
/// tool with a deliberately narrow context and has no mode.
#[must_use]
pub fn provides_mode(self) -> bool {
!matches!(self, HookEvent::ShellEnv)
}
/// Whether `background = true` is honored as actual scheduling for this
/// event. Events whose result is part of the contract are always run in
/// the foreground, so declaring them background is a config error rather
/// than a scheduling choice.
#[must_use]
pub fn honors_background(self) -> bool {
!matches!(self, HookEvent::ShellEnv)
}
}
/// Condition for when a hook should run
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[derive(Default)]
pub enum HookCondition {
/// Always run this hook
#[default]
Always,
/// Only run for specific tool names
ToolName {
/// Tool name to match (e.g., "`exec_shell`", "`write_file`")
name: String,
},
/// Only run for specific tool categories
ToolCategory {
/// Category: "safe", "`file_write`", "shell"
category: String,
},
/// Only run in specific modes
Mode {
/// Mode: "plan", "agent", "yolo"
mode: String,
},
/// Only run when exit code matches (for `ToolCallAfter` / `OnError`)
ExitCode {
/// Exit code to match.
///
/// `i64`, not `i32`: a Windows crash code such as `3221225477`
/// (`0xC0000005`, access violation) is a real code a shell tool
/// reports, and narrowing it would silently turn the predicate into
/// one that can never match.
code: i64,
},
/// Combine multiple conditions with AND
All { conditions: Vec<HookCondition> },
/// Combine multiple conditions with OR
Any { conditions: Vec<HookCondition> },
}
#[derive(Debug, Clone)]
pub struct NativeShellHook {
pub owner: crate::OwnerRef,
pub scope: Option<crate::EntryRef>,
pub handle: u64,
pub dialect: String,
pub point: String,
pub matcher: Option<String>,
}
/// A single hook definition
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hook {
#[serde(skip)]
pub native_shell: Option<NativeShellHook>,
/// The event that triggers this hook
pub event: HookEvent,
/// Shell command to execute (platform shell: `sh -c` on Unix, `cmd /C` on Windows)
pub command: String,
/// Optional condition for when this hook should run
#[serde(default)]
pub condition: Option<HookCondition>,
/// Timeout in seconds (default: 30)
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
/// Run in background (don't wait for completion)
#[serde(default)]
pub background: bool,
/// Continue if this hook fails (default: true)
#[serde(default = "default_continue_on_error")]
pub continue_on_error: bool,
/// Optional name for logging/debugging
#[serde(default)]
pub name: Option<String>,
/// Content- and generation-bound authority for a plugin-contributed hook.
/// Never accepted from TOML; only the reviewed staged adapter may attach
/// it after parsing immutable bytes.
#[serde(skip)]
pub plugin_authority: Option<crate::PluginAuthority>,
/// Exact-byte project approval attached by the loader; never read from TOML.
#[serde(skip)]
pub project_authority: Option<crate::ProjectHookAuthority>,
}
fn default_timeout() -> u64 {
30
}
fn default_continue_on_error() -> bool {
true
}
impl Hook {
/// Create a new hook with minimal configuration
pub fn new(event: HookEvent, command: &str) -> Self {
Self {
event,
command: command.to_string(),
condition: None,
timeout_secs: 30,
background: false,
continue_on_error: true,
name: None,
plugin_authority: None,
project_authority: None,
native_shell: None,
}
}
/// Builder: set condition
pub fn with_condition(mut self, condition: HookCondition) -> Self {
self.condition = Some(condition);
self
}
/// Builder: set timeout
pub fn with_timeout(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
/// Builder: run in background
pub fn background(mut self) -> Self {
self.background = true;
self
}
/// Builder: set name
pub fn with_name(mut self, name: &str) -> Self {
self.name = Some(name.to_string());
self
}
}
/// A configured hook that can never behave the way it is written.
///
/// Reported by [`HooksConfig::validate`] and, for rejections, surfaced in
/// `/hooks list` so a broken hook is visible instead of silently inert.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookConfigProblem {
/// Hook `name`, or `None` for an unnamed entry.
pub name: Option<String>,
/// The event the hook is registered for, or `None` when the problem is
/// with a setting in the `[hooks]` table itself rather than with one
/// entry — `default_timeout_secs` governs every hook, so pinning its
/// rejection on an arbitrary hook would misreport the blast radius.
pub event: Option<HookEvent>,
/// What is wrong, in one line, with no paths or payload content.
pub detail: String,
/// `true` when the hook is dropped at load and will never run.
pub rejected: bool,
}
/// Configuration for hooks (loaded from config.toml)
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HooksConfig {
/// List of hooks to execute
#[serde(default)]
pub hooks: Vec<Hook>,
/// Global enable/disable for all hooks
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Global timeout override. When set this **replaces** every hook's own
/// `timeout_secs` rather than only filling in for hooks that omit one —
/// see `HookExecutor::execute_sync_inner`. Documented as-implemented in
/// `docs/HOOKS.md`; leave unset for per-hook timeouts.
#[serde(default)]
pub default_timeout_secs: Option<u64>,
/// Working directory for hook execution (default: workspace)
#[serde(default)]
pub working_dir: Option<PathBuf>,
/// Problems found by [`HooksConfig::validate`] at load time. Never read
/// from or written to `config.toml`; populated by
/// the host loader so `/hooks` can show a rejected
/// hook instead of leaving it silently inert.
#[serde(skip)]
pub problems: Vec<HookConfigProblem>,
}
fn default_enabled() -> bool {
true
}
impl HooksConfig {
/// Report every configured hook that cannot behave as written.
///
/// A condition that references context the event never carries can never
/// match, so a hook wearing one is inert — the dangerous version of that
/// is a `deny` gate the operator believes is armed. Those are reported as
/// `rejected` and dropped by the host loader rather than left
/// to fail silently at dispatch time. Problems that only affect how a
/// hook is scheduled are reported as warnings and the hook still runs.
#[must_use]
pub fn validate(&self) -> Vec<HookConfigProblem> {
self.validate_settings()
.into_iter()
.chain(self.validate_indexed().into_iter().map(|(_, p)| p))
.collect()
}
/// Problems with the `[hooks]` table itself, independent of any entry.
///
/// `default_timeout_secs = 0` is the one that matters: it *replaces* every
/// hook's own `timeout_secs`, so the per-hook `timeout_secs = 0` rejection
/// does nothing to stop one line from killing every hook in the config
/// before it can produce output — including a `tool_call_before` gate,
/// which then fails closed on every tool call.
pub fn validate_settings(&self) -> Vec<HookConfigProblem> {
let mut problems = Vec::new();
if self.default_timeout_secs == Some(0) {
problems.push(HookConfigProblem {
name: None,
event: None,
detail: "`default_timeout_secs = 0` would expire every hook immediately; \
the override is ignored and per-hook `timeout_secs` applies"
.to_string(),
rejected: true,
});
}
problems
}
/// [`Self::validate`], but each problem is paired with the index of the
/// entry that produced it.
///
/// The index is the hook's identity for rejection purposes. Keying on
/// `(name, event)` instead would make one invalid unnamed `session_start`
/// entry delete *every* unnamed `session_start` entry, and one invalid
/// `gate` delete every other hook also called `gate` — innocent hooks
/// dropped because they share a label with a broken one.
pub fn validate_indexed(&self) -> Vec<(usize, HookConfigProblem)> {
let mut problems = Vec::new();
for (index, hook) in self.hooks.iter().enumerate() {
let mut condition_rejections = Vec::new();
collect_condition_problems(hook.event, hook.condition.as_ref(), &mut |detail| {
condition_rejections.push(detail);
});
let mut push = |detail: String, rejected: bool| {
problems.push((
index,
HookConfigProblem {
name: hook.name.clone(),
event: Some(hook.event),
detail,
rejected,
},
));
};
// A condition that can never match makes the hook inert, so it is
// dropped; the rest only affect how the hook is scheduled, so the
// hook still runs and the problem is a warning.
for detail in condition_rejections {
push(detail, true);
}
if hook.background && !hook.event.honors_background() {
push(
format!(
"`background = true` is not honored for `{}`; its stdout is the \
contract, so it always runs in the foreground",
hook.event.as_str()
),
false,
);
} else if hook.background && hook.event.can_steer() {
push(
format!(
"`background = true` makes this `{}` hook observer-only — it is \
submitted and never awaited, so it cannot steer the turn",
hook.event.as_str()
),
false,
);
}
if hook.timeout_secs == 0 {
push(
"`timeout_secs = 0` expires immediately; the command is killed \
before it can produce output"
.to_string(),
true,
);
}
if hook.command.trim().is_empty() {
push("`command` is empty".to_string(), true);
}
}
problems
}
/// Get hooks for a specific event
pub fn hooks_for_event(&self, event: HookEvent) -> Vec<&Hook> {
if !self.enabled {
return Vec::new();
}
self.hooks.iter().filter(|h| h.event == event).collect()
}
/// The timeout the runtime will actually apply to `hook`.
///
/// `[hooks].default_timeout_secs` *replaces* the per-hook value when set.
/// This is the single owner of that rule: the executor enforces it and
/// `/hooks list` displays it, so the listing cannot advertise a per-hook
/// number the runtime will not use.
#[must_use]
pub fn effective_timeout_secs(&self, hook: &Hook) -> u64 {
// `filter`, not `unwrap_or`: `apply_validation` already strips a zero
// override at load, but a `HooksConfig` can also be built in code, and
// a zero here means "kill every hook before it speaks".
self.default_timeout_secs
.filter(|secs| *secs > 0)
.unwrap_or(hook.timeout_secs)
}
/// `true` when `[hooks].default_timeout_secs` is overriding per-hook
/// timeouts, so surfaces can name the provenance of the number they show.
#[must_use]
pub fn timeout_is_overridden(&self) -> bool {
// An ignored zero override is not provenance: `/hooks list` must not
// credit a number to a setting the runtime refused to apply.
self.default_timeout_secs.is_some_and(|secs| secs > 0)
}
}
/// Walk a condition tree and report every predicate the event can never
/// satisfy. `all` / `any` are walked so a nested unsupported predicate is
/// caught rather than hidden behind a combinator.
fn collect_condition_problems(
event: HookEvent,
condition: Option<&HookCondition>,
reject: &mut impl FnMut(String),
) {
let Some(condition) = condition else {
return;
};
match condition {
HookCondition::Always => {}
HookCondition::ToolName { .. } | HookCondition::ToolCategory { .. } => {
if !event.provides_tool_identity() {
reject(format!(
"`{}` never carries a tool name, so this tool condition can never match",
event.as_str()
));
}
}
HookCondition::Mode { .. } => {
if !event.provides_mode() {
reject(format!(
"`{}` runs with a narrow context that has no mode, so a `mode` \
condition can never match; scope it with `tool_name` or \
`tool_category` instead",
event.as_str()
));
}
}
HookCondition::ExitCode { .. } => {
if !event.provides_exit_code() {
reject(format!(
"`{}` has no completed process to read an exit code from; \
`exit_code` conditions are only supported on `tool_call_after` \
and `on_error`",
event.as_str()
));
}
}
HookCondition::All { conditions } | HookCondition::Any { conditions } => {
for nested in conditions {
// Reborrow rather than pass `reject` itself: `&mut F` also
// implements `FnMut`, so passing it directly would recurse in
// the type parameter and never finish monomorphizing.
collect_condition_problems(event, Some(nested), &mut *reject);
}
}
}
}
+124
View File
@@ -0,0 +1,124 @@
use serde::Deserialize;
/// Absolute per-scope default: 100k estimated tokens.
pub const DEFAULT_SCOPE_BUDGET_TOKENS: usize = 100_000;
/// Per-scope default as a share of the usable window.
pub const DEFAULT_SCOPE_BUDGET_PERCENT: u8 = 25;
/// Default ceiling for the whole static prefix, as a share of the usable window.
pub const DEFAULT_STATIC_PREFIX_GUARD_PERCENT: u8 = 80;
/// Hard memory-safety ceiling for one instruction file (8 MiB).
pub const DEFAULT_FILE_MAX_BYTES: u64 = 8 * 1024 * 1024;
const MIN_STATIC_RESIDUAL_PERCENT: u8 = 10;
/// What to do when an instruction scope does not fit.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum InstructionBudgetPolicy {
/// Trim the scope and continue with a warning (default).
#[default]
Trim,
/// Stop the session instead of trimming or running an unsatisfiable guard.
Refuse,
}
impl InstructionBudgetPolicy {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Trim => "trim",
Self::Refuse => "refuse",
}
}
}
/// The `[instruction_budget]` config table.
///
/// Every field is optional so the table only states what the user changed and
/// the defaults stay in one place. `deny_unknown_fields` makes a typo inside
/// the table fail loudly — the top-level config is deliberately tolerant of
/// unknown *tables* (#6516), so silently accepting `global_budget_token`
/// would hide the mistake forever.
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InstructionBudgetConfig {
/// Absolute ceiling for the global scope, in estimated tokens.
pub global_budget_tokens: Option<usize>,
/// Absolute ceiling for the project scope, in estimated tokens.
pub project_budget_tokens: Option<usize>,
/// Global scope ceiling as a share of the usable window (1–100).
pub global_budget_percent: Option<u8>,
/// Project scope ceiling as a share of the usable window (1–100).
pub project_budget_percent: Option<u8>,
/// Ceiling for the whole static prefix as a share of the usable window.
pub static_prefix_guard_percent: Option<u8>,
/// Hard per-file ceiling in bytes; a larger file is skipped, not trimmed.
pub file_max_bytes: Option<u64>,
/// `trim` (default) or `refuse`.
pub policy: Option<InstructionBudgetPolicy>,
/// Read `CLAUDE.md` when no `AGENTS.md`/codewhale file is present.
pub claude_fallback: Option<bool>,
}
impl InstructionBudgetConfig {
/// Reject values that cannot mean anything.
///
/// Called while config is applied, so a broken table stops the launch
/// instead of silently producing a nonsensical budget.
pub fn validate(&self) -> Result<(), String> {
for (name, value) in [
("global_budget_tokens", self.global_budget_tokens),
("project_budget_tokens", self.project_budget_tokens),
] {
if value == Some(0) {
return Err(format!(
"instruction_budget.{name} must be positive (0 is not an unlimited budget)"
));
}
}
if self
.file_max_bytes
.is_some_and(|value| value == 0 || value > DEFAULT_FILE_MAX_BYTES)
{
return Err(
"instruction_budget.file_max_bytes must be between 1 and 8388608 (the hard ceiling cannot be raised)"
.to_string(),
);
}
for (name, value) in [
("global_budget_percent", self.global_budget_percent),
("project_budget_percent", self.project_budget_percent),
(
"static_prefix_guard_percent",
self.static_prefix_guard_percent,
),
] {
if let Some(value) = value
&& !(1..=100).contains(&value)
{
return Err(format!(
"instruction_budget.{name} must be between 1 and 100 (got {value})"
));
}
}
let global_percent = self
.global_budget_percent
.unwrap_or(DEFAULT_SCOPE_BUDGET_PERCENT);
let project_percent = self
.project_budget_percent
.unwrap_or(DEFAULT_SCOPE_BUDGET_PERCENT);
let guard = self
.static_prefix_guard_percent
.unwrap_or(DEFAULT_STATIC_PREFIX_GUARD_PERCENT);
if guard < global_percent.saturating_add(project_percent) {
return Err(format!(
"instruction_budget.static_prefix_guard_percent ({guard}) must be at least the sum of the scope percents ({global_percent} + {project_percent})"
));
}
if guard - global_percent.saturating_add(project_percent) < MIN_STATIC_RESIDUAL_PERCENT {
return Err(format!(
"instruction_budget.static_prefix_guard_percent ({guard}) must leave at least {MIN_STATIC_RESIDUAL_PERCENT} points above the scope percents ({global_percent} + {project_percent}) for non-instruction static content"
));
}
Ok(())
}
}
+42 -9
View File
@@ -7,7 +7,10 @@ mod config_document;
pub mod credentials;
pub mod descriptors;
pub mod device_code;
pub mod extension_host;
pub mod external_credentials;
pub mod hooks;
pub mod instruction_budget;
pub mod legacy_root;
pub mod model_reference;
pub mod models_dev;
@@ -1771,6 +1774,10 @@ pub struct PluginAuthority {
pub plugin_name: String,
pub workspace: std::path::PathBuf,
pub state_path: std::path::PathBuf,
#[serde(default)]
pub managed_policy_path: Option<std::path::PathBuf>,
#[serde(skip)]
pub managed_policy_source: Option<std::path::PathBuf>,
pub source_manifest: std::path::PathBuf,
pub staged_manifest: std::path::PathBuf,
pub content_hash: String,
@@ -1781,19 +1788,45 @@ pub struct PluginAuthority {
pub state_generation: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectHookAuthority {
pub workspace: PathBuf,
pub digest: String,
}
/// Identity of one activation. `generation` bumps on every (re)activation;
/// `owner_token` is 128+ random bits minted by the core and handed over only
/// in `ext/activate`. It catches bugs and stale fibers; it is not a boundary
/// against a malicious plugin in the same process (see the design, §4.4).
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct OwnerRef {
pub plugin_id: String,
pub generation: u64,
pub owner_token: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EntryRef {
pub path: String,
pub sha256: String,
}
impl std::fmt::Display for PluginId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// Protocol owner selected only by user configuration. Rust is the default;
/// Host explicitly selects the SDK and never falls back to Rust on refusal.
/// Host uses the SDK by default; explicit Rust selection remains available.
/// A Host refusal never falls back to Rust.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum McpBackend {
#[default]
Rust,
#[default]
Host,
}
@@ -1849,7 +1882,7 @@ pub struct SnapshotsToml {
pub extras: BTreeMap<String, toml::Value>,
}
fn default_snapshots_enabled() -> bool {
pub fn default_snapshots_enabled() -> bool {
true
}
@@ -4832,7 +4865,7 @@ fn resolve_deepseek_base_url(
}
}
fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
pub fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
let normalized = mode.trim().to_ascii_lowercase().replace(['_', ' '], "-");
if normalized.is_empty() || xiaomi_mimo_mode_uses_standard_endpoint(&normalized) {
return None;
@@ -4865,7 +4898,7 @@ fn xiaomi_mimo_base_url_for_mode(mode: &str) -> Option<&'static str> {
})
}
fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
pub fn xiaomi_mimo_mode_uses_standard_endpoint(normalized_mode: &str) -> bool {
matches!(
normalized_mode,
"standard" | "default" | "payg" | "paygo" | "pay-as-you-go" | "pay-as-go"
@@ -4912,7 +4945,7 @@ fn xiaomi_mimo_env_api_key_for_runtime(
env_value(XIAOMI_MIMO_TOKEN_PLAN_ENV_VARS).or_else(|| env_value(XIAOMI_MIMO_STANDARD_ENV_VARS))
}
fn resolve_xiaomi_mimo_base_url(
pub fn resolve_xiaomi_mimo_base_url(
configured: Option<String>,
api_key: Option<&str>,
mode: Option<&str>,
@@ -4948,7 +4981,7 @@ fn resolve_xiaomi_mimo_base_url(
}
}
fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
pub fn xiaomi_mimo_api_key_uses_token_plan(api_key: Option<&str>) -> bool {
api_key.is_some_and(|key| key.trim_start().starts_with("tp-"))
}
@@ -5196,7 +5229,7 @@ fn base_url_uses_local_host(base_url: &str) -> bool {
.is_ok_and(|addr| addr.is_loopback() || addr.is_unspecified())
}
fn base_url_host(base_url: &str) -> Option<&str> {
pub fn base_url_host(base_url: &str) -> Option<&str> {
let without_scheme = base_url
.split_once("://")
.map_or(base_url, |(_, rest)| rest);
+31 -12
View File
@@ -286,17 +286,19 @@ fn is_symlink(p: &Path) -> bool {
.map(|m| m.file_type().is_symlink())
.unwrap_or(false)
}
fn read_capped(p: &Path) -> Option<String> {
let meta = std::fs::metadata(p).ok()?;
pub fn read_project_instruction_file(file: std::fs::File) -> Option<String> {
let meta = file.metadata().ok()?;
if !meta.is_file() {
return None;
}
if meta.len() > INSTRUCTIONS_FILE_MAX_BYTES as u64 {
let mut file = std::fs::File::open(p).ok()?;
let mut buf = vec![0u8; INSTRUCTIONS_FILE_MAX_BYTES];
use std::io::Read as _;
let n = file.read(&mut buf).ok()?;
buf.truncate(n);
use std::io::Read as _;
let mut buf = Vec::new();
file.take(INSTRUCTIONS_FILE_MAX_BYTES as u64 + 1)
.read_to_end(&mut buf)
.ok()?;
let observed_len = buf.len() as u64;
if meta.len().max(observed_len) > INSTRUCTIONS_FILE_MAX_BYTES as u64 {
buf.truncate(INSTRUCTIONS_FILE_MAX_BYTES);
let mut text = String::from_utf8_lossy(&buf).into_owned();
let mut end = INSTRUCTIONS_FILE_MAX_BYTES.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
@@ -305,11 +307,12 @@ fn read_capped(p: &Path) -> Option<String> {
text.truncate(end);
let omitted = meta
.len()
.max(observed_len)
.saturating_sub(INSTRUCTIONS_FILE_MAX_BYTES as u64);
text.push_str(&format!("\n[…truncated: {omitted} bytes omitted]"));
return Some(text);
}
let raw = std::fs::read_to_string(p).ok()?;
let raw = String::from_utf8(buf).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
None
@@ -392,6 +395,7 @@ pub fn selected_project_instruction_candidate_files(
fn load_project_instruction_fragment_from_candidates(
workspace: &Path,
candidates: &[&str],
mut opener: impl FnMut(&Path) -> std::io::Result<std::fs::File>,
) -> Option<BoundedFragment> {
let files = collect_candidate_files(workspace, candidates);
if files.is_empty() {
@@ -399,7 +403,7 @@ fn load_project_instruction_fragment_from_candidates(
}
let mut sections = Vec::new();
for path in files {
if let Some(content) = read_capped(&path) {
if let Some(content) = opener(&path).ok().and_then(read_project_instruction_file) {
let rel = path
.strip_prefix(workspace)
.unwrap_or(&path)
@@ -420,7 +424,11 @@ fn load_project_instruction_fragment_from_candidates(
}
pub fn load_project_instruction_fragment(workspace: &Path) -> Option<BoundedFragment> {
load_project_instruction_fragment_from_candidates(workspace, PROJECT_INSTRUCTION_CANDIDATES)
load_project_instruction_fragment_from_candidates(
workspace,
PROJECT_INSTRUCTION_CANDIDATES,
|path| std::fs::File::open(path),
)
}
/// Load only instruction formats that the canonical TUI project-context path
@@ -434,6 +442,7 @@ pub fn load_additional_project_instruction_fragment(workspace: &Path) -> Option<
load_project_instruction_fragment_from_candidates(
workspace,
ADDITIONAL_PROJECT_INSTRUCTION_CANDIDATES,
|path| std::fs::File::open(path),
)
}
@@ -444,11 +453,21 @@ pub fn load_additional_project_instruction_fragment(workspace: &Path) -> Option<
pub fn load_selected_project_instruction_fragment(
workspace: &Path,
candidates: &[&str],
) -> Option<BoundedFragment> {
load_selected_project_instruction_fragment_with_reader(workspace, candidates, |path| {
std::fs::File::open(path)
})
}
pub fn load_selected_project_instruction_fragment_with_reader(
workspace: &Path,
candidates: &[&str],
opener: impl FnMut(&Path) -> std::io::Result<std::fs::File>,
) -> Option<BoundedFragment> {
if candidates.is_empty() {
return None;
}
load_project_instruction_fragment_from_candidates(workspace, candidates)
load_project_instruction_fragment_from_candidates(workspace, candidates, opener)
}
pub fn project_instructions_from_sources(
+20 -1
View File
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Política de sandbox d’aquesta execució; qualsevol --sandbox diferent de danger-full-access (o un sandbox_mode equivalent a la configuració) desactiva el mode de confiança.",
"ExecAfterHelp": "Exemples:\n codewhale exec \"explica aquesta funció\"\n codewhale exec --auto \"llista crates/ amb ls\"\n codewhale exec --auto --output-format stream-json \"corregeix la prova que falla\"\n\n`codewhale exec` sense opcions produeix una única resposta del model: un torn\ndel motor Engine amb el mateix prompt de sistema que les altres execucions i\nsense eines. Fes servir `--auto` per executar un agent amb eines sense interacció.\nNomés s’ofereixen eines amb `--auto`, `--yolo`, `--allowed-tools` o en reprendre\nuna sessió; límits com `--max-turns`, `--disallowed-tools` o `--sandbox`, i el\nformat de sortida, no afegeixen mai eines. Una resposta tallada pel límit de\nsortida del proveïdor continua en el mateix torn, amb un màxim de 8 passos\ndel model, llevat que `--max-turns` indiqui un altre valor.\n--auto fa servir Full Access per defecte: sense sandbox del shell i amb el mode de confiança activat. Fes servir --sandbox workspace-write per mantenir el sandbox del shell i les restriccions de les eines de fitxers a l’espai de treball; també s’apliquen les polítiques més restrictives configurades. Tornar a provar una operació denegada pel sandbox requereix --allow-sandbox-elevation o --sandbox danger-full-access.\nLes execucions que ofereixen eines, les execucions stream-json i les sessions represes es desen per a codewhale sessions, exec --continue i codewhale receipts <id>. Les execucions text/--json que només responen no es desen, llevat que la compactació necessiti dades de recuperació.\nQualsevol --sandbox diferent de danger-full-access (o un sandbox_mode equivalent a la configuració) desactiva el mode de confiança.\n",
"ExecPromptHelp": "Indicació que s’enviarà al model. Posa -- abans d’una indicació que comenci amb - o contingui una opció literal d’exec; fes servir --prompt-file - per llegir l’entrada estàndard.",
"ExecFlagAfterPrompt": "{flag} apareix després de la indicació i es tractaria com a text. Posa les opcions abans de la indicació o, per usar-les com a text literal, després de --."
"ExecFlagAfterPrompt": "{flag} apareix després de la indicació i es tractaria com a text. Posa les opcions abans de la indicació o, per usar-les com a text literal, després de --.",
"InstructionNoticeHeader": "S'han retallat o ometut instruccions permanents abans de començar aquesta sessió:",
"InstructionNoticeFooter": "Executeu /context report per veure el desglossament complet per font.",
"InstructionNoticeMore": "…i {count} més; la llista completa és a /context report.",
"InstructionNoticeLine": "instruccions: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " s'han conservat {kept_bytes} de {total_bytes} bytes i ~{kept_tokens} de ~{total_tokens} tokens estimats",
"InstructionNoticeDroppedBytes": " s'han descartat {bytes} bytes",
"InstructionNoticeFallback": "; alternativa: {source}",
"InstructionNoticeStaticGuardOver": "instruccions: el prefix estàtic encara supera en ~{tokens} tokens estimats el límit del prefix estàtic després de retallar les instruccions; es continua amb la política `trim` — augmenteu static_prefix_guard_percent o reduïu la superfície d'eines.",
"InstructionNoticeStaticGuardTrimmed": "instruccions: l'àmbit {scope} s'ha retallat ~{tokens} tokens estimats per mantenir el prefix estàtic sota el límit",
"InstructionDetailDroppedBroadest": "descartat com a segment de l'àmbit més ampli",
"InstructionDetailNoRoom": "el pressupost d'àmbit de {tokens} tokens estimats no ha deixat espai per al contingut",
"InstructionDetailRulesExhausted": "les instruccions han esgotat el pressupost d'àmbit del projecte de {tokens} tokens estimats",
"InstructionDetailOverCeiling": "supera el límit de {bytes} bytes",
"InstructionDetailUnreadable": "no s'ha pogut llegir: {reason}",
"InstructionDetailAlreadyLoaded": "ja carregat des d'un àmbit més ampli",
"InstructionDetailRulesCap": "supera el límit de {count} fitxers de regles per directori",
"InstructionDetailSymlinkedRules": "es rebutja el directori de regles perquè és un enllaç simbòlic",
"InstructionBudgetRefused": "el pressupost d'instruccions ha rebutjat aquesta sol·licitud: {reason}",
"InstructionBudgetOverGuard": "el prefix estàtic de ~{measured} tokens estimats supera el límit {guard}"
}
+20 -1
View File
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Sandbox-Richtlinie für diesen Lauf; jedes --sandbox außer danger-full-access (oder ein entsprechender sandbox_mode in der Konfiguration) deaktiviert den Vertrauensmodus.",
"ExecAfterHelp": "Beispiele:\n codewhale exec \"erkläre diese Funktion\"\n codewhale exec --auto \"liste crates/ mit ls auf\"\n codewhale exec --auto --output-format stream-json \"behebe den fehlschlagenden Test\"\n\nEin einfaches `codewhale exec` liefert eine einmalige Modellantwort: einen\nEngine-Turn mit demselben System-Prompt wie andere Läufe und ohne Tools.\nMit `--auto` wird ein Agent mit Tools ohne Interaktion ausgeführt. Tools\nwerden nur mit `--auto`, `--yolo`, `--allowed-tools` oder beim Fortsetzen einer\nSitzung angeboten; Grenzen wie `--max-turns`, `--disallowed-tools` oder\n`--sandbox` sowie das Ausgabeformat fügen niemals Tools hinzu. Eine durch das\nAusgabelimit des Anbieters abgeschnittene Antwort wird im selben Turn\nfortgesetzt, mit höchstens 8 Modellschritten, sofern `--max-turns` nichts\nanderes vorgibt.\n--auto verwendet standardmäßig Full Access: keine Shell-Sandbox und aktiver Vertrauensmodus. Mit --sandbox workspace-write bleiben die Shell-Sandbox und die Arbeitsbereichsbeschränkungen der Datei-Tools erhalten; strengere konfigurierte Richtlinien gelten ebenfalls. Ein erneuter Versuch nach einer Sandbox-Verweigerung erfordert --allow-sandbox-elevation oder --sandbox danger-full-access.\nLäufe mit angebotenen Tools, stream-json-Läufe und fortgesetzte Sitzungen werden für codewhale sessions, exec --continue und codewhale receipts <id> gespeichert. Reine Antwortläufe mit text/--json werden nicht gespeichert, es sei denn, die Komprimierung benötigt Wiederherstellungsdaten.\nJedes --sandbox außer danger-full-access (oder ein entsprechender sandbox_mode in der Konfiguration) deaktiviert den Vertrauensmodus.\n",
"ExecPromptHelp": "Prompt für das Modell. Setze -- vor einen Prompt, der mit - beginnt oder eine exec-Option als wörtlichen Text enthält; --prompt-file - liest die Standardeingabe.",
"ExecFlagAfterPrompt": "{flag} steht nach dem Prompt und würde als Text behandelt. Setze Optionen vor den Prompt oder wörtlichen Optionstext nach --."
"ExecFlagAfterPrompt": "{flag} steht nach dem Prompt und würde als Text behandelt. Setze Optionen vor den Prompt oder wörtlichen Optionstext nach --.",
"InstructionNoticeHeader": "Dauerhafte Anweisungen wurden vor Sitzungsbeginn gekürzt oder übersprungen:",
"InstructionNoticeFooter": "Führe /context report aus, um die vollständige Aufschlüsselung pro Quelle zu sehen.",
"InstructionNoticeMore": "…und {count} weitere; die vollständige Liste steht in /context report.",
"InstructionNoticeLine": "Anweisungen: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " {kept_bytes} von {total_bytes} Bytes und ~{kept_tokens} von ~{total_tokens} geschätzten Tokens behalten",
"InstructionNoticeDroppedBytes": " {bytes} Bytes verworfen",
"InstructionNoticeFallback": "; Ersatz: {source}",
"InstructionNoticeStaticGuardOver": "Anweisungen: Das statische Präfix liegt nach dem Kürzen der Anweisungen noch ~{tokens} geschätzte Tokens über der Grenze für das statische Präfix; es geht mit der Richtlinie `trim` weiter — erhöhe static_prefix_guard_percent oder verkleinere die Werkzeugoberfläche.",
"InstructionNoticeStaticGuardTrimmed": "Anweisungen: Bereich {scope} um ~{tokens} geschätzte Tokens gekürzt, damit das statische Präfix unter der Grenze bleibt",
"InstructionDetailDroppedBroadest": "als Segment des breitesten Bereichs verworfen",
"InstructionDetailNoRoom": "das Bereichsbudget von {tokens} geschätzten Tokens ließ keinen Platz für Inhalt",
"InstructionDetailRulesExhausted": "das Projektbudget von {tokens} geschätzten Tokens ist durch Anweisungen aufgebraucht",
"InstructionDetailOverCeiling": "größer als die Obergrenze von {bytes} Bytes",
"InstructionDetailUnreadable": "konnte nicht gelesen werden: {reason}",
"InstructionDetailAlreadyLoaded": "bereits aus einem breiteren Bereich geladen",
"InstructionDetailRulesCap": "über der Grenze von {count} Regeldateien pro Verzeichnis",
"InstructionDetailSymlinkedRules": "Regelverzeichnis als Symlink abgelehnt",
"InstructionBudgetRefused": "Das Anweisungsbudget hat diese Anfrage abgelehnt: {reason}",
"InstructionBudgetOverGuard": "statisches Präfix von ~{measured} geschätzten Tokens überschreitet die Grenze {guard}"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "a project, profile, environment, managed config, or organization requirement controls this value",
"ConfigHintManagedAllowShell": "a project, profile, environment, or managed config controls shell access",
"ConfigHintAllowShell": "on exposes shell tools in Work mode; permission rules still apply",
"ConfigHintComposerMultilineMode": "off: Enter sends, Shift+Enter adds a line; on: Enter adds a line, Shift+Enter sends",
"ConfigHintComposerMultilineMode": "off: Enter sends, Alt+Enter / Shift+Enter adds a line; on: Enter adds a line, Alt+Enter / Shift+Enter sends",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off; full change stays in Alt/Option+V",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Sandbox policy for this run; any --sandbox other than danger-full-access (or an equivalent sandbox_mode in config) disables trust mode.",
"ExecAfterHelp": "Examples:\n codewhale exec \"explain this function\"\n codewhale exec --auto \"list crates/ with ls\"\n codewhale exec --auto --output-format stream-json \"fix the failing test\"\n\nPlain `codewhale exec` is a one-shot model response: one Engine turn with the\nsame system prompt as every other run, and no tools. Use `--auto` for\nnon-interactive agent-with-tools execution. Tools are offered only with\n`--auto`, `--yolo`, `--allowed-tools`, or when resuming a session; limits such\nas `--max-turns`, `--disallowed-tools` or `--sandbox`, and the output format,\nnever add tools. A reply cut off at the provider's output limit is continued\nin the same turn, at most 8 model steps unless `--max-turns` says otherwise.\n--auto defaults to Full Access: no shell sandbox and trust mode on. Use --sandbox workspace-write to keep the shell sandbox and file-tool workspace restrictions; stricter configured policies also apply. A denied sandbox retry requires --allow-sandbox-elevation or --sandbox danger-full-access.\nRuns that offer tools, stream-json runs, and resumed sessions are saved for codewhale sessions, exec --continue, and codewhale receipts <id>. Plain answer-only text/--json runs are not saved unless compaction needs recovery.\nAny --sandbox other than danger-full-access (or an equivalent sandbox_mode in config) disables trust mode.\n",
"ExecPromptHelp": "Prompt to send to the model. Put -- before a prompt starting with - or containing a literal exec flag; use --prompt-file - to read stdin.",
"ExecFlagAfterPrompt": "{flag} appears after the prompt and would be treated as text. Put flags before the prompt, or put literal flag text after --."
"ExecFlagAfterPrompt": "{flag} appears after the prompt and would be treated as text. Put flags before the prompt, or put literal flag text after --.",
"InstructionNoticeHeader": "Standing instructions were trimmed or skipped before this session started:",
"InstructionNoticeFooter": "Run /context report for the full per-source breakdown.",
"InstructionNoticeMore": "…and {count} more; the full list is in /context report.",
"InstructionNoticeLine": "instructions: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " kept {kept_bytes} of {total_bytes} bytes and ~{kept_tokens} of ~{total_tokens} estimated tokens",
"InstructionNoticeDroppedBytes": " dropped {bytes} bytes",
"InstructionNoticeFallback": "; fallback: {source}",
"InstructionNoticeStaticGuardOver": "instructions: the static prefix is still ~{tokens} estimated tokens over the static-prefix guard after instruction trimming; continuing under policy `trim` — raise static_prefix_guard_percent or narrow the tool surface.",
"InstructionNoticeStaticGuardTrimmed": "instructions: {scope} scope trimmed by ~{tokens} estimated tokens to keep the static prefix under the guard",
"InstructionDetailDroppedBroadest": "dropped as a broadest-scope segment",
"InstructionDetailNoRoom": "scope budget of {tokens} estimated tokens left no room for content",
"InstructionDetailRulesExhausted": "project scope budget of {tokens} estimated tokens exhausted by instructions",
"InstructionDetailOverCeiling": "larger than the {bytes} byte ceiling",
"InstructionDetailUnreadable": "could not be read: {reason}",
"InstructionDetailAlreadyLoaded": "already loaded from a wider scope",
"InstructionDetailRulesCap": "beyond the per-directory rules cap of {count} files",
"InstructionDetailSymlinkedRules": "refusing symlinked rules directory",
"InstructionBudgetRefused": "instruction budget refused this request: {reason}",
"InstructionBudgetOverGuard": "static prefix ~{measured} estimated tokens exceeds guard {guard}"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "un proyecto, perfil, entorno, config administrado o requisito de la organización controla este valor",
"ConfigHintManagedAllowShell": "un proyecto, perfil, entorno o config administrado controla el acceso al shell",
"ConfigHintAllowShell": "activado expone herramientas de shell en modo Agente; las reglas de permiso siguen aplicando",
"ConfigHintComposerMultilineMode": "desactivado: Enter envía, Shift+Enter agrega línea; activado: Enter agrega línea, Shift+Enter envía",
"ConfigHintComposerMultilineMode": "desactivado: Enter envía, Alt+Enter / Shift+Enter agrega línea; activado: Enter agrega línea, Alt+Enter / Shift+Enter envía",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off; el cambio exacto permanece en los detalles de Alt/Option+V",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Política de sandbox para esta ejecución; cualquier --sandbox distinto de danger-full-access (o un sandbox_mode equivalente en la configuración) desactiva el modo de confianza.",
"ExecAfterHelp": "Ejemplos:\n codewhale exec \"explica esta función\"\n codewhale exec --auto \"lista crates/ con ls\"\n codewhale exec --auto --output-format stream-json \"corrige la prueba que falla\"\n\n`codewhale exec` sin opciones genera una única respuesta del modelo: un turno\ndel Engine con el mismo prompt del sistema que las demás ejecuciones y sin\nherramientas. Usa `--auto` para ejecutar un agente con herramientas sin interacción.\nSolo se ofrecen herramientas con `--auto`, `--yolo`, `--allowed-tools` o al\nreanudar una sesión; límites como `--max-turns`, `--disallowed-tools` o\n`--sandbox`, y el formato de salida, nunca agregan herramientas. Una respuesta\ncortada por el límite de salida del proveedor continúa en el mismo turno,\ncon un máximo de 8 pasos del modelo salvo que `--max-turns` indique otro valor.\n--auto usa Full Access de forma predeterminada: sin sandbox del shell y con el modo de confianza activado. Usa --sandbox workspace-write para mantener el sandbox del shell y las restricciones de las herramientas de archivos al espacio de trabajo; también se aplican las políticas más restrictivas configuradas. Reintentar una operación denegada por el sandbox requiere --allow-sandbox-elevation o --sandbox danger-full-access.\nLas ejecuciones que ofrecen herramientas, las ejecuciones stream-json y las sesiones reanudadas se guardan para codewhale sessions, exec --continue y codewhale receipts <id>. Las ejecuciones text/--json que solo responden no se guardan, a menos que la compactación necesite datos de recuperación.\nCualquier --sandbox distinto de danger-full-access (o un sandbox_mode equivalente en la configuración) desactiva el modo de confianza.\n",
"ExecPromptHelp": "Prompt que se enviará al modelo. Coloca -- antes de un prompt que empiece con - o contenga una opción literal de exec; usa --prompt-file - para leer la entrada estándar.",
"ExecFlagAfterPrompt": "{flag} aparece después del prompt y se trataría como texto. Coloca las opciones antes del prompt o, para usarlas como texto literal, después de --."
"ExecFlagAfterPrompt": "{flag} aparece después del prompt y se trataría como texto. Coloca las opciones antes del prompt o, para usarlas como texto literal, después de --.",
"InstructionNoticeHeader": "Se recortaron u omitieron instrucciones permanentes antes de que empezara esta sesión:",
"InstructionNoticeFooter": "Ejecuta /context report para ver el desglose completo por fuente.",
"InstructionNoticeMore": "…y {count} más; la lista completa está en /context report.",
"InstructionNoticeLine": "instrucciones: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " se conservaron {kept_bytes} de {total_bytes} bytes y ~{kept_tokens} de ~{total_tokens} tokens estimados",
"InstructionNoticeDroppedBytes": " se descartaron {bytes} bytes",
"InstructionNoticeFallback": "; alternativa: {source}",
"InstructionNoticeStaticGuardOver": "instrucciones: el prefijo estático sigue ~{tokens} tokens estimados por encima del límite del prefijo estático tras recortar las instrucciones; se continúa con la política `trim` — aumenta static_prefix_guard_percent o reduce la superficie de herramientas.",
"InstructionNoticeStaticGuardTrimmed": "instrucciones: el ámbito {scope} se recortó ~{tokens} tokens estimados para mantener el prefijo estático bajo el límite",
"InstructionDetailDroppedBroadest": "descartado como segmento del ámbito más amplio",
"InstructionDetailNoRoom": "el presupuesto del ámbito de {tokens} tokens estimados no dejó espacio para contenido",
"InstructionDetailRulesExhausted": "las instrucciones agotaron el presupuesto del ámbito del proyecto de {tokens} tokens estimados",
"InstructionDetailOverCeiling": "supera el límite de {bytes} bytes",
"InstructionDetailUnreadable": "no se pudo leer: {reason}",
"InstructionDetailAlreadyLoaded": "ya se cargó desde un ámbito más amplio",
"InstructionDetailRulesCap": "excede el tope de {count} archivos de reglas por directorio",
"InstructionDetailSymlinkedRules": "se rechaza el directorio de reglas por ser un enlace simbólico",
"InstructionBudgetRefused": "el presupuesto de instrucciones rechazó esta solicitud: {reason}",
"InstructionBudgetOverGuard": "el prefijo estático de ~{measured} tokens estimados supera el límite {guard}"
}
+20 -1
View File
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Politique de sandbox pour cette exécution ; tout --sandbox autre que danger-full-access (ou un sandbox_mode équivalent dans la configuration) désactive le mode de confiance.",
"ExecAfterHelp": "Exemples :\n codewhale exec \"explique cette fonction\"\n codewhale exec --auto \"liste crates/ avec ls\"\n codewhale exec --auto --output-format stream-json \"corrige le test en échec\"\n\n`codewhale exec` seul produit une réponse unique du modèle : un tour Engine\navec la même instruction système que les autres exécutions, sans outils.\nUtilisez `--auto` pour exécuter un agent avec outils sans interaction.\nLes outils sont proposés uniquement avec `--auto`, `--yolo`, `--allowed-tools`\nou lors de la reprise d’une session ; les limites comme `--max-turns`,\n`--disallowed-tools` ou `--sandbox`, ainsi que le format de sortie, n’ajoutent\njamais d’outils. Une réponse interrompue par la limite de sortie du fournisseur\nse poursuit dans le même tour, avec au plus 8 étapes du modèle, sauf indication\ncontraire de `--max-turns`.\n--auto utilise Full Access par défaut : aucun bac à sable du shell et mode de confiance activé. Utilisez --sandbox workspace-write pour conserver le bac à sable du shell et les restrictions des outils de fichiers à l’espace de travail ; les politiques configurées plus strictes s’appliquent aussi. Réessayer une opération refusée par le bac à sable nécessite --allow-sandbox-elevation ou --sandbox danger-full-access.\nLes exécutions proposant des outils, les exécutions stream-json et les sessions reprises sont enregistrées pour codewhale sessions, exec --continue et codewhale receipts <id>. Les exécutions text/--json qui ne font que répondre ne sont pas enregistrées, sauf si la compaction nécessite des données de récupération.\nTout --sandbox autre que danger-full-access (ou un sandbox_mode équivalent dans la configuration) désactive le mode de confiance.\n",
"ExecPromptHelp": "Prompt à envoyer au modèle. Placez -- avant un prompt qui commence par - ou contient une option exec comme texte littéral ; utilisez --prompt-file - pour lire l’entrée standard.",
"ExecFlagAfterPrompt": "{flag} apparaît après le prompt et serait traité comme du texte. Placez les options avant le prompt, ou le texte littéral d’une option après --."
"ExecFlagAfterPrompt": "{flag} apparaît après le prompt et serait traité comme du texte. Placez les options avant le prompt, ou le texte littéral d’une option après --.",
"InstructionNoticeHeader": "Des instructions permanentes ont été tronquées ou ignorées avant le début de cette session :",
"InstructionNoticeFooter": "Exécutez /context report pour le détail complet par source.",
"InstructionNoticeMore": "…et {count} de plus ; la liste complète figure dans /context report.",
"InstructionNoticeLine": "instructions : {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " {kept_bytes} sur {total_bytes} octets et ~{kept_tokens} sur ~{total_tokens} tokens estimés conservés",
"InstructionNoticeDroppedBytes": " {bytes} octets supprimés",
"InstructionNoticeFallback": " ; repli : {source}",
"InstructionNoticeStaticGuardOver": "instructions : le préfixe statique dépasse encore de ~{tokens} tokens estimés la limite du préfixe statique après réduction des instructions ; poursuite sous la politique `trim` — augmentez static_prefix_guard_percent ou réduisez la surface d'outils.",
"InstructionNoticeStaticGuardTrimmed": "instructions : portée {scope} réduite de ~{tokens} tokens estimés pour garder le préfixe statique sous la limite",
"InstructionDetailDroppedBroadest": "supprimé comme segment de la portée la plus large",
"InstructionDetailNoRoom": "le budget de portée de {tokens} tokens estimés n'a laissé aucune place au contenu",
"InstructionDetailRulesExhausted": "le budget de portée du projet de {tokens} tokens estimés est épuisé par les instructions",
"InstructionDetailOverCeiling": "plus grand que le plafond de {bytes} octets",
"InstructionDetailUnreadable": "lecture impossible : {reason}",
"InstructionDetailAlreadyLoaded": "déjà chargé depuis une portée plus large",
"InstructionDetailRulesCap": "au-delà du plafond de {count} fichiers de règles par répertoire",
"InstructionDetailSymlinkedRules": "refus : le répertoire de règles est un lien symbolique",
"InstructionBudgetRefused": "le budget d'instructions a refusé cette requête : {reason}",
"InstructionBudgetOverGuard": "le préfixe statique de ~{measured} tokens estimés dépasse la limite {guard}"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "कोई प्रोजेक्ट, प्रोफ़ाइल, परिवेश, प्रबंधित config या संगठन की आवश्यकता इस मान को नियंत्रित करती है",
"ConfigHintManagedAllowShell": "कोई प्रोजेक्ट, प्रोफ़ाइल, परिवेश या प्रबंधित config shell पहुँच को नियंत्रित करता है",
"ConfigHintAllowShell": "चालू होने पर एजेंट मोड में shell टूल उपलब्ध होते हैं; अनुमति नियम फिर भी लागू रहते हैं",
"ConfigHintComposerMultilineMode": "बंद: Enter भेजता है, Shift+Enter पंक्ति जोड़ता है; चालू: Enter पंक्ति जोड़ता है, Shift+Enter भेजता है",
"ConfigHintComposerMultilineMode": "बंद: Enter भेजता है, Alt+Enter / Shift+Enter पंक्ति जोड़ता है; चालू: Enter पंक्ति जोड़ता है, Alt+Enter / Shift+Enter भेजता है",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off; सटीक बदलाव Alt/Option+V विवरण में बना रहता है",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "इस रन की sandbox नीति; danger-full-access के अलावा कोई भी --sandbox (या कॉन्फ़िगरेशन में समान sandbox_mode) ट्रस्ट मोड बंद करता है।",
"ExecAfterHelp": "उदाहरण:\n codewhale exec \"इस फ़ंक्शन को समझाएँ\"\n codewhale exec --auto \"ls से crates/ की सूची दिखाएँ\"\n codewhale exec --auto --output-format stream-json \"विफल हो रहा टेस्ट ठीक करें\"\n\nसामान्य `codewhale exec` मॉडल से एक बार जवाब लेता है: अन्य रन वाले ही सिस्टम\nप्रॉम्प्ट के साथ Engine का एक टर्न, बिना टूल के। बिना इंटरैक्शन के टूल वाले\nएजेंट को चलाने के लिए `--auto` इस्तेमाल करें। टूल केवल `--auto`, `--yolo`,\n`--allowed-tools` के साथ या सत्र फिर शुरू करने पर उपलब्ध होते हैं;\n`--max-turns`, `--disallowed-tools` या `--sandbox` जैसी सीमाएँ और आउटपुट\nफ़ॉर्मैट कभी टूल नहीं जोड़ते। प्रदाता की आउटपुट सीमा से कटे जवाब को उसी टर्न\nमें आगे जारी रखा जाता है। `--max-turns` से अन्य सीमा न देने पर मॉडल के\nअधिकतम 8 चरण होते हैं।\n--auto डिफ़ॉल्ट रूप से Full Access इस्तेमाल करता है: शेल सैंडबॉक्स नहीं और भरोसा मोड चालू। शेल सैंडबॉक्स और फ़ाइल टूल की कार्यक्षेत्र संबंधी पाबंदियाँ बनाए रखने के लिए --sandbox workspace-write इस्तेमाल करें; कॉन्फ़िगर की गई अधिक सख्त नीतियाँ भी लागू होती हैं। सैंडबॉक्स द्वारा रोकी गई कार्रवाई दोबारा आज़माने के लिए --allow-sandbox-elevation या --sandbox danger-full-access ज़रूरी है।\nटूल उपलब्ध कराने वाले रन, stream-json रन और फिर शुरू किए गए सत्र सहेजे जाते हैं, ताकि codewhale sessions, exec --continue और codewhale receipts <id> में उनका उपयोग हो सके। केवल जवाब देने वाले सामान्य text/--json रन नहीं सहेजे जाते, जब तक संदर्भ संपीड़न के लिए पुनर्प्राप्ति डेटा की ज़रूरत न हो।\ndanger-full-access के अलावा कोई भी --sandbox (या कॉन्फ़िगरेशन में समान sandbox_mode) ट्रस्ट मोड बंद करता है।\n",
"ExecPromptHelp": "मॉडल को भेजने वाला प्रॉम्प्ट। - से शुरू होने वाले या exec फ़्लैग को सामान्य टेक्स्ट के रूप में रखने वाले प्रॉम्प्ट से पहले -- लगाएँ; मानक इनपुट पढ़ने के लिए --prompt-file - का उपयोग करें।",
"ExecFlagAfterPrompt": "{flag} प्रॉम्प्ट के बाद है और टेक्स्ट माना जाएगा। फ़्लैग प्रॉम्प्ट से पहले रखें, या फ़्लैग को सामान्य टेक्स्ट के रूप में भेजने के लिए उसे -- के बाद रखें।"
"ExecFlagAfterPrompt": "{flag} प्रॉम्प्ट के बाद है और टेक्स्ट माना जाएगा। फ़्लैग प्रॉम्प्ट से पहले रखें, या फ़्लैग को सामान्य टेक्स्ट के रूप में भेजने के लिए उसे -- के बाद रखें।",
"InstructionNoticeHeader": "स्थायी निर्देश इस सत्र के शुरू होने से पहले छाँट दिए गए या छोड़ दिए गए:",
"InstructionNoticeFooter": "स्रोत-वार पूरा ब्योरा देखने के लिए /context report चलाएँ।",
"InstructionNoticeMore": "…और {count} अन्य; पूरी सूची /context report में है।",
"InstructionNoticeLine": "निर्देश: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " {total_bytes} में से {kept_bytes} बाइट और ~{total_tokens} में से ~{kept_tokens} अनुमानित टोकन रखे गए",
"InstructionNoticeDroppedBytes": " {bytes} बाइट हटाए गए",
"InstructionNoticeFallback": "; विकल्प: {source}",
"InstructionNoticeStaticGuardOver": "निर्देश: निर्देश छाँटने के बाद भी स्थिर प्रीफ़िक्स, स्थिर प्रीफ़िक्स सीमा से ~{tokens} अनुमानित टोकन ऊपर है; नीति `trim` के साथ जारी है — static_prefix_guard_percent बढ़ाएँ या टूल का दायरा घटाएँ।",
"InstructionNoticeStaticGuardTrimmed": "निर्देश: स्थिर प्रीफ़िक्स को सीमा के भीतर रखने के लिए {scope} दायरा ~{tokens} अनुमानित टोकन छाँटा गया",
"InstructionDetailDroppedBroadest": "सबसे व्यापक दायरे के खंड के रूप में हटाया गया",
"InstructionDetailNoRoom": "{tokens} अनुमानित टोकन के दायरा बजट में सामग्री के लिए जगह नहीं बची",
"InstructionDetailRulesExhausted": "{tokens} अनुमानित टोकन का प्रोजेक्ट दायरा बजट निर्देशों से समाप्त हो गया",
"InstructionDetailOverCeiling": "{bytes} बाइट की सीमा से बड़ा",
"InstructionDetailUnreadable": "पढ़ा नहीं जा सका: {reason}",
"InstructionDetailAlreadyLoaded": "अधिक व्यापक दायरे से पहले ही लोड हो चुका है",
"InstructionDetailRulesCap": "प्रति निर्देशिका {count} नियम फ़ाइलों की सीमा से अधिक",
"InstructionDetailSymlinkedRules": "सिमलिंक वाली नियम निर्देशिका अस्वीकार की गई",
"InstructionBudgetRefused": "निर्देश बजट ने इस अनुरोध को अस्वीकार कर दिया: {reason}",
"InstructionBudgetOverGuard": "स्थिर प्रीफ़िक्स ~{measured} अनुमानित टोकन सीमा {guard} से अधिक है"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "proyek, profil, lingkungan, config terkelola, atau persyaratan organisasi mengendalikan nilai ini",
"ConfigHintManagedAllowShell": "proyek, profil, lingkungan, atau config terkelola mengendalikan akses shell",
"ConfigHintAllowShell": "aktif memunculkan alat shell di mode Agen; aturan izin tetap berlaku",
"ConfigHintComposerMultilineMode": "nonaktif: Enter mengirim, Shift+Enter menambah baris; aktif: Enter menambah baris, Shift+Enter mengirim",
"ConfigHintComposerMultilineMode": "nonaktif: Enter mengirim, Alt+Enter / Shift+Enter menambah baris; aktif: Enter menambah baris, Alt+Enter / Shift+Enter mengirim",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off; perubahan persis tetap ada di detail Alt/Option+V",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Kebijakan sandbox untuk proses ini; setiap --sandbox selain danger-full-access (atau sandbox_mode yang setara dalam konfigurasi) menonaktifkan mode kepercayaan.",
"ExecAfterHelp": "Contoh:\n codewhale exec \"jelaskan fungsi ini\"\n codewhale exec --auto \"tampilkan isi crates/ dengan ls\"\n codewhale exec --auto --output-format stream-json \"perbaiki pengujian yang gagal\"\n\n`codewhale exec` biasa menghasilkan satu respons model: satu giliran Engine\ndengan prompt sistem yang sama seperti eksekusi lain, tanpa alat. Gunakan\n`--auto` untuk menjalankan agen dengan alat secara noninteraktif. Alat hanya\ndisediakan dengan `--auto`, `--yolo`, `--allowed-tools`, atau saat melanjutkan\nsesi; batas seperti `--max-turns`, `--disallowed-tools`, atau `--sandbox`, dan\nformat keluaran, tidak pernah menambahkan alat. Respons yang terpotong karena\nbatas keluaran penyedia dilanjutkan dalam giliran yang sama, hingga 8 langkah\nmodel kecuali `--max-turns` menentukan batas lain.\n--auto menggunakan Full Access secara bawaan: tanpa sandbox shell dan dengan mode kepercayaan aktif. Gunakan --sandbox workspace-write untuk mempertahankan sandbox shell dan pembatasan alat berkas pada ruang kerja; kebijakan lebih ketat yang dikonfigurasi juga berlaku. Mencoba ulang operasi yang ditolak sandbox memerlukan --allow-sandbox-elevation atau --sandbox danger-full-access.\nEksekusi yang menyediakan alat, eksekusi stream-json, dan sesi yang dilanjutkan disimpan untuk codewhale sessions, exec --continue, dan codewhale receipts <id>. Eksekusi text/--json biasa yang hanya memberikan jawaban tidak disimpan, kecuali pemadatan memerlukan data pemulihan.\nSetiap --sandbox selain danger-full-access (atau sandbox_mode yang setara dalam konfigurasi) menonaktifkan mode kepercayaan.\n",
"ExecPromptHelp": "Prompt untuk dikirim ke model. Letakkan -- sebelum prompt yang diawali - atau memuat flag exec sebagai teks literal; gunakan --prompt-file - untuk membaca input standar.",
"ExecFlagAfterPrompt": "{flag} muncul setelah prompt dan akan dianggap sebagai teks. Letakkan flag sebelum prompt, atau letakkan flag yang dimaksudkan sebagai teks literal setelah --."
"ExecFlagAfterPrompt": "{flag} muncul setelah prompt dan akan dianggap sebagai teks. Letakkan flag sebelum prompt, atau letakkan flag yang dimaksudkan sebagai teks literal setelah --.",
"InstructionNoticeHeader": "Instruksi tetap dipangkas atau dilewati sebelum sesi ini dimulai:",
"InstructionNoticeFooter": "Jalankan /context report untuk rincian lengkap per sumber.",
"InstructionNoticeMore": "…dan {count} lagi; daftar lengkap ada di /context report.",
"InstructionNoticeLine": "instruksi: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " dipertahankan {kept_bytes} dari {total_bytes} byte dan ~{kept_tokens} dari ~{total_tokens} token perkiraan",
"InstructionNoticeDroppedBytes": " {bytes} byte dibuang",
"InstructionNoticeFallback": "; cadangan: {source}",
"InstructionNoticeStaticGuardOver": "instruksi: prefiks statis masih ~{tokens} token perkiraan di atas batas prefiks statis setelah instruksi dipangkas; dilanjutkan dengan kebijakan `trim` — naikkan static_prefix_guard_percent atau persempit cakupan alat.",
"InstructionNoticeStaticGuardTrimmed": "instruksi: cakupan {scope} dipangkas ~{tokens} token perkiraan agar prefiks statis tetap di bawah batas",
"InstructionDetailDroppedBroadest": "dibuang sebagai segmen cakupan terluas",
"InstructionDetailNoRoom": "anggaran cakupan sebesar {tokens} token perkiraan tidak menyisakan ruang untuk konten",
"InstructionDetailRulesExhausted": "anggaran cakupan proyek sebesar {tokens} token perkiraan habis oleh instruksi",
"InstructionDetailOverCeiling": "melebihi batas {bytes} byte",
"InstructionDetailUnreadable": "tidak dapat dibaca: {reason}",
"InstructionDetailAlreadyLoaded": "sudah dimuat dari cakupan yang lebih luas",
"InstructionDetailRulesCap": "melebihi batas {count} berkas aturan per direktori",
"InstructionDetailSymlinkedRules": "menolak direktori aturan berupa symlink",
"InstructionBudgetRefused": "anggaran instruksi menolak permintaan ini: {reason}",
"InstructionBudgetOverGuard": "prefiks statis ~{measured} token perkiraan melebihi batas {guard}"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "プロジェクト・プロファイル・環境・管理された設定・組織の要件がこの値を制御します",
"ConfigHintManagedAllowShell": "プロジェクト・プロファイル・環境・管理された設定がシェルアクセスを制御します",
"ConfigHintAllowShell": "オンにするとエージェントモードでシェルツールを公開します。権限ルールは引き続き適用されます",
"ConfigHintComposerMultilineMode": "オフ: Enter で送信、Shift+Enter で改行。オン: Enter で改行、Shift+Enter で送信",
"ConfigHintComposerMultilineMode": "オフ: Enter で送信、Alt+Enter / Shift+Enter で改行。オン: Enter で改行、Alt+Enter / Shift+Enter で送信",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off。正確な変更は Alt/Option+V の詳細に残ります",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "この実行のサンドボックスポリシー。danger-full-access 以外の --sandbox(または設定内の同等の sandbox_mode)は信頼モードを無効にします。",
"ExecAfterHelp": "使用例:\n codewhale exec \"この関数を説明して\"\n codewhale exec --auto \"ls で crates/ の内容を表示して\"\n codewhale exec --auto --output-format stream-json \"失敗しているテストを修正して\"\n\n通常の `codewhale exec` はモデルの単発応答です。他の実行と同じシステム\nプロンプトを使い、ツールなしで Engine を 1 ターン実行します。対話なしで\nツールを使うエージェントを実行するには `--auto` を指定してください。\nツールが提供されるのは `--auto`、`--yolo`、`--allowed-tools` の指定時、\nまたはセッションの再開時のみです。`--max-turns`、`--disallowed-tools`、\n`--sandbox` などの制限や出力形式を指定してもツールは追加されません。\nプロバイダーの出力上限で途切れた応答は同じターン内で続行されます。\n`--max-turns` で変更しない限り、モデルのステップ数は最大 8 回です。\n--auto は既定で Full Access です。シェルのサンドボックスはなく、信頼モードが有効です。シェルのサンドボックスと、ファイルツールのワークスペース制限を維持するには --sandbox workspace-write を指定してください。設定済みのより厳しいポリシーも適用されます。サンドボックスで拒否された操作の再試行には --allow-sandbox-elevation または --sandbox danger-full-access が必要です。\nツールを提供する実行、stream-json の実行、再開したセッションは保存され、codewhale sessions、exec --continue、codewhale receipts <id> で利用できます。応答のみの text/--json 実行は、圧縮時に復旧用の保存が必要な場合を除いて保存されません。\ndanger-full-access 以外の --sandbox(または設定内の同等の sandbox_mode)は信頼モードを無効にします。\n",
"ExecPromptHelp": "モデルに送信するプロンプト。- で始まる場合や exec のフラグを文字列として含む場合は、プロンプトの前に -- を置いてください。標準入力を読むには --prompt-file - を使用してください。",
"ExecFlagAfterPrompt": "{flag} がプロンプトの後にあり、文字列として扱われます。フラグはプロンプトの前に置くか、文字列として渡す場合は -- の後に置いてください。"
"ExecFlagAfterPrompt": "{flag} がプロンプトの後にあり、文字列として扱われます。フラグはプロンプトの前に置くか、文字列として渡す場合は -- の後に置いてください。",
"InstructionNoticeHeader": "常設の指示は、このセッションの開始前に切り詰められたか、スキップされました:",
"InstructionNoticeFooter": "ソースごとの詳細は /context report を実行してください。",
"InstructionNoticeMore": "…ほか {count} 件。全一覧は /context report にあります。",
"InstructionNoticeLine": "指示: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " {total_bytes} バイト中 {kept_bytes} バイト、推定 ~{total_tokens} トークン中 ~{kept_tokens} トークンを保持",
"InstructionNoticeDroppedBytes": " {bytes} バイトを破棄",
"InstructionNoticeFallback": "; 代替: {source}",
"InstructionNoticeStaticGuardOver": "指示: 指示を切り詰めても、静的プレフィックスはなお推定 ~{tokens} トークン、静的プレフィックスの上限を超えています。ポリシー `trim` のまま続行します — static_prefix_guard_percent を上げるか、ツールの範囲を絞ってください。",
"InstructionNoticeStaticGuardTrimmed": "指示: 静的プレフィックスを上限内に収めるため、{scope} スコープを推定 ~{tokens} トークン切り詰めました",
"InstructionDetailDroppedBroadest": "最も広いスコープのセグメントとして破棄",
"InstructionDetailNoRoom": "推定 {tokens} トークンのスコープ予算では内容を置く余地がありませんでした",
"InstructionDetailRulesExhausted": "推定 {tokens} トークンのプロジェクトスコープ予算を指示が使い切りました",
"InstructionDetailOverCeiling": "上限 {bytes} バイトを超えています",
"InstructionDetailUnreadable": "読み取れませんでした: {reason}",
"InstructionDetailAlreadyLoaded": "より広いスコープから読み込み済み",
"InstructionDetailRulesCap": "ディレクトリごとのルール上限 {count} ファイルを超えています",
"InstructionDetailSymlinkedRules": "シンボリックリンクのルールディレクトリは拒否します",
"InstructionBudgetRefused": "指示の予算により、このリクエストは拒否されました: {reason}",
"InstructionBudgetOverGuard": "静的プレフィックス ~{measured} 推定トークンが上限 {guard} を超えています"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "프로젝트, 프로필, 환경, 관리 설정 또는 조직 요구 사항이 이 값을 제어",
"ConfigHintManagedAllowShell": "프로젝트, 프로필, 환경 또는 관리 설정이 shell 접근을 제어",
"ConfigHintAllowShell": "켜면 에이전트 모드에서 shell 도구를 노출. 권한 규칙은 계속 적용",
"ConfigHintComposerMultilineMode": "끔: Enter로 전송, Shift+Enter로 줄 추가. 켬: Enter로 줄 추가, Shift+Enter로 전송",
"ConfigHintComposerMultilineMode": "끔: Enter로 전송, Alt+Enter / Shift+Enter로 줄 추가. 켬: Enter로 줄 추가, Alt+Enter / Shift+Enter로 전송",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off. 정확한 변경은 Alt/Option+V 세부 정보에 남음",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "이번 실행의 샌드박스 정책입니다. danger-full-access 이외의 --sandbox(또는 설정의 동등한 sandbox_mode)는 신뢰 모드를 끕니다.",
"ExecAfterHelp": "예시:\n codewhale exec \"이 함수를 설명해 줘\"\n codewhale exec --auto \"ls로 crates/ 목록을 보여 줘\"\n codewhale exec --auto --output-format stream-json \"실패한 테스트를 수정해 줘\"\n\n일반 `codewhale exec`는 모델의 일회성 응답입니다. 다른 실행과 같은 시스템\n프롬프트로 도구 없이 Engine 턴을 한 번 실행합니다. 대화형 입력 없이\n도구를 쓰는 에이전트를 실행하려면 `--auto`를 사용하세요. 도구는 `--auto`,\n`--yolo`, `--allowed-tools`를 지정하거나 세션을 재개할 때만 제공됩니다.\n`--max-turns`, `--disallowed-tools`, `--sandbox` 같은 제한이나 출력 형식은\n도구를 추가하지 않습니다. 공급자의 출력 한도로 잘린 응답은 같은 턴에서\n이어지며, `--max-turns`로 달리 지정하지 않으면 모델 단계는 최대 8회입니다.\n--auto의 기본값은 Full Access입니다. 셸 샌드박스가 없고 신뢰 모드가 켜집니다. 셸 샌드박스와 파일 도구의 워크스페이스 제한을 유지하려면 --sandbox workspace-write를 사용하세요. 설정된 더 엄격한 정책도 적용됩니다. 샌드박스에서 거부된 작업을 재시도하려면 --allow-sandbox-elevation 또는 --sandbox danger-full-access가 필요합니다.\n도구를 제공하는 실행, stream-json 실행, 재개한 세션은 저장되며 codewhale sessions, exec --continue, codewhale receipts <id>에서 사용할 수 있습니다. 응답만 제공하는 일반 text/--json 실행은 압축 시 복구 데이터가 필요한 경우를 제외하고 저장되지 않습니다.\ndanger-full-access 이외의 --sandbox(또는 설정의 동등한 sandbox_mode)는 신뢰 모드를 끕니다.\n",
"ExecPromptHelp": "모델에 보낼 프롬프트입니다. -로 시작하거나 exec 플래그를 일반 텍스트로 포함하는 프롬프트 앞에는 --를 넣으세요. 표준 입력을 읽으려면 --prompt-file -를 사용하세요.",
"ExecFlagAfterPrompt": "{flag}가 프롬프트 뒤에 있어 텍스트로 처리됩니다. 플래그는 프롬프트 앞에 넣고, 일반 텍스트로 사용할 플래그는 -- 뒤에 넣으세요."
"ExecFlagAfterPrompt": "{flag}가 프롬프트 뒤에 있어 텍스트로 처리됩니다. 플래그는 프롬프트 앞에 넣고, 일반 텍스트로 사용할 플래그는 -- 뒤에 넣으세요.",
"InstructionNoticeHeader": "상시 지침이 이 세션이 시작되기 전에 잘리거나 건너뛰어졌습니다:",
"InstructionNoticeFooter": "소스별 전체 내역은 /context report를 실행하세요.",
"InstructionNoticeMore": "…외 {count}건. 전체 목록은 /context report에 있습니다.",
"InstructionNoticeLine": "지침: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " {total_bytes}바이트 중 {kept_bytes}바이트, 추정 ~{total_tokens}토큰 중 ~{kept_tokens}토큰 유지",
"InstructionNoticeDroppedBytes": " {bytes}바이트 제외",
"InstructionNoticeFallback": "; 대체: {source}",
"InstructionNoticeStaticGuardOver": "지침: 지침을 잘라낸 뒤에도 정적 접두부가 정적 접두부 한도를 추정 ~{tokens}토큰 초과합니다. `trim` 정책으로 계속합니다 — static_prefix_guard_percent를 높이거나 도구 범위를 줄이세요.",
"InstructionNoticeStaticGuardTrimmed": "지침: 정적 접두부를 한도 아래로 유지하려고 {scope} 범위를 추정 ~{tokens}토큰 잘랐습니다",
"InstructionDetailDroppedBroadest": "가장 넓은 범위의 세그먼트로 제외됨",
"InstructionDetailNoRoom": "추정 {tokens}토큰의 범위 예산으로는 내용을 담을 여유가 없었습니다",
"InstructionDetailRulesExhausted": "추정 {tokens}토큰의 프로젝트 범위 예산을 지침이 모두 사용했습니다",
"InstructionDetailOverCeiling": "{bytes}바이트 한도를 초과합니다",
"InstructionDetailUnreadable": "읽을 수 없음: {reason}",
"InstructionDetailAlreadyLoaded": "더 넓은 범위에서 이미 로드됨",
"InstructionDetailRulesCap": "디렉터리당 규칙 파일 한도 {count}개를 초과합니다",
"InstructionDetailSymlinkedRules": "심볼릭 링크 규칙 디렉터리는 거부합니다",
"InstructionBudgetRefused": "지침 예산이 이 요청을 거부했습니다: {reason}",
"InstructionBudgetOverGuard": "정적 접두부 ~{measured} 추정 토큰이 한도 {guard}를 초과합니다"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "um projeto, perfil, ambiente, config gerenciado ou exigência da organização controla este valor",
"ConfigHintManagedAllowShell": "um projeto, perfil, ambiente ou config gerenciado controla o acesso ao shell",
"ConfigHintAllowShell": "ligado expõe ferramentas de shell no modo Agente; as regras de permissão continuam valendo",
"ConfigHintComposerMultilineMode": "desligado: Enter envia, Shift+Enter adiciona linha; ligado: Enter adiciona linha, Shift+Enter envia",
"ConfigHintComposerMultilineMode": "desligado: Enter envia, Alt+Enter / Shift+Enter adiciona linha; ligado: Enter adiciona linha, Alt+Enter / Shift+Enter envia",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off; a mudança exata permanece nos detalhes de Alt/Option+V",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Política de sandbox desta execução; qualquer --sandbox diferente de danger-full-access (ou sandbox_mode equivalente na configuração) desativa o modo de confiança.",
"ExecAfterHelp": "Exemplos:\n codewhale exec \"explique esta função\"\n codewhale exec --auto \"liste crates/ com ls\"\n codewhale exec --auto --output-format stream-json \"corrija o teste que falha\"\n\n`codewhale exec` sem opções produz uma única resposta do modelo: um turno do\nEngine com o mesmo prompt de sistema das demais execuções e sem ferramentas.\nUse `--auto` para executar um agente com ferramentas sem interação.\nAs ferramentas só são oferecidas com `--auto`, `--yolo`, `--allowed-tools`\nou ao retomar uma sessão; limites como `--max-turns`, `--disallowed-tools`\nou `--sandbox`, assim como o formato de saída, nunca adicionam ferramentas.\nUma resposta cortada pelo limite de saída do provedor continua no mesmo turno,\ncom no máximo 8 etapas do modelo, salvo indicação diferente de `--max-turns`.\n--auto usa Full Access por padrão: sem sandbox do shell e com modo de confiança ativado. Use --sandbox workspace-write para manter o sandbox do shell e as restrições das ferramentas de arquivos ao workspace; políticas mais restritivas configuradas também se aplicam. Repetir uma operação negada pelo sandbox exige --allow-sandbox-elevation ou --sandbox danger-full-access.\nExecuções que oferecem ferramentas, execuções stream-json e sessões retomadas são salvas para codewhale sessions, exec --continue e codewhale receipts <id>. Execuções text/--json que apenas respondem não são salvas, a menos que a compactação precise de dados de recuperação.\nQualquer --sandbox diferente de danger-full-access (ou sandbox_mode equivalente na configuração) desativa o modo de confiança.\n",
"ExecPromptHelp": "Prompt enviado ao modelo. Coloque -- antes de um prompt que comece com - ou contenha uma opção literal de exec; use --prompt-file - para ler a entrada padrão.",
"ExecFlagAfterPrompt": "{flag} aparece após o prompt e seria tratado como texto. Coloque as opções antes do prompt ou, para usá-las como texto literal, após --."
"ExecFlagAfterPrompt": "{flag} aparece após o prompt e seria tratado como texto. Coloque as opções antes do prompt ou, para usá-las como texto literal, após --.",
"InstructionNoticeHeader": "Instruções permanentes foram cortadas ou ignoradas antes do início desta sessão:",
"InstructionNoticeFooter": "Execute /context report para ver o detalhamento completo por fonte.",
"InstructionNoticeMore": "…e mais {count}; a lista completa está em /context report.",
"InstructionNoticeLine": "instruções: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " mantidos {kept_bytes} de {total_bytes} bytes e ~{kept_tokens} de ~{total_tokens} tokens estimados",
"InstructionNoticeDroppedBytes": " {bytes} bytes descartados",
"InstructionNoticeFallback": "; alternativa: {source}",
"InstructionNoticeStaticGuardOver": "instruções: o prefixo estático ainda está ~{tokens} tokens estimados acima do limite do prefixo estático após o corte das instruções; continuando com a política `trim` — aumente static_prefix_guard_percent ou reduza a superfície de ferramentas.",
"InstructionNoticeStaticGuardTrimmed": "instruções: o escopo {scope} foi cortado em ~{tokens} tokens estimados para manter o prefixo estático abaixo do limite",
"InstructionDetailDroppedBroadest": "descartado como segmento do escopo mais amplo",
"InstructionDetailNoRoom": "o orçamento do escopo de {tokens} tokens estimados não deixou espaço para conteúdo",
"InstructionDetailRulesExhausted": "o orçamento do escopo do projeto de {tokens} tokens estimados foi esgotado pelas instruções",
"InstructionDetailOverCeiling": "maior que o limite de {bytes} bytes",
"InstructionDetailUnreadable": "não foi possível ler: {reason}",
"InstructionDetailAlreadyLoaded": "já carregado de um escopo mais amplo",
"InstructionDetailRulesCap": "acima do limite de {count} arquivos de regras por diretório",
"InstructionDetailSymlinkedRules": "recusando diretório de regras que é um link simbólico",
"InstructionBudgetRefused": "o orçamento de instruções recusou esta solicitação: {reason}",
"InstructionBudgetOverGuard": "o prefixo estático de ~{measured} tokens estimados excede o limite {guard}"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "это значение задаёт проект, профиль, окружение, управляемый config или требование организации",
"ConfigHintManagedAllowShell": "доступ к shell задаёт проект, профиль, окружение или управляемый config",
"ConfigHintAllowShell": "вкл открывает инструменты shell в режиме агента; правила разрешений по-прежнему действуют",
"ConfigHintComposerMultilineMode": "выкл: Enter отправляет, Shift+Enter добавляет строку; вкл: Enter добавляет строку, Shift+Enter отправляет",
"ConfigHintComposerMultilineMode": "выкл: Enter отправляет, Alt+Enter / Shift+Enter добавляет строку; вкл: Enter добавляет строку, Alt+Enter / Shift+Enter отправляет",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off; точное изменение остаётся в подробностях по Alt/Option+V",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Политика песочницы для этого запуска; любое значение --sandbox, кроме danger-full-access (или соответствующее значение sandbox_mode в конфигурации), отключает режим доверия.",
"ExecAfterHelp": "Примеры:\n codewhale exec \"объясни эту функцию\"\n codewhale exec --auto \"покажи содержимое crates/ с помощью ls\"\n codewhale exec --auto --output-format stream-json \"исправь падающий тест\"\n\nОбычный `codewhale exec` выдаёт один ответ модели: один ход Engine с тем же\nсистемным промптом, что и в других запусках, без инструментов. Для запуска\nагента с инструментами без взаимодействия используйте `--auto`. Инструменты\nдоступны только с `--auto`, `--yolo`, `--allowed-tools` или при продолжении\nсессии; ограничения вроде `--max-turns`, `--disallowed-tools`, `--sandbox` и\nформат вывода никогда не добавляют инструменты. Ответ, прерванный лимитом\nвывода провайдера, продолжается в том же ходе, не более 8 шагов модели,\nесли `--max-turns` не задаёт другой предел.\nПо умолчанию --auto использует Full Access: без песочницы оболочки, с включённым режимом доверия. Используйте --sandbox workspace-write, чтобы сохранить песочницу оболочки и ограничения файловых инструментов рабочей областью; более строгие настроенные политики также действуют. Повтор операции, отклонённой песочницей, требует --allow-sandbox-elevation или --sandbox danger-full-access.\nЗапуски с доступными инструментами, запуски stream-json и возобновлённые сессии сохраняются для codewhale sessions, exec --continue и codewhale receipts <id>. Обычные запуски text/--json только с ответом не сохраняются, если сжатие контекста не требует данных для восстановления.\nЛюбое значение --sandbox, кроме danger-full-access (или соответствующее значение sandbox_mode в конфигурации), отключает режим доверия.\n",
"ExecPromptHelp": "Запрос для модели. Поставьте -- перед запросом, который начинается с - или содержит флаг exec как обычный текст; --prompt-file - читает стандартный ввод.",
"ExecFlagAfterPrompt": "{flag} указан после запроса и будет воспринят как текст. Укажите флаги перед запросом, а если флаг нужен как обычный текст — после --."
"ExecFlagAfterPrompt": "{flag} указан после запроса и будет воспринят как текст. Укажите флаги перед запросом, а если флаг нужен как обычный текст — после --.",
"InstructionNoticeHeader": "Постоянные инструкции были урезаны или пропущены до начала этой сессии:",
"InstructionNoticeFooter": "Выполните /context report, чтобы увидеть полную разбивку по источникам.",
"InstructionNoticeMore": "…и ещё {count}; полный список в /context report.",
"InstructionNoticeLine": "инструкции: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " сохранено {kept_bytes} из {total_bytes} байт и ~{kept_tokens} из ~{total_tokens} оценочных токенов",
"InstructionNoticeDroppedBytes": " отброшено {bytes} байт",
"InstructionNoticeFallback": "; замена: {source}",
"InstructionNoticeStaticGuardOver": "инструкции: статический префикс всё ещё превышает его предел на ~{tokens} оценочных токенов после сокращения инструкций; работа продолжается с политикой `trim` — увеличьте static_prefix_guard_percent или сократите набор инструментов.",
"InstructionNoticeStaticGuardTrimmed": "инструкции: область {scope} сокращена на ~{tokens} оценочных токенов, чтобы статический префикс уложился в предел",
"InstructionDetailDroppedBroadest": "отброшено как сегмент самой широкой области",
"InstructionDetailNoRoom": "бюджет области в {tokens} оценочных токенов не оставил места для содержимого",
"InstructionDetailRulesExhausted": "бюджет области проекта в {tokens} оценочных токенов исчерпан инструкциями",
"InstructionDetailOverCeiling": "больше предела в {bytes} байт",
"InstructionDetailUnreadable": "не удалось прочитать: {reason}",
"InstructionDetailAlreadyLoaded": "уже загружено из более широкой области",
"InstructionDetailRulesCap": "сверх предела в {count} файлов правил на каталог",
"InstructionDetailSymlinkedRules": "отклонено: каталог правил является символической ссылкой",
"InstructionBudgetRefused": "бюджет инструкций отклонил этот запрос: {reason}",
"InstructionBudgetOverGuard": "статический префикс ~{measured} оценочных токенов превышает предел {guard}"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "це значення задає проєкт, профіль, середовище, керований config або вимога організації",
"ConfigHintManagedAllowShell": "доступ до shell задає проєкт, профіль, середовище або керований config",
"ConfigHintAllowShell": "увімк відкриває інструменти shell у режимі агента; правила дозволів і далі діють",
"ConfigHintComposerMultilineMode": "вимк: Enter надсилає, Shift+Enter додає рядок; увімк: Enter додає рядок, Shift+Enter надсилає",
"ConfigHintComposerMultilineMode": "вимк: Enter надсилає, Alt+Enter / Shift+Enter додає рядок; увімк: Enter додає рядок, Alt+Enter / Shift+Enter надсилає",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off; точна зміна залишається в подробицях за Alt/Option+V",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Політика пісочниці для цього запуску; будь-яке значення --sandbox, крім danger-full-access (або відповідне значення sandbox_mode у конфігурації), вимикає режим довіри.",
"ExecAfterHelp": "Приклади:\n codewhale exec \"поясни цю функцію\"\n codewhale exec --auto \"покажи вміст crates/ за допомогою ls\"\n codewhale exec --auto --output-format stream-json \"виправ тест, що не проходить\"\n\nЗвичайний `codewhale exec` дає одну відповідь моделі: один хід Engine з тією\nсамою системною підказкою, що й інші запуски, без інструментів. Використовуйте\n`--auto`, щоб запускати агента з інструментами без взаємодії. Інструменти\nдоступні лише з `--auto`, `--yolo`, `--allowed-tools` або під час поновлення\nсесії; обмеження на кшталт `--max-turns`, `--disallowed-tools`, `--sandbox` і\nформат виводу ніколи не додають інструментів. Відповідь, перервана лімітом\nвиводу провайдера, продовжується в тому самому ході, щонайбільше 8 кроків\nмоделі, якщо `--max-turns` не задає іншу межу.\nТипово --auto використовує Full Access: без пісочниці оболонки, з увімкненим режимом довіри. Використовуйте --sandbox workspace-write, щоб зберегти пісочницю оболонки та обмеження файлових інструментів робочою областю; суворіші налаштовані політики також діють. Повтор операції, відхиленої пісочницею, потребує --allow-sandbox-elevation або --sandbox danger-full-access.\nЗапуски з доступними інструментами, запуски stream-json і поновлені сесії зберігаються для codewhale sessions, exec --continue і codewhale receipts <id>. Звичайні запуски text/--json лише з відповіддю не зберігаються, якщо стиснення контексту не потребує даних для відновлення.\nБудь-яке значення --sandbox, крім danger-full-access (або відповідне значення sandbox_mode у конфігурації), вимикає режим довіри.\n",
"ExecPromptHelp": "Запит для моделі. Поставте -- перед запитом, який починається з - або містить прапорець exec як звичайний текст; --prompt-file - читає стандартний ввід.",
"ExecFlagAfterPrompt": "{flag} вказано після запиту, тож його буде сприйнято як текст. Указуйте прапорці перед запитом, а якщо прапорець потрібен як звичайний текст — після --."
"ExecFlagAfterPrompt": "{flag} вказано після запиту, тож його буде сприйнято як текст. Указуйте прапорці перед запитом, а якщо прапорець потрібен як звичайний текст — після --.",
"InstructionNoticeHeader": "Постійні інструкції було скорочено або пропущено до початку цього сеансу:",
"InstructionNoticeFooter": "Виконайте /context report, щоб побачити повну розбивку за джерелами.",
"InstructionNoticeMore": "…і ще {count}; повний список у /context report.",
"InstructionNoticeLine": "інструкції: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " збережено {kept_bytes} із {total_bytes} байтів і ~{kept_tokens} із ~{total_tokens} оціночних токенів",
"InstructionNoticeDroppedBytes": " відкинуто {bytes} байтів",
"InstructionNoticeFallback": "; заміна: {source}",
"InstructionNoticeStaticGuardOver": "інструкції: статичний префікс усе ще перевищує свій ліміт на ~{tokens} оціночних токенів після скорочення інструкцій; роботу продовжено з політикою `trim` — збільште static_prefix_guard_percent або скоротіть набір інструментів.",
"InstructionNoticeStaticGuardTrimmed": "інструкції: область {scope} скорочено на ~{tokens} оціночних токенів, щоб статичний префікс вклався в ліміт",
"InstructionDetailDroppedBroadest": "відкинуто як сегмент найширшої області",
"InstructionDetailNoRoom": "бюджет області в {tokens} оціночних токенів не залишив місця для вмісту",
"InstructionDetailRulesExhausted": "бюджет області проєкту в {tokens} оціночних токенів вичерпано інструкціями",
"InstructionDetailOverCeiling": "більше за ліміт у {bytes} байтів",
"InstructionDetailUnreadable": "не вдалося прочитати: {reason}",
"InstructionDetailAlreadyLoaded": "уже завантажено з ширшої області",
"InstructionDetailRulesCap": "понад ліміт у {count} файлів правил на каталог",
"InstructionDetailSymlinkedRules": "відхилено: каталог правил є символічним посиланням",
"InstructionBudgetRefused": "бюджет інструкцій відхилив цей запит: {reason}",
"InstructionBudgetOverGuard": "статичний префікс ~{measured} оціночних токенів перевищує ліміт {guard}"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "một dự án, hồ sơ, môi trường, config được quản lý hoặc yêu cầu của tổ chức kiểm soát giá trị này",
"ConfigHintManagedAllowShell": "một dự án, hồ sơ, môi trường hoặc config được quản lý kiểm soát quyền truy cập shell",
"ConfigHintAllowShell": "bật sẽ cung cấp công cụ shell ở chế độ Tác nhân; quy tắc quyền vẫn áp dụng",
"ConfigHintComposerMultilineMode": "tắt: Enter gửi, Shift+Enter thêm dòng; bật: Enter thêm dòng, Shift+Enter gửi",
"ConfigHintComposerMultilineMode": "tắt: Enter gửi, Alt+Enter / Shift+Enter thêm dòng; bật: Enter thêm dòng, Alt+Enter / Shift+Enter gửi",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off; thay đổi chính xác vẫn ở chi tiết Alt/Option+V",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "Chính sách sandbox cho lần chạy này; mọi --sandbox khác danger-full-access (hoặc sandbox_mode tương đương trong cấu hình) đều tắt chế độ tin cậy.",
"ExecAfterHelp": "Ví dụ:\n codewhale exec \"giải thích hàm này\"\n codewhale exec --auto \"liệt kê crates/ bằng ls\"\n codewhale exec --auto --output-format stream-json \"sửa bài kiểm thử đang lỗi\"\n\n`codewhale exec` thông thường tạo một câu trả lời của mô hình: một lượt Engine\nvới cùng lời nhắc hệ thống như các lần chạy khác và không có công cụ. Dùng\n`--auto` để chạy tác nhân có công cụ mà không cần tương tác. Công cụ chỉ được\ncung cấp khi có `--auto`, `--yolo`, `--allowed-tools` hoặc khi tiếp tục phiên;\ncác giới hạn như `--max-turns`, `--disallowed-tools`, `--sandbox` và định dạng\nđầu ra không bao giờ thêm công cụ. Câu trả lời bị ngắt do giới hạn đầu ra của\nnhà cung cấp được tiếp tục trong cùng lượt, tối đa 8 bước mô hình trừ khi\n`--max-turns` quy định khác.\n--auto mặc định dùng Full Access: không có sandbox shell và bật chế độ tin cậy. Dùng --sandbox workspace-write để duy trì sandbox shell và giới hạn công cụ tệp trong không gian làm việc; các chính sách chặt chẽ hơn đã cấu hình cũng được áp dụng. Thử lại thao tác bị sandbox từ chối cần --allow-sandbox-elevation hoặc --sandbox danger-full-access.\nCác lần chạy có cung cấp công cụ, lần chạy stream-json và phiên được tiếp tục đều được lưu để dùng với codewhale sessions, exec --continue và codewhale receipts <id>. Các lần chạy text/--json thông thường chỉ trả lời sẽ không được lưu, trừ khi việc nén ngữ cảnh cần dữ liệu khôi phục.\nMọi --sandbox khác danger-full-access (hoặc sandbox_mode tương đương trong cấu hình) đều tắt chế độ tin cậy.\n",
"ExecPromptHelp": "Lời nhắc gửi đến mô hình. Đặt -- trước lời nhắc bắt đầu bằng - hoặc chứa cờ exec dưới dạng văn bản; dùng --prompt-file - để đọc đầu vào chuẩn.",
"ExecFlagAfterPrompt": "{flag} nằm sau lời nhắc và sẽ bị xem là văn bản. Hãy đặt cờ trước lời nhắc hoặc đặt cờ cần dùng dưới dạng văn bản sau --."
"ExecFlagAfterPrompt": "{flag} nằm sau lời nhắc và sẽ bị xem là văn bản. Hãy đặt cờ trước lời nhắc hoặc đặt cờ cần dùng dưới dạng văn bản sau --.",
"InstructionNoticeHeader": "Các chỉ dẫn thường trực đã bị cắt bớt hoặc bỏ qua trước khi phiên này bắt đầu:",
"InstructionNoticeFooter": "Chạy /context report để xem chi tiết đầy đủ theo từng nguồn.",
"InstructionNoticeMore": "…và {count} mục nữa; danh sách đầy đủ nằm trong /context report.",
"InstructionNoticeLine": "chỉ dẫn: {scope} {label} ({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " giữ {kept_bytes}/{total_bytes} byte và ~{kept_tokens}/~{total_tokens} token ước tính",
"InstructionNoticeDroppedBytes": " đã bỏ {bytes} byte",
"InstructionNoticeFallback": "; thay thế: {source}",
"InstructionNoticeStaticGuardOver": "chỉ dẫn: tiền tố tĩnh vẫn vượt giới hạn tiền tố tĩnh ~{tokens} token ước tính sau khi cắt chỉ dẫn; tiếp tục theo chính sách `trim` — hãy tăng static_prefix_guard_percent hoặc thu hẹp bề mặt công cụ.",
"InstructionNoticeStaticGuardTrimmed": "chỉ dẫn: phạm vi {scope} bị cắt ~{tokens} token ước tính để giữ tiền tố tĩnh dưới giới hạn",
"InstructionDetailDroppedBroadest": "bị bỏ vì là đoạn thuộc phạm vi rộng nhất",
"InstructionDetailNoRoom": "ngân sách phạm vi {tokens} token ước tính không còn chỗ cho nội dung",
"InstructionDetailRulesExhausted": "ngân sách phạm vi dự án {tokens} token ước tính đã bị chỉ dẫn dùng hết",
"InstructionDetailOverCeiling": "lớn hơn mức tối đa {bytes} byte",
"InstructionDetailUnreadable": "không đọc được: {reason}",
"InstructionDetailAlreadyLoaded": "đã được nạp từ phạm vi rộng hơn",
"InstructionDetailRulesCap": "vượt giới hạn {count} tệp quy tắc mỗi thư mục",
"InstructionDetailSymlinkedRules": "từ chối thư mục quy tắc là liên kết tượng trưng",
"InstructionBudgetRefused": "ngân sách chỉ dẫn đã từ chối yêu cầu này: {reason}",
"InstructionBudgetOverGuard": "tiền tố tĩnh ~{measured} token ước tính vượt giới hạn {guard}"
}
+21 -2
View File
@@ -2371,7 +2371,7 @@
"ConfigHintManagedApprovalPolicy": "项目、配置文件、环境、托管配置或组织要求控制此值",
"ConfigHintManagedAllowShell": "项目、配置文件、环境或托管配置控制 shell 访问",
"ConfigHintAllowShell": "开启后在 Work 模式中提供 shell 工具;权限规则仍然适用",
"ConfigHintComposerMultilineMode": "关:Enter 发送,Shift+Enter 换行;开:Enter 换行,Shift+Enter 发送",
"ConfigHintComposerMultilineMode": "关:Enter 发送,Alt+Enter / Shift+Enter 换行;开:Enter 换行,Alt+Enter / Shift+Enter 发送",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off;精确更改仍在 Alt/Option+V 详情中",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "本次运行的沙盒策略;danger-full-access 以外的任何 --sandbox(或配置中等效的 sandbox_mode)都会关闭信任模式。",
"ExecAfterHelp": "示例:\n codewhale exec \"解释这个函数\"\n codewhale exec --auto \"用 ls 列出 crates/ 的内容\"\n codewhale exec --auto --output-format stream-json \"修复失败的测试\"\n\n普通的 `codewhale exec` 仅获取一次模型回答:使用与其他运行相同的系统提示词,\n执行一轮 Engine,不提供工具。使用 `--auto` 可运行非交互式工具代理。\n只有指定 `--auto`、`--yolo`、`--allowed-tools` 或恢复会话时才会提供工具;\n`--max-turns`、`--disallowed-tools`、`--sandbox` 等限制以及输出格式\n都不会添加工具。因提供商输出上限而中断的回答会在同一轮中继续,\n除非通过 `--max-turns` 另行指定,否则最多进行 8 个模型步骤。\n--auto 默认使用 Full Access:无 shell 沙盒,开启信任模式。使用 --sandbox workspace-write 可保留 shell 沙盒和文件工具的工作区限制;已配置的更严格策略也会生效。重试被沙盒拒绝的操作需要 --allow-sandbox-elevation 或 --sandbox danger-full-access。\n提供工具的运行、stream-json 运行以及恢复的会话都会保存,可通过 codewhale sessions、exec --continue 和 codewhale receipts <id> 使用。仅回答的普通 text/--json 运行不会保存,除非上下文压缩需要保留恢复数据。\ndanger-full-access 以外的任何 --sandbox(或配置中等效的 sandbox_mode)都会关闭信任模式。\n",
"ExecPromptHelp": "发送给模型的提示词。若以 - 开头或包含作为文本的 exec 标志,请在提示词前加 --;使用 --prompt-file - 读取标准输入。",
"ExecFlagAfterPrompt": "{flag} 位于提示词之后,会被当作文本。请将标志放在提示词之前,或将作为文本的标志放在 -- 之后。"
"ExecFlagAfterPrompt": "{flag} 位于提示词之后,会被当作文本。请将标志放在提示词之前,或将作为文本的标志放在 -- 之后。",
"InstructionNoticeHeader": "常驻指令在本会话开始前已被截断或跳过:",
"InstructionNoticeFooter": "运行 /context report 查看按来源划分的完整明细。",
"InstructionNoticeMore": "…另有 {count} 项;完整列表见 /context report。",
"InstructionNoticeLine": "指令:{scope} {label}({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " 保留 {total_bytes} 字节中的 {kept_bytes} 字节,以及约 {total_tokens} 估算 token 中的约 {kept_tokens}",
"InstructionNoticeDroppedBytes": " 丢弃 {bytes} 字节",
"InstructionNoticeFallback": ";替代:{source}",
"InstructionNoticeStaticGuardOver": "指令:截断指令后,静态前缀仍超出静态前缀上限约 {tokens} 估算 token;按策略 `trim` 继续——请调高 static_prefix_guard_percent 或收窄工具范围。",
"InstructionNoticeStaticGuardTrimmed": "指令:{scope} 范围已截断约 {tokens} 估算 token,以使静态前缀保持在上限内",
"InstructionDetailDroppedBroadest": "作为最宽范围的片段被丢弃",
"InstructionDetailNoRoom": "{tokens} 估算 token 的范围预算已无空间容纳内容",
"InstructionDetailRulesExhausted": "{tokens} 估算 token 的项目范围预算已被指令用尽",
"InstructionDetailOverCeiling": "超过 {bytes} 字节上限",
"InstructionDetailUnreadable": "无法读取:{reason}",
"InstructionDetailAlreadyLoaded": "已从更宽范围加载",
"InstructionDetailRulesCap": "超过每个目录 {count} 个规则文件的上限",
"InstructionDetailSymlinkedRules": "拒绝符号链接的规则目录",
"InstructionBudgetRefused": "指令预算拒绝了此请求:{reason}",
"InstructionBudgetOverGuard": "静态前缀约 {measured} 估算 token 超出上限 {guard}"
}
+21 -2
View File
@@ -2369,7 +2369,7 @@
"ConfigHintManagedApprovalPolicy": "專案、設定檔、環境、受管設定或組織要求控制此值",
"ConfigHintManagedAllowShell": "專案、設定檔、環境或受管設定控制 shell 存取",
"ConfigHintAllowShell": "開啟後在 Work 模式中提供 shell 工具;權限規則仍然適用",
"ConfigHintComposerMultilineMode": "關:Enter 送出,Shift+Enter 換行;開:Enter 換行,Shift+Enter 送出",
"ConfigHintComposerMultilineMode": "關:Enter 送出,Alt+Enter / Shift+Enter 換行;開:Enter 換行,Alt+Enter / Shift+Enter 送出",
"ConfigHintBooleanValues": "on/off, true/false, yes/no, 1/0",
"ConfigHintDensity": "compact | comfortable | spacious",
"ConfigHintInlineDiffs": "full | summary | off;精確變更仍在 Alt/Option+V 詳情中",
@@ -2614,5 +2614,24 @@
"ExecSandboxHelp": "本次執行的沙盒原則;danger-full-access 以外的任何 --sandbox(或設定中等效的 sandbox_mode)都會關閉信任模式。",
"ExecAfterHelp": "範例:\n codewhale exec \"解釋這個函式\"\n codewhale exec --auto \"用 ls 列出 crates/ 的內容\"\n codewhale exec --auto --output-format stream-json \"修正失敗的測試\"\n\n一般的 `codewhale exec` 僅取得一次模型回覆:使用與其他執行相同的系統提示詞,\n執行一輪 Engine,不提供工具。使用 `--auto` 可執行非互動式工具代理。\n只有指定 `--auto`、`--yolo`、`--allowed-tools` 或還原工作階段時才會提供工具;\n`--max-turns`、`--disallowed-tools`、`--sandbox` 等限制及輸出格式\n都不會新增工具。因供應商輸出上限而中斷的回覆會在同一輪中繼續,\n除非透過 `--max-turns` 另行指定,否則最多進行 8 個模型步驟。\n--auto 預設使用 Full Access:無 shell 沙盒,開啟信任模式。使用 --sandbox workspace-write 可保留 shell 沙盒與檔案工具的工作區限制;已設定的更嚴格政策也會生效。重試遭沙盒拒絕的操作需要 --allow-sandbox-elevation 或 --sandbox danger-full-access。\n提供工具的執行、stream-json 執行及還原的工作階段都會儲存,可透過 codewhale sessions、exec --continue 與 codewhale receipts <id> 使用。僅回覆的一般 text/--json 執行不會儲存,除非內容壓縮需要保留復原資料。\ndanger-full-access 以外的任何 --sandbox(或設定中等效的 sandbox_mode)都會關閉信任模式。\n",
"ExecPromptHelp": "傳送給模型的提示詞。若以 - 開頭或包含作為文字的 exec 旗標,請在提示詞前加 --;使用 --prompt-file - 讀取標準輸入。",
"ExecFlagAfterPrompt": "{flag} 位於提示詞之後,會被視為文字。請將旗標放在提示詞之前,或將作為文字的旗標放在 -- 之後。"
"ExecFlagAfterPrompt": "{flag} 位於提示詞之後,會被視為文字。請將旗標放在提示詞之前,或將作為文字的旗標放在 -- 之後。",
"InstructionNoticeHeader": "常駐指令在此工作階段開始前已被截斷或略過:",
"InstructionNoticeFooter": "執行 /context report 以查看依來源劃分的完整明細。",
"InstructionNoticeMore": "…另有 {count} 項;完整清單見 /context report。",
"InstructionNoticeLine": "指令:{scope} {label}({origin}, {action}, {limit})",
"InstructionNoticeTrimmed": " 保留 {total_bytes} 位元組中的 {kept_bytes} 位元組,以及約 {total_tokens} 估算 token 中的約 {kept_tokens}",
"InstructionNoticeDroppedBytes": " 捨棄 {bytes} 位元組",
"InstructionNoticeFallback": ";替代:{source}",
"InstructionNoticeStaticGuardOver": "指令:截斷指令後,靜態前綴仍超出靜態前綴上限約 {tokens} 估算 token;依政策 `trim` 繼續——請調高 static_prefix_guard_percent 或縮小工具範圍。",
"InstructionNoticeStaticGuardTrimmed": "指令:{scope} 範圍已截斷約 {tokens} 估算 token,以使靜態前綴維持在上限內",
"InstructionDetailDroppedBroadest": "作為最廣範圍的片段被捨棄",
"InstructionDetailNoRoom": "{tokens} 估算 token 的範圍預算已無空間容納內容",
"InstructionDetailRulesExhausted": "{tokens} 估算 token 的專案範圍預算已被指令用盡",
"InstructionDetailOverCeiling": "超過 {bytes} 位元組上限",
"InstructionDetailUnreadable": "無法讀取:{reason}",
"InstructionDetailAlreadyLoaded": "已從更廣範圍載入",
"InstructionDetailRulesCap": "超過每個目錄 {count} 個規則檔案的上限",
"InstructionDetailSymlinkedRules": "拒絕符號連結的規則目錄",
"InstructionBudgetRefused": "指令預算拒絕了此請求:{reason}",
"InstructionBudgetOverGuard": "靜態前綴約 {measured} 估算 token 超出上限 {guard}"
}
+38
View File
@@ -2851,6 +2851,25 @@ pub enum MessageId {
PluginOAuthLocalLogout,
PlanHandoffProceed,
PlanHandoffTodoNote,
InstructionNoticeHeader,
InstructionNoticeFooter,
InstructionNoticeMore,
InstructionNoticeLine,
InstructionNoticeTrimmed,
InstructionNoticeDroppedBytes,
InstructionNoticeFallback,
InstructionNoticeStaticGuardOver,
InstructionNoticeStaticGuardTrimmed,
InstructionDetailDroppedBroadest,
InstructionDetailNoRoom,
InstructionDetailRulesExhausted,
InstructionDetailOverCeiling,
InstructionDetailUnreadable,
InstructionDetailAlreadyLoaded,
InstructionDetailRulesCap,
InstructionDetailSymlinkedRules,
InstructionBudgetRefused,
InstructionBudgetOverGuard,
ExecAutoHelp,
ExecSandboxHelp,
ExecAfterHelp,
@@ -5471,6 +5490,25 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::PluginOAuthLocalLogout,
MessageId::PlanHandoffProceed,
MessageId::PlanHandoffTodoNote,
MessageId::InstructionNoticeHeader,
MessageId::InstructionNoticeFooter,
MessageId::InstructionNoticeMore,
MessageId::InstructionNoticeLine,
MessageId::InstructionNoticeTrimmed,
MessageId::InstructionNoticeDroppedBytes,
MessageId::InstructionNoticeFallback,
MessageId::InstructionNoticeStaticGuardOver,
MessageId::InstructionNoticeStaticGuardTrimmed,
MessageId::InstructionDetailDroppedBroadest,
MessageId::InstructionDetailNoRoom,
MessageId::InstructionDetailRulesExhausted,
MessageId::InstructionDetailOverCeiling,
MessageId::InstructionDetailUnreadable,
MessageId::InstructionDetailAlreadyLoaded,
MessageId::InstructionDetailRulesCap,
MessageId::InstructionDetailSymlinkedRules,
MessageId::InstructionBudgetRefused,
MessageId::InstructionBudgetOverGuard,
MessageId::ExecAutoHelp,
MessageId::ExecSandboxHelp,
MessageId::ExecAfterHelp,
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "codewhale-net"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Codewhale outbound network policy, guarded destinations and proxy resolution"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
codewhale-command-contract = { path = "../command-contract", version = "0.10.2" }
codewhale-config = { path = "../config", version = "0.10.2" }
codewhale-release = { path = "../release", version = "0.10.2" }
codewhale-runtime = { path = "../runtime", version = "0.10.2" }
codewhale-tools = { path = "../tools", version = "0.10.2" }
http = "1"
hyper-util = { version = "=0.1.21", features = ["client-proxy"] }
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
tracing.workspace = true
[dev-dependencies]
tempfile.workspace = true
+630
View File
@@ -0,0 +1,630 @@
use crate::network_policy::NetworkPolicyDecider;
use codewhale_tools::ToolError;
use std::net::IpAddr;
use std::time::Duration;
/// Wall-clock bound for one pre-flight DNS resolution. The resolution runs
/// before the guarded request (and once per redirect), so a hung resolver
/// would otherwise stall the tool far beyond the documented request timeout
/// envelope of the calling fetch tool.
pub const DNS_PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(10);
/// DNS pin returned when a hostname was resolved to a validated public IP.
/// Callers should pass this to `reqwest::ClientBuilder::resolve` so the
/// connection uses the pre-validated address instead of re-resolving.
pub type DnsPin = Option<(String, IpAddr)>;
/// Build the transport used after a destination has passed SSRF validation.
/// Ambient HTTP(S)/SOCKS proxies are deliberately disabled: a proxy would
/// receive the original hostname, resolve it again outside this process, and
/// bypass the validated DNS pin.
pub fn guarded_reqwest_client_builder() -> reqwest::ClientBuilder {
codewhale_release::tls::reqwest_client_builder().no_proxy()
}
/// Check if an IP address is loopback, private, link-local, cloud-metadata,
/// multicast, or reserved — all addresses that should not be reachable via
/// an LLM-initiated fetch request (SSRF prevention).
pub fn is_restricted_ip(ip: &IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => {
v4.is_loopback()
|| v4.is_private()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_broadcast()
|| v4.is_unspecified()
// 100.64.0.0/10 — Carrier-grade NAT (CGNAT / shared address space)
|| matches!(v4.octets(), [100, 64..=127, ..])
// 169.254.169.254 — cloud metadata (AWS/GCP/Azure)
|| *ip == IpAddr::V4(std::net::Ipv4Addr::new(169, 254, 169, 254))
// 198.18.0.0/15 — IETF benchmark testing
|| matches!(v4.octets(), [198, 18..=19, ..])
// 240.0.0.0/4 — reserved (former Class E)
|| v4.octets()[0] >= 240
}
IpAddr::V6(v6) => {
// IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — unwrap and check as IPv4
// to prevent bypass via ::ffff:127.0.0.1 etc.
if v6.is_unspecified()
|| matches!(v6.octets(), [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, ..])
{
return true;
}
if let Some(v4) = v6.to_ipv4_mapped() {
return is_restricted_ip(&IpAddr::V4(v4));
}
// A NAT64 gateway reaches the embedded IPv4 target, so the prefix
// must not hide a restricted address.
if let Some(v4) = nat64_ipv4(*v6)
&& is_restricted_ip(&IpAddr::V4(v4))
{
return true;
}
v6.is_loopback()
|| v6.is_multicast()
|| matches!(v6.segments(), [0xfc00..=0xfdff, ..]) // ULA fc00::/7
|| matches!(v6.segments(), [0xfe80..=0xfebf, ..]) // Link-local fe80::/10
|| matches!(v6.segments(), [0xfec0..=0xfeff, ..]) // Site-local fec0::/10
|| matches!(v6.segments(), [0x2002, ..]) // 6to4 2002::/16
|| matches!(v6.segments(), [0x2001, 0x0000, ..]) // Teredo 2001::/32
|| matches!(v6.segments(), [0, 0, 0, 0, 0, 0, ..]) // IPv4-compatible ::a.b.c.d
}
}
}
/// The IPv4 target of a well-known-prefix NAT64 address (`64:ff9b::/96`).
fn nat64_ipv4(v6: std::net::Ipv6Addr) -> Option<std::net::Ipv4Addr> {
let segments = v6.segments();
if segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2..6].iter().all(|s| *s == 0) {
let octets = v6.octets();
return Some(std::net::Ipv4Addr::new(
octets[12], octets[13], octets[14], octets[15],
));
}
None
}
pub fn fetch_target_host(url: &reqwest::Url) -> Result<String, ToolError> {
if url.scheme() != "http" && url.scheme() != "https" {
return Err(ToolError::invalid_input(
"only http:// and https:// URLs are supported",
));
}
url.host_str()
.map(str::to_ascii_lowercase)
.ok_or_else(|| ToolError::invalid_input("URL must include a host"))
}
pub async fn validate_fetch_destination(
host: String,
decider: Option<&NetworkPolicyDecider>,
tool: &str,
resolve: bool,
) -> Result<DnsPin, ToolError> {
// SSRF protection: reject localhost names and private/link-local/loopback
// IP literals; when `resolve` is set, also resolve the hostname and reject
// restricted answers. Prevents LLM-prompted requests to cloud metadata
// (169.254.169.254), localhost services, and internal networks.
let bare = host.strip_suffix('.').unwrap_or(&host);
if bare == "localhost" || bare == "localhost.localdomain" || bare.ends_with(".localhost") {
return Err(ToolError::permission_denied(
"requests to localhost are not allowed",
));
}
// Normalize bracketed IPv6 literals before the literal-IP check so they
// route through the same restricted-IP policy as unbracketed forms
// (GHSA-88gh-2526-gfrr).
let ip_candidate = host
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(host.as_str());
if let Ok(ip) = ip_candidate.parse::<IpAddr>() {
if is_restricted_ip(&ip) {
return Err(ToolError::permission_denied(format!(
"IP {ip} is a restricted address (private/loopback/link-local)"
)));
}
return Ok(None);
}
if !resolve {
return Ok(None);
}
let addrs = tokio::time::timeout(
DNS_PREFLIGHT_TIMEOUT,
tokio::net::lookup_host((host.as_str(), 0u16)),
)
.await
.map_err(|_| {
ToolError::permission_denied(format!("timed out resolving host before {tool} request"))
})?
.map_err(|e| {
ToolError::permission_denied(format!("could not resolve host before {tool} request: {e}"))
})?;
let mut first_valid: Option<IpAddr> = None;
for addr in addrs {
validate_dns_resolved_ip(&host, &addr.ip(), decider, tool)?;
if first_valid.is_none() {
first_valid = Some(addr.ip());
}
}
let Some(validated_ip) = first_valid else {
return Err(ToolError::permission_denied(format!(
"host resolved to no addresses before {tool} request"
)));
};
Ok(Some((host, validated_ip)))
}
pub fn validate_dns_resolved_ip(
host: &str,
ip: &IpAddr,
decider: Option<&NetworkPolicyDecider>,
tool: &str,
) -> Result<(), ToolError> {
if !is_restricted_ip(ip) {
return Ok(());
}
// A fake-IP exception requires both an explicitly trusted hostname and an
// explicitly trusted placeholder CIDR. The CIDR parser admits only subnets
// inside 198.18.0.0/15, so real private/loopback/link-local/metadata/ULA
// addresses remain blocked even when the hostname is trusted.
if let Some(decider) = decider
&& decider.is_trusted_fakeip_addr(ip)
&& decider.trusts_proxy_fakeip_host(host)
{
decider.record_trusted_proxy_fakeip_allow(host, tool);
return Ok(());
}
Err(ToolError::permission_denied(format!(
"resolved IP {ip} is a restricted address (private/loopback/link-local)"
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(not(windows))]
use std::io::{Read, Write};
#[cfg(not(windows))]
use std::net::{Ipv4Addr, SocketAddr, TcpListener};
#[cfg(not(windows))]
use std::process::Command;
#[cfg(not(windows))]
use std::sync::Arc;
#[cfg(not(windows))]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(not(windows))]
use std::time::{Duration, Instant};
#[cfg(not(windows))]
#[derive(Clone, Copy, Debug)]
enum AmbientProxyKind {
Http,
HttpsConnect,
SocksRemoteDns,
}
#[cfg(not(windows))]
fn spawn_accept_probe(
stop: Arc<AtomicBool>,
respond_http: bool,
drain_http_headers: bool,
) -> (u16, std::thread::JoinHandle<bool>) {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind probe listener");
let port = listener.local_addr().expect("probe address").port();
listener
.set_nonblocking(true)
.expect("nonblocking probe listener");
let handle = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match listener.accept() {
Ok((mut stream, _)) => {
// Accepted sockets inherit nonblocking from the listener
// on several Unixes. Restore blocking mode before timed
// header reads so a not-yet-ready socket is not treated
// as a hard failure (WouldBlock / EAGAIN).
stream
.set_nonblocking(false)
.expect("blocking probe stream");
if drain_http_headers {
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.expect("probe read timeout");
let mut request = Vec::new();
let mut chunk = [0_u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let read = match stream.read(&mut chunk) {
Ok(n) => n,
Err(err)
if matches!(
err.kind(),
std::io::ErrorKind::WouldBlock
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::Interrupted
) =>
{
continue;
}
Err(err) => panic!("read probe request: {err}"),
};
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
if Instant::now() >= deadline {
break;
}
}
}
if respond_http {
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
);
}
return true;
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {}
Err(err) => panic!("probe accept failed: {err}"),
}
if stop.load(Ordering::SeqCst) || Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(10));
}
});
(port, handle)
}
#[cfg(not(windows))]
fn run_ambient_proxy_probe(kind: AmbientProxyKind, guarded: bool) -> (bool, bool) {
let stop = Arc::new(AtomicBool::new(false));
let (target_port, target_handle) = spawn_accept_probe(
Arc::clone(&stop),
matches!(
kind,
AmbientProxyKind::Http | AmbientProxyKind::SocksRemoteDns
),
matches!(
kind,
AmbientProxyKind::Http | AmbientProxyKind::SocksRemoteDns
),
);
let (proxy_port, proxy_handle) = spawn_accept_probe(Arc::clone(&stop), true, false);
let mut command = Command::new(std::env::current_exe().expect("current test executable"));
command.args([
"--exact",
"guard::tests::guarded_transport_proxy_probe_child",
"--ignored",
"--nocapture",
]);
for key in [
"HTTP_PROXY",
"http_proxy",
"HTTPS_PROXY",
"https_proxy",
"ALL_PROXY",
"all_proxy",
"NO_PROXY",
"no_proxy",
"REQUEST_METHOD",
] {
command.env_remove(key);
}
command
.env("CODEWHALE_PROXY_PROBE_CHILD", "1")
.env(
"CODEWHALE_PROXY_PROBE_GUARDED",
if guarded { "1" } else { "0" },
)
.env("CODEWHALE_PROXY_PROBE_TARGET_PORT", target_port.to_string());
match kind {
AmbientProxyKind::Http => {
let proxy = format!("http://127.0.0.1:{proxy_port}");
command
.env("CODEWHALE_PROXY_PROBE_SCHEME", "http")
.env("HTTP_PROXY", &proxy)
.env("http_proxy", proxy);
}
AmbientProxyKind::HttpsConnect => {
let proxy = format!("http://127.0.0.1:{proxy_port}");
command
.env("CODEWHALE_PROXY_PROBE_SCHEME", "https")
.env("HTTPS_PROXY", &proxy)
.env("https_proxy", proxy);
}
AmbientProxyKind::SocksRemoteDns => {
let proxy = format!("socks5h://127.0.0.1:{proxy_port}");
command
.env("CODEWHALE_PROXY_PROBE_SCHEME", "http")
.env("ALL_PROXY", &proxy)
.env("all_proxy", proxy);
}
}
let output = command.output().expect("run proxy probe child");
stop.store(true, Ordering::SeqCst);
let target_hit = target_handle.join().expect("join target probe");
let proxy_hit = proxy_handle.join().expect("join proxy probe");
assert!(
output.status.success(),
"proxy probe child failed for {kind:?} guarded={guarded}:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
(target_hit, proxy_hit)
}
#[cfg(not(windows))]
#[tokio::test]
#[ignore = "subprocess helper for ambient proxy regression test"]
async fn guarded_transport_proxy_probe_child() {
if std::env::var_os("CODEWHALE_PROXY_PROBE_CHILD").is_none() {
return;
}
let guarded = std::env::var("CODEWHALE_PROXY_PROBE_GUARDED").as_deref() == Ok("1");
let scheme = std::env::var("CODEWHALE_PROXY_PROBE_SCHEME").expect("probe scheme");
let target_port = std::env::var("CODEWHALE_PROXY_PROBE_TARGET_PORT")
.expect("probe target port")
.parse::<u16>()
.expect("numeric target port");
let host = "guarded-proxy-probe.example.invalid";
let builder = if guarded {
guarded_reqwest_client_builder()
} else {
codewhale_release::tls::reqwest_client_builder()
};
let client = builder
.timeout(Duration::from_secs(2))
.resolve(
host,
SocketAddr::new(Ipv4Addr::LOCALHOST.into(), target_port),
)
.build()
.expect("build proxy probe client");
let result = client
.get(format!("{scheme}://{host}:{target_port}/"))
.send()
.await;
if guarded && scheme == "http" {
assert!(
result.is_ok(),
"guarded HTTP target should answer: {result:?}"
);
}
}
#[cfg(not(windows))]
#[test]
fn guarded_transport_bypasses_ambient_http_https_and_remote_dns_socks_proxies() {
for kind in [
AmbientProxyKind::Http,
AmbientProxyKind::HttpsConnect,
AmbientProxyKind::SocksRemoteDns,
] {
let (unguarded_target, unguarded_proxy) = run_ambient_proxy_probe(kind, false);
assert!(
unguarded_proxy && !unguarded_target,
"control client must demonstrate ambient {kind:?} proxy interception"
);
let (guarded_target, guarded_proxy) = run_ambient_proxy_probe(kind, true);
assert!(
guarded_target && !guarded_proxy,
"guarded client must preserve its DNS pin and bypass ambient {kind:?} proxy"
);
}
}
#[test]
fn rejects_private_localhost_literal() {
assert!(is_restricted_ip(&"127.0.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"::1".parse().unwrap()));
}
#[test]
fn rejects_private_rfc1918() {
assert!(is_restricted_ip(&"10.0.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"172.16.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"192.168.1.1".parse().unwrap()));
}
#[test]
fn rejects_cloud_metadata() {
assert!(is_restricted_ip(&"169.254.169.254".parse().unwrap()));
}
#[test]
fn rejects_link_local() {
assert!(is_restricted_ip(&"169.254.1.1".parse().unwrap()));
}
#[test]
fn rejects_cgnat() {
assert!(is_restricted_ip(&"100.64.0.1".parse().unwrap()));
assert!(!is_restricted_ip(&"100.63.0.1".parse().unwrap()));
assert!(!is_restricted_ip(&"100.128.0.1".parse().unwrap()));
}
#[test]
fn rejects_ipv6_ula() {
assert!(is_restricted_ip(&"fc00::1".parse().unwrap()));
assert!(is_restricted_ip(&"fd12:3456::1".parse().unwrap()));
}
#[test]
fn rejects_ipv4_mapped_ipv6() {
// ::ffff:127.0.0.1 — IPv4-mapped IPv6 loopback bypass
assert!(is_restricted_ip(&"::ffff:127.0.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"::ffff:10.0.0.1".parse().unwrap()));
assert!(is_restricted_ip(&"::ffff:169.254.169.254".parse().unwrap()));
assert!(is_restricted_ip(&"::ffff:192.168.1.1".parse().unwrap()));
// :: (unspecified)
assert!(is_restricted_ip(&"::".parse().unwrap()));
}
#[test]
fn allows_public_ips() {
assert!(!is_restricted_ip(&"8.8.8.8".parse().unwrap()));
assert!(!is_restricted_ip(&"1.1.1.1".parse().unwrap()));
assert!(!is_restricted_ip(&"93.184.216.34".parse().unwrap()));
assert!(!is_restricted_ip(&"2606:4700::1".parse().unwrap()));
}
#[test]
fn restricted_dns_result_is_denied_without_proxy_opt_in() {
let ip = "198.18.0.1".parse().unwrap();
let err = validate_dns_resolved_ip("github.com", &ip, None, "fetch_url")
.expect_err("fake-IP DNS result must be denied by default");
assert!(format!("{err}").contains("resolved IP 198.18.0.1 is a restricted address"));
}
#[test]
fn proxy_host_and_fakeip_cidr_allow_matching_placeholder() {
use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
let policy = NetworkPolicy {
default: Decision::Allow.into(),
allow: Vec::new(),
deny: Vec::new(),
proxy: vec!["github.com".to_string()],
proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
audit: false,
};
let decider = NetworkPolicyDecider::new(policy, None);
let ip = "198.18.0.1".parse().unwrap();
validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url")
.expect("matching host and fake-IP CIDR should allow the placeholder");
}
#[test]
fn proxy_host_without_fakeip_cidr_does_not_allow_restricted_dns() {
use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
let policy = NetworkPolicy {
default: Decision::Allow.into(),
allow: Vec::new(),
deny: Vec::new(),
proxy: vec!["github.com".to_string()],
proxy_fake_ip_cidrs: Vec::new(),
audit: false,
};
let decider = NetworkPolicyDecider::new(policy, None);
let ip = "198.18.0.1".parse().unwrap();
validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url")
.expect_err("hostname trust alone must not allow a restricted address");
}
#[test]
fn fakeip_cidr_without_proxy_host_does_not_allow_restricted_dns() {
use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
let policy = NetworkPolicy {
default: Decision::Allow.into(),
allow: Vec::new(),
deny: Vec::new(),
proxy: Vec::new(),
proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
audit: false,
};
let decider = NetworkPolicyDecider::new(policy, None);
let ip = "198.18.0.1".parse().unwrap();
validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url")
.expect_err("fake-IP CIDR alone must not allow an untrusted hostname");
}
#[test]
fn proxy_host_never_exempts_real_private_or_local_addresses() {
use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
let policy = NetworkPolicy {
default: Decision::Allow.into(),
allow: Vec::new(),
deny: Vec::new(),
proxy: vec!["github.com".to_string()],
proxy_fake_ip_cidrs: vec![
"198.18.0.0/15".to_string(),
"127.0.0.0/8".to_string(),
"10.0.0.0/8".to_string(),
"169.254.0.0/16".to_string(),
],
audit: false,
};
let decider = NetworkPolicyDecider::new(policy, None);
for ip in [
"127.0.0.1",
"10.0.0.1",
"192.168.1.1",
"169.254.169.254",
"fc00::1",
] {
let ip = ip.parse().unwrap();
assert!(
validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url").is_err(),
"{ip} must remain restricted"
);
}
}
#[test]
fn proxy_opt_in_does_not_allow_unlisted_host() {
use crate::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
let policy = NetworkPolicy {
default: Decision::Allow.into(),
allow: Vec::new(),
deny: Vec::new(),
proxy: vec!["github.com".to_string()],
proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
audit: false,
};
let decider = NetworkPolicyDecider::new(policy, None);
let ip = "198.18.0.1".parse().unwrap();
let err = validate_dns_resolved_ip("example.com", &ip, Some(&decider), "fetch_url")
.expect_err("proxy opt-in must be scoped to configured hosts");
assert!(format!("{err}").contains("resolved IP 198.18.0.1 is a restricted address"));
}
#[test]
fn proxy_dns_allow_is_audited() {
use crate::network_policy::{
Decision, NetworkAuditor, NetworkPolicy, NetworkPolicyDecider,
};
use tempfile::tempdir;
let dir = tempdir().expect("tempdir");
let auditor = NetworkAuditor::new(dir.path().join("audit.log"), true);
let policy = NetworkPolicy {
default: Decision::Allow.into(),
allow: Vec::new(),
deny: Vec::new(),
proxy: vec!["github.com".to_string()],
proxy_fake_ip_cidrs: vec!["198.18.0.0/15".to_string()],
audit: true,
};
let decider = NetworkPolicyDecider::new(policy, Some(auditor));
let ip = "198.18.0.1".parse().unwrap();
validate_dns_resolved_ip("github.com", &ip, Some(&decider), "fetch_url")
.expect("proxy DNS allow");
let body = std::fs::read_to_string(dir.path().join("audit.log")).expect("audit log");
assert!(body.contains("github.com"));
assert!(body.contains("TrustedProxyFakeIp-Allow"));
}
}
@@ -26,7 +26,7 @@ use std::path::{Path, PathBuf};
/// Maximum size accepted for any single Claude source file. Generous for
/// real `~/.claude.json` files with many projects; bounded so a pathological
/// file cannot exhaust memory. Matches the bundle reader's order of magnitude.
pub(crate) const MAX_SOURCE_BYTES: u64 = 5 * 1024 * 1024;
pub const MAX_SOURCE_BYTES: u64 = 5 * 1024 * 1024;
/// Env keys safe to propose for the portable bundle (lowercase). Deliberately
/// a short allowlist: timeouts, output caps, and editor/telemetry choices.
@@ -66,7 +66,7 @@ fn read_bounded_json(path: &Path) -> Result<Value, CommandSourceStatus> {
}
/// Read the Claude sources under `home` (usually `~`).
pub(crate) fn read_sources(
pub fn read_sources(
home: &Path,
) -> (
Vec<(PathBuf, CommandSourceStatus)>,
@@ -109,7 +109,7 @@ fn safe_env_value(value: &Value) -> Option<String> {
/// Build the plan from already-read sources plus MCP candidates discovered by
/// the existing external-import machinery (so server parsing stays single-sourced).
pub(crate) fn build_plan(
pub fn build_plan(
sources: Vec<(PathBuf, CommandSourceStatus)>,
claude: Option<Value>,
settings: Option<Value>,
+4
View File
@@ -0,0 +1,4 @@
pub mod guard;
pub mod import_claude;
pub mod network_policy;
pub mod proxy;
@@ -1,8 +1,3 @@
// Several public helpers in this module are exposed for future slash-command
// wiring (`/network allow <host>`, `/network deny <host>`) and for the
// approval-modal hook that v0.7.x adds incrementally. Dead-code warnings
// would otherwise be noisy until those call sites land.
#![allow(dead_code)]
// Audit-write failure must route through `tracing::*`, not raw stderr —
// see `runtime_log` for the scroll-demon rationale.
#![deny(clippy::print_stdout)]
@@ -443,7 +438,7 @@ impl NetworkAuditor {
))
}
/// Append one line. Best-effort: errors are logged via `eprintln!` but
/// Append one line. Best-effort: errors are logged via `tracing` but
/// never bubble back to the caller.
pub fn record(&self, host: &str, tool: &str, decision_label: &str) {
if !self.enabled {
+190
View File
@@ -0,0 +1,190 @@
use anyhow::Result;
/// Redact the userinfo segment (`username[:password]@…` portion) from
/// a proxy URL so it can be safely included in `tracing::warn!` output
/// without leaking the
/// password into the on-disk log. URLs without userinfo are returned
/// unchanged. Garbage input (no `://` scheme separator) is also returned
/// unchanged — the malformed-URL warning path is the only caller, so an
/// unparseable input is already the failure case.
fn redact_proxy_userinfo(proxy_url: &str) -> String {
let Some(scheme_end) = proxy_url.find("://") else {
return proxy_url.to_string();
};
let after_scheme = scheme_end + 3;
// The userinfo segment ends at the next `@`, but only if that `@`
// comes before the next `/`, `?`, or `#` (otherwise the `@` is in a
// path / query and the URL has no userinfo at all).
let rest = &proxy_url[after_scheme..];
let at_idx = rest.find('@');
let path_idx = rest.find(['/', '?', '#']);
let userinfo_end = match (at_idx, path_idx) {
(Some(a), Some(p)) if a < p => Some(a),
(Some(a), None) => Some(a),
_ => None,
};
if let Some(end) = userinfo_end {
let mut out = String::with_capacity(proxy_url.len());
out.push_str(&proxy_url[..after_scheme]);
out.push_str("***@");
out.push_str(&rest[end + 1..]);
out
} else {
proxy_url.to_string()
}
}
/// Resolve the operator's proxy route for this exact request using the same
/// matcher as reqwest. A NO_PROXY match returns None: direct requests must keep
/// their public DNS validation and pins. Model and reviewed-plugin requests
/// return before even reading proxy credentials.
pub fn configured_mcp_proxy<F>(
url: &reqwest::Url,
disallow_ambient_proxy: bool,
mut read_environment: F,
) -> Result<Option<reqwest::Proxy>>
where
F: FnMut(&str) -> std::result::Result<String, std::env::VarError>,
{
if disallow_ambient_proxy {
return Ok(None);
}
let proxy_url = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]
.into_iter()
.find_map(|key| {
read_environment(key)
.ok()
.filter(|value| !value.trim().is_empty())
});
let Some(proxy_url) = proxy_url else {
return Ok(None);
};
// Normalize userinfo and Unicode with the URL parser before passing the
// URL to reqwest's own underlying matcher. Keep its missing-scheme support.
let normalized = reqwest::Url::parse(&proxy_url)
.ok()
.filter(|url| url.has_host())
.or_else(|| reqwest::Url::parse(&format!("http://{proxy_url}")).ok());
let Some(normalized) = normalized else {
tracing::warn!(target: "mcp", proxy = %redact_proxy_userinfo(&proxy_url), "ignoring malformed HTTP(S)_PROXY URL");
return Ok(None);
};
let no_proxy = read_environment("NO_PROXY")
.or_else(|_| read_environment("no_proxy"))
.unwrap_or_default()
.replace(char::is_whitespace, ",");
if no_proxy.split(',').any(|entry| entry == "*") {
return Ok(None);
}
let matcher = hyper_util::client::proxy::matcher::Matcher::builder()
.all(normalized.as_str())
.no(no_proxy)
.build();
let destination: http::Uri = url.as_str().parse()?;
let Some(route) = matcher.intercept(&destination) else {
return Ok(None);
};
// Build the actual proxy from the matched route itself so the decision
// that grants delegated DNS authority cannot diverge from the transport.
let route_uri = route.uri().to_string();
let route_uri = match route.uri().scheme_str() {
Some("socks5") => route_uri.replacen("socks5://", "socks5h://", 1),
Some("socks4") => route_uri.replacen("socks4://", "socks4a://", 1),
_ => route_uri,
};
let mut proxy = reqwest::Proxy::all(route_uri)?;
if let Some(auth) = route.basic_auth() {
proxy = proxy.custom_http_auth(auth.clone());
}
if let Some((user, password)) = route.raw_auth() {
proxy = proxy.basic_auth(user, password);
}
Ok(Some(proxy))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reviewed_plugin_remote_proxy_policy_never_reads_ambient_environment() {
let reads = std::cell::Cell::new(0_u32);
let proxy = configured_mcp_proxy(
&reqwest::Url::parse("https://example.com/mcp").unwrap(),
true,
|_| {
reads.set(reads.get() + 1);
Ok("http://127.0.0.1:9999".to_string())
},
);
assert_eq!(
reads.get(),
0,
"reviewed remotes must not read proxy values"
);
assert!(proxy.unwrap().is_none());
}
#[test]
fn user_authored_mcp_proxy_policy_keeps_environment_support() {
let requested = std::cell::RefCell::new(Vec::new());
let proxy = configured_mcp_proxy(
&reqwest::Url::parse("https://example.com/mcp").unwrap(),
false,
|name| {
requested.borrow_mut().push(name.to_string());
match name {
"HTTPS_PROXY" => Ok("http://127.0.0.1:8080".to_string()),
_ => Err(std::env::VarError::NotPresent),
}
},
);
assert_eq!(
requested.into_inner(),
vec![
"HTTPS_PROXY".to_string(),
"NO_PROXY".to_string(),
"no_proxy".to_string(),
]
);
assert!(proxy.unwrap().is_some());
}
#[test]
fn redact_proxy_userinfo_strips_password() {
// Corporate-style proxy URL with embedded creds — the
// password must never reach the on-disk log file. URL strings
// are assembled from placeholder constants via `format!` so the
// literal source never contains a scheme-prefixed username +
// password pair (colon-separated, `@`-terminated) that
// GitGuardian's "Basic Auth String" detector would flag as a
// committed credential.
let (placeholder_user, placeholder_pass) = ("PLACEHOLDER_USER", "PLACEHOLDER_PASS");
let with_creds = format!("http://{placeholder_user}:{placeholder_pass}@proxy.example/");
let redacted = redact_proxy_userinfo(&with_creds);
assert_eq!(redacted, "http://***@proxy.example/");
assert!(!redacted.contains(placeholder_pass));
assert!(!redacted.contains(placeholder_user));
// User only (no password) — still redacted.
let with_user_only = format!("https://{placeholder_user}@proxy.example:8080");
let redacted = redact_proxy_userinfo(&with_user_only);
assert_eq!(redacted, "https://***@proxy.example:8080");
// No userinfo segment — pass through.
let redacted = redact_proxy_userinfo("http://proxy.example:3128/");
assert_eq!(redacted, "http://proxy.example:3128/");
// `@` appears only in the path, not as userinfo separator —
// must not be mistaken for credentials.
let redacted = redact_proxy_userinfo("http://proxy.example/path@thing");
assert_eq!(redacted, "http://proxy.example/path@thing");
// Garbage input (no `://`) returned unchanged — the
// surrounding warning log is the only caller and is already
// handling the malformed-URL case.
assert_eq!(redact_proxy_userinfo("not-a-url"), "not-a-url");
}
}
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "codewhale-notify"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Shared Codewhale notification policy and local audio dispatch"
[lints]
workspace = true
[features]
test-support = []
test-thread-scoped-state = ["test-support"]
[dependencies]
codewhale-config = { path = "../config", version = "0.10.2" }
codewhale-secrets = { path = "../secrets", version = "0.10.2" }
regex = "1.11"
tempfile.workspace = true
tracing.workspace = true
[dev-dependencies]
codewhale-runtime = { path = "../runtime", version = "0.10.2", features = ["test-support"] }
[target.'cfg(windows)'.dependencies]
dirs.workspace = true
windows = { version = "0.62", features = ["Win32_Media_Audio", "Win32_System_Diagnostics_Debug", "Win32_UI_WindowsAndMessaging"] }
@@ -27,7 +27,7 @@ pub fn emit_terminal(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
}
}
pub const WHALE_WAV: &[u8] = include_bytes!("../../assets/audio/codewhale-whale-call.wav");
pub const WHALE_WAV: &[u8] = include_bytes!("../assets/audio/codewhale-whale-call.wav");
/// Resolve an external system helper (the audio players `aplay` and the
/// pet's `ffplay`, the Linux browser launcher `xdg-open`) from fixed install
@@ -39,7 +39,7 @@ pub const WHALE_WAV: &[u8] = include_bytes!("../../assets/audio/codewhale-whale-
/// custom `~/bin`, a version manager shim) is refused and the caller reports
/// the feature as unavailable. That is deliberate — there is no `PATH`
/// fallback.
pub(crate) fn trusted_system_executable(name: &str) -> io::Result<PathBuf> {
pub fn trusted_system_executable(name: &str) -> io::Result<PathBuf> {
trusted_player_in(name, &trusted_player_dirs())
}
@@ -110,7 +110,7 @@ fn is_executable_file(path: &Path) -> bool {
}
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
pub fn dispatch(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
#[cfg(target_os = "windows")]
if matches!(cue, SoundCue::Bell | SoundCue::Beep | SoundCue::DoubleBell) {
@@ -130,16 +130,16 @@ pub fn dispatch(cue: &SoundCue, out: &mut dyn Write) -> AudioOutcome {
dispatch_wav(cue)
}
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
pub fn dispatch(_cue: &SoundCue, _out: &mut dyn Write) -> AudioOutcome {
// Production entry points are also fail-closed in library tests.
// Production entry points are also fail-closed in library and scoped debug tests.
AudioOutcome::Unsupported
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
static PLAYING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
fn dispatch_wav(cue: &SoundCue) -> AudioOutcome {
use std::sync::atomic::Ordering;
if !cfg!(any(
@@ -179,7 +179,7 @@ fn dispatch_wav(cue: &SoundCue) -> AudioOutcome {
}
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
fn play_wav(cue: &SoundCue) -> io::Result<()> {
let mut bundled = None;
let path = match cue {
@@ -203,7 +203,10 @@ fn play_wav(cue: &SoundCue) -> io::Result<()> {
result
}
#[cfg(all(not(test), target_os = "windows"))]
#[cfg(all(
not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))),
target_os = "windows"
))]
fn play_file(path: &std::path::Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
use windows::Win32::Media::Audio::{PlaySoundW, SND_FILENAME, SND_NODEFAULT};
@@ -217,7 +220,10 @@ fn play_file(path: &std::path::Path) -> io::Result<()> {
}
}
#[cfg(all(not(test), any(target_os = "macos", target_os = "linux")))]
#[cfg(all(
not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))),
any(target_os = "macos", target_os = "linux")
))]
fn play_file(path: &std::path::Path) -> io::Result<()> {
#[cfg(target_os = "macos")]
let player = PathBuf::from("/usr/bin/afplay");
@@ -237,7 +243,7 @@ fn play_file(path: &std::path::Path) -> io::Result<()> {
}
#[cfg(all(
not(test),
not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))),
not(any(target_os = "windows", target_os = "macos", target_os = "linux"))
))]
fn play_file(_path: &std::path::Path) -> io::Result<()> {
@@ -16,7 +16,7 @@ use super::{DeliveryOutcome, Method, NotificationGate};
/// - `$TERM` contains `kitty` → `Kitty`
/// - Unknown terminal → `Off` (never invent an audible fallback)
#[must_use]
pub(crate) fn resolve_method() -> Method {
pub fn resolve_method() -> Method {
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
match term_program.as_str() {
"iTerm.app" | "WezTerm" | "Cmux" => return Method::Osc9,
@@ -108,7 +108,7 @@ fn build_escape(method: Method, in_tmux: bool, msg: &str) -> Vec<u8> {
/// All side effects sit behind injected sinks. A disallowed event reaches none.
#[allow(clippy::too_many_arguments)]
pub(crate) fn notify_with_sinks(
pub fn notify_with_sinks(
method: Method,
in_tmux: bool,
payload: &NotificationPayload,
@@ -117,18 +117,15 @@ pub(crate) fn notify_with_sinks(
gate: NotificationGate,
attention_allowed: bool,
sink: &mut dyn Write,
decide_sound: &mut dyn FnMut(
NotificationKind,
bool,
) -> crate::notify::sound_policy::SoundDecision,
decide_sound: &mut dyn FnMut(NotificationKind, bool) -> crate::sound_policy::SoundDecision,
audio: &mut dyn FnMut(
&crate::notify::sound_policy::SoundCue,
&crate::sound_policy::SoundCue,
&mut dyn Write,
) -> crate::notify::audio::AudioOutcome,
) -> crate::audio::AudioOutcome,
native: &mut dyn FnMut(&NotificationPayload) -> DeliveryOutcome,
) -> DeliveryOutcome {
use crate::notify::audio::AudioOutcome;
use crate::notify::sound_policy::SoundDecision;
use crate::audio::AudioOutcome;
use crate::sound_policy::SoundDecision;
if !attention_allowed {
return DeliveryOutcome::SuppressedByAttention;
}
@@ -2,7 +2,7 @@
//! delivery-method and category gate, attention (focus) rules, the
//! per-event sound policy and audio cues.
//!
//! Runtime code owns *what* may notify and *which* sound it selects. The
//! This crate owns *what* may notify and *which* sound it selects. The
//! terminal UI owns the *terminal*: `tui::notifications` is the only code that
//! hands a real terminal to [`delivery::notify_with_sinks`], writes the taskbar
//! progress sequence or the window title. Runtime callers that need a delivery
@@ -199,7 +199,7 @@ impl NotificationGate {
const ELEVATION_NEEDED_BIT: u8 = 1 << 5;
const MODEL_NOTIFY_BIT: u8 = 1 << 6;
pub(crate) const fn to_bits(self) -> u8 {
pub const fn to_bits(self) -> u8 {
(self.quiet as u8 * Self::QUIET_BIT)
| (self.turn_complete as u8 * Self::TURN_COMPLETE_BIT)
| (self.subagent_terminal as u8 * Self::SUBAGENT_TERMINAL_BIT)
@@ -209,7 +209,7 @@ impl NotificationGate {
| (self.model_notify as u8 * Self::MODEL_NOTIFY_BIT)
}
pub(crate) const fn from_bits(bits: u8) -> Self {
pub const fn from_bits(bits: u8) -> Self {
Self {
quiet: bits & Self::QUIET_BIT != 0,
turn_complete: bits & Self::TURN_COMPLETE_BIT != 0,
@@ -228,15 +228,15 @@ impl NotificationGate {
/// until the terminal explicitly reports `FocusLost`, so the safe startup
/// behavior is silence rather than an unexpected banner or bell.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AttentionCondition {
pub enum AttentionCondition {
Always = 0,
Unfocused = 1,
Never = 2,
}
pub(crate) const DEFAULT_UNFOCUSED_GRACE: Duration = Duration::from_secs(2);
pub const DEFAULT_UNFOCUSED_GRACE: Duration = Duration::from_secs(2);
#[must_use]
pub(crate) fn attention_delivery_allowed_at(
pub fn attention_delivery_allowed_at(
condition: AttentionCondition,
focused: bool,
unfocused_since_ms: u64,
@@ -256,7 +256,7 @@ pub(crate) fn attention_delivery_allowed_at(
/// Native hosts provide focus observations; the same grace/condition rule
/// applies before either native sound or banner preparation.
pub(crate) fn native_attention_allowed(
pub fn native_attention_allowed(
config: &NotificationsConfig,
focused: bool,
unfocused_for: Duration,
@@ -214,14 +214,6 @@ impl NotificationPayload {
}
/// Bounded, sanitized headline. Never empty.
///
/// Only the macOS path reads this today — `display notification` is the
/// one backend that takes a separate subtitle, while the escape-sequence
/// backends send a single string via [`Self::body`]. Tests exercise it on
/// every platform, but `#[cfg(test)]` uses do not keep it alive in a
/// non-macOS release build, so the allow is scoped to exactly that case
/// rather than blanket-silencing dead_code on the accessor.
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
#[must_use]
pub fn headline(&self) -> &str {
&self.headline
@@ -199,7 +199,7 @@ pub fn decide_configured(
.unwrap_or(SoundDecision::Suppress(SuppressReason::Disabled))
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn configure(policy: EventSoundPolicy) {
*policy_cell().write().unwrap() = policy;
}
@@ -314,7 +314,7 @@ mod tests {
#[test]
fn settings_refresh_preserves_repeat_history() {
let _lock = crate::test_support::lock_test_env();
let _lock = codewhale_runtime::test_support::lock_test_env();
let config = NotificationsConfig {
sound: Some(CompletionSound::Whale),
..Default::default()
@@ -334,7 +334,7 @@ mod tests {
#[test]
fn configured_off_decision_ignores_intervening_installed_whale_policy() {
let _lock = crate::test_support::lock_test_env();
let _lock = codewhale_runtime::test_support::lock_test_env();
let off = NotificationsConfig {
sound: Some(CompletionSound::Off),
..Default::default()
@@ -367,7 +367,7 @@ mod tests {
#[test]
fn configured_sound_keeps_installed_defaults_and_suppression_keeps_history() {
let _lock = crate::test_support::lock_test_env();
let _lock = codewhale_runtime::test_support::lock_test_env();
let off = NotificationsConfig {
sound: Some(CompletionSound::Off),
..Default::default()
@@ -412,7 +412,7 @@ mod tests {
#[test]
fn concurrent_configured_decisions_share_history_with_settings_refresh() {
let _lock = crate::test_support::lock_test_env();
let _lock = codewhale_runtime::test_support::lock_test_env();
configure(EventSoundPolicy::default());
let whale = NotificationsConfig {
sound: Some(CompletionSound::Whale),
+3
View File
@@ -752,6 +752,8 @@ pub enum EventMsg {
messages: Vec<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
system_prompt: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
instruction_assembly: Option<Value>,
model: String,
workspace: PathBuf,
},
@@ -1496,6 +1498,7 @@ mod tests {
engine_session_id: "sess".into(),
messages: vec![json!({"role": "user", "content": []})],
system_prompt: Some(json!("sys")),
instruction_assembly: None,
model: "m".into(),
workspace: PathBuf::from("/ws"),
},
+55
View File
@@ -174,6 +174,31 @@ pub struct TurnSpec {
pub verbosity: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewOperationIntent {
pub title: String,
pub description: String,
pub commits: String,
pub linked_issue: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReviewOperation {
pub version: u32,
pub repository: String,
pub pr_number: u64,
pub head_sha: String,
pub base_sha: String,
pub diff: String,
pub intent: ReviewOperationIntent,
pub guidance: String,
pub max_claims: usize,
pub max_nits: usize,
pub max_chars: usize,
}
/// Operations that can be submitted to the core engine. This is the
/// protocol view of `crates/tui/src/core/ops::Op` — same lifecycle,
/// same provenance gate — but serializable and free of `mpsc` / `oneshot`
@@ -182,6 +207,10 @@ pub struct TurnSpec {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Op {
Review {
turn: TurnSpec,
request: ReviewOperation,
},
/// Drive one model turn: `role=user` content plus the resolved route
/// receipt the engine will freeze at the client-freeze boundary. Headless
/// and TUI must produce byte-identical `MessageRequest`s for identical
@@ -450,6 +479,7 @@ fn default_approval_mode() -> String {
/// Every wire tag `Op` can carry, in declaration order.
pub const OP_KINDS: &[&str] = &[
"review",
"send_message",
"steer",
"continue_goal",
@@ -496,6 +526,7 @@ impl Op {
#[must_use]
pub fn kind_str(&self) -> &'static str {
match self {
Self::Review { .. } => "review",
Self::SendMessage(_) => "send_message",
Self::Steer { .. } => "steer",
Self::ContinueGoal { .. } => "continue_goal",
@@ -593,7 +624,31 @@ mod tests {
/// One instance of every variant, in declaration order.
fn every_variant() -> Vec<Op> {
let Op::SendMessage(turn) = headless_send_message_op(ThreadId::new(), "review").op else {
unreachable!()
};
vec![
Op::Review {
turn,
request: ReviewOperation {
version: 1,
repository: "owner/repo".into(),
pr_number: 1,
head_sha: "a".repeat(40),
base_sha: "b".repeat(40),
diff: "diff".into(),
intent: ReviewOperationIntent {
title: String::new(),
description: String::new(),
commits: String::new(),
linked_issue: String::new(),
},
guidance: String::new(),
max_claims: 40,
max_nits: 3,
max_chars: 60_000,
},
},
headless_send_message_op(ThreadId::new(), "hello").op,
Op::Steer {
content: "more".into(),
+3
View File
@@ -80,6 +80,8 @@ pub struct RuntimeCapabilities {
/// Read-only exact accepted-turn lookup by thread and operation key.
#[serde(default)]
pub turn_operation_lookup: bool,
#[serde(default)]
pub structured_review_turns: bool,
/// Automation patch/run/pause/resume honor `expected_revision` (409
/// `revision_conflict` carrying `current_revision`); create/patch/run/pause/
/// resume honor a bounded, persisted `Idempotency-Key`. Delete honors
@@ -460,6 +462,7 @@ mod tests {
turns: true,
turn_operation_idempotency: true,
turn_operation_lookup: true,
structured_review_turns: true,
automation_mutation_preconditions: true,
turn_image_inputs: true,
turn_steer: true,
+33
View File
@@ -95,6 +95,16 @@ impl InstallMethod {
if path_method != Self::Binary {
return path_method;
}
if exe.parent().is_some_and(|bin| {
bin.file_name().is_some_and(|name| name == "bin")
&& bin.parent().is_some_and(|root| {
[".crates.toml", ".crates2.json"]
.iter()
.any(|name| std::fs::symlink_metadata(root.join(name)).is_ok())
})
}) {
return Self::Cargo;
}
if owns_package(exe) {
Self::Omarchy
} else {
@@ -162,6 +172,21 @@ impl InstallMethod {
}
}
#[must_use]
pub fn uninstall_command(self) -> &'static str {
match self {
Self::Npm => "npm uninstall -g codewhale",
Self::Homebrew => {
"brew uninstall codewhale (use the owning formula name for a legacy formula)"
}
Self::Cargo => "cargo uninstall codewhale-cli --root <the owning Cargo install root>",
Self::Omarchy => {
"pacman -Qo <the executable path>, then remove that package with your package manager"
}
Self::Binary => "codewhale uninstall --yes",
}
}
/// Whether `codewhale update` may replace this binary in place.
///
/// False for every package-managed install: see the module docs for why
@@ -295,6 +320,14 @@ mod tests {
InstallMethod::Binary
);
assert!(!InstallMethod::Cargo.supports_self_update());
let root = tempfile::tempdir().unwrap();
let bin = root.path().join("bin");
std::fs::create_dir(&bin).unwrap();
std::fs::write(root.path().join(".crates2.json"), b"{}").unwrap();
assert_eq!(
InstallMethod::detect(&bin.join("codewhale")),
InstallMethod::Cargo
);
}
#[test]
+26 -7
View File
@@ -12,19 +12,19 @@ workspace = true
# Test helpers (process-global state guards, snapshots, diff renders) that
# the TUI's tests use. It adds `pub` items, and the side-effect trap that
# counts external credential reads; it changes no other behavior.
# counts external credential reads; it also exposes the existing shared test fixtures.
# The TUI enables it from [dev-dependencies] only, so `-p codewhale-cli` /
# `-p codewhale-tui` release builds never see it; a `--workspace
# --all-features` build does, which is harmless for helpers.
#
# `test-thread-scoped-state` is the one behavior switch: it makes the retry
# banner and the provider-wide `Retry-After` pause per-thread so concurrent
# tests cannot see each other's HTTP retries (see `retry_status`). It takes
# banner, provider-wide `Retry-After` pause, and configuration state paths
# use the existing sealed test scope. It takes
# effect only with debug assertions, so no optimized build ever carries it,
# even under `--all-features`.
[features]
test-support = ["dep:tempfile"]
test-thread-scoped-state = []
test-support = ["dep:ring"]
test-thread-scoped-state = ["test-support"]
[lib]
name = "codewhale_runtime"
@@ -34,6 +34,19 @@ path = "src/lib.rs"
# crossterm, ansi-to-tui or a terminal component kit.
# scripts/check-command-crate-boundaries.py enforces that with `cargo tree -p`.
[dependencies]
base64 = "0.23.1"
codewhale-cloud-facts = { path = "../cloud-facts", version = "0.10.2" }
codewhale-localization = { path = "../localization", version = "0.10.2" }
codewhale-protocol = { path = "../protocol", version = "0.10.2" }
codewhale-release = { path = "../release", version = "0.10.2" }
codewhale-paths = { path = "../paths", version = "0.10.2" }
dirs.workspace = true
jsonwebtoken = { version = "10", default-features = false, features = ["aws_lc_rs"] }
parking_lot = "0.12"
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2"] }
ring = { version = "0.17", optional = true }
shellexpand = "3"
toml_edit.workspace = true
ahash = "0.8"
anyhow.workspace = true
chrono.workspace = true
@@ -53,7 +66,7 @@ regex = "1.11"
serde.workspace = true
serde_json = { workspace = true, features = ["preserve_order", "raw_value"] }
sha2.workspace = true
tempfile = { workspace = true, optional = true }
tempfile.workspace = true
thiserror.workspace = true
tokio.workspace = true
tokio-util = { version = "0.7.16", features = ["io"] }
@@ -63,7 +76,9 @@ uuid.workspace = true
wait-timeout = "0.2"
[dev-dependencies]
codewhale-paths = { path = "../paths", version = "0.10.2" }
futures-util = "0.3"
ring = "0.17"
wiremock = "0.6"
codewhale-models = { path = "../models", version = "0.10.2", features = [
"test-support",
] }
@@ -78,3 +93,7 @@ windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security",
[target.'cfg(not(target_env = "ohos"))'.dependencies]
portable-pty = "0.9"
[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies]
# JWT verification uses upstream assembly objects when NASM is unavailable.
aws-lc-rs = { version = "1.18.1", default-features = false, features = ["prebuilt-nasm"] }
+38
View File
@@ -0,0 +1,38 @@
use std::path::PathBuf;
/// Filename under the CodeWhale `catalog` state dir.
pub const CACHE_FILE: &str = "models-dev-catalog.json";
/// Resolve the on-disk cache path under the CodeWhale `catalog` state dir.
///
/// Under `cfg(test)` this is confined the same way settings and config paths
/// are: `resolve_state_dir` lives in `codewhale-config`, which is compiled as a
/// plain dependency here and so has no view of this crate's isolated test root.
/// Without this shield a test that never asked for the developer's catalog read
/// their real `~/.codewhale/catalog` and rendered against whatever models they
/// last fetched (#5359).
#[must_use]
pub fn cache_path() -> Option<PathBuf> {
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
if !crate::test_support::guarded_environment_provides_state_paths() {
return Some(
crate::test_support::unsealed_test_state_root()
.join("catalog")
.join(CACHE_FILE),
);
}
}
codewhale_config::resolve_state_dir("catalog")
.ok()
.map(|dir| dir.join(CACHE_FILE))
}
pub fn valid_catalog_model_id(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 256
&& value.bytes().any(|byte| byte.is_ascii_alphanumeric())
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'/' | b'-' | b'~')
})
}
+413
View File
@@ -0,0 +1,413 @@
use crate::config::Config;
use chrono::{DateTime, Duration, Utc};
use codewhale_config::ProviderKind;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Read;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::SystemTime;
pub const MAX_MODEL_CACHE_BYTES: u64 = 4 * 1024 * 1024;
const MODEL_CACHE_MAX_AGE: Duration = Duration::hours(24);
const MAX_FUTURE_CLOCK_SKEW: Duration = Duration::minutes(5);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexModelCacheFreshness {
Fresh,
Missing,
Stale,
Invalid,
}
impl CodexModelCacheFreshness {
#[must_use]
pub const fn picker_label(self) -> &'static str {
match self {
Self::Fresh => "ChatGPT OAuth",
Self::Missing => "OAuth roster missing",
Self::Stale => "OAuth roster stale",
Self::Invalid => "OAuth roster invalid",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CodexModelRoster {
pub models: Vec<CodexModelMetadata>,
pub freshness: CodexModelCacheFreshness,
pub fetched_at: Option<DateTime<Utc>>,
pub observed_at: Option<DateTime<Utc>>,
pub source: &'static str,
pub observation_persisted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodexModelMetadata {
pub id: String,
#[serde(default)]
pub display_name: Option<String>,
pub context_window: Option<u32>,
pub reasoning: Option<bool>,
pub efforts: Vec<String>,
}
impl CodexModelRoster {
fn fallback(freshness: CodexModelCacheFreshness, fetched_at: Option<DateTime<Utc>>) -> Self {
Self {
models: Vec::new(),
freshness,
fetched_at,
observed_at: None,
source: "chatgpt_plan_api",
observation_persisted: false,
}
}
#[must_use]
pub fn model_ids(&self) -> Vec<String> {
self.models.iter().map(|model| model.id.clone()).collect()
}
#[must_use]
pub fn metadata_for(&self, id: &str) -> Option<&CodexModelMetadata> {
self.models
.iter()
.find(|model| model.id.eq_ignore_ascii_case(id.trim()))
}
#[must_use]
pub fn preferred_model_id(&self) -> Option<&str> {
(self.freshness == CodexModelCacheFreshness::Fresh)
.then(|| self.models.first().map(|model| model.id.as_str()))
.flatten()
}
}
#[derive(Serialize, Deserialize)]
pub struct CatalogSnapshot {
pub fetched_at: DateTime<Utc>,
pub models: Vec<CodexModelMetadata>,
}
type RosterCacheKey = (PathBuf, Option<SystemTime>, u64);
static ROSTER_MEMO: Mutex<Option<(RosterCacheKey, CodexModelRoster)>> = Mutex::new(None);
/// An unscoped completion/catalog cannot borrow another account's roster.
#[must_use]
pub fn model_roster() -> CodexModelRoster {
CodexModelRoster::fallback(CodexModelCacheFreshness::Missing, None)
}
pub fn cached_roster_for(config: &Config) -> CodexModelRoster {
let Some(path) = snapshot_path(config) else {
return model_roster();
};
let key = match std::fs::symlink_metadata(&path) {
Ok(metadata) if !metadata.file_type().is_file() => {
return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None);
}
Ok(metadata) => (path.clone(), metadata.modified().ok(), metadata.len()),
Err(_) => (path.clone(), None, 0),
};
let now = Utc::now();
if let Ok(memo) = ROSTER_MEMO.lock()
&& let Some((cached_key, roster)) = memo.as_ref()
&& *cached_key == key
&& roster.freshness == CodexModelCacheFreshness::Fresh
&& roster
.fetched_at
.is_some_and(|fetched| now.signed_duration_since(fetched) <= MODEL_CACHE_MAX_AGE)
{
return roster.clone();
}
let roster = load_snapshot(&path, now);
if let Ok(mut memo) = ROSTER_MEMO.lock() {
*memo = Some((key, roster.clone()));
}
roster
}
fn registration_key(issuer: &str, client_id: &str, subject: &str) -> String {
let mut identity = Sha256::new();
identity.update(b"codewhale-chatgpt-plan-roster-v1\0");
for value in [issuer, client_id, subject] {
identity.update((value.len() as u64).to_le_bytes());
identity.update(value.as_bytes());
}
identity
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub fn snapshot_path(config: &Config) -> Option<PathBuf> {
let identity = config
.builtin_provider_identity(ProviderKind::OpenaiCodex)
.ok()?;
if config.provider_uses_custom_endpoint(&identity) {
return None;
}
let registration = crate::oauth::official_chatgpt_registration(config).ok()?;
let catalog_path = crate::catalog_cache::cache_path()?;
Some(catalog_path.parent()?.join(format!(
"chatgpt-plan-{}.json",
registration_key(
&registration.issuer,
&registration.client_id,
&registration.subject
)
)))
}
pub fn load_snapshot(path: &Path, now: DateTime<Utc>) -> CodexModelRoster {
let bytes = match read_cache_bytes(path) {
Ok(bytes) => bytes,
Err(freshness) => return CodexModelRoster::fallback(freshness, None),
};
let snapshot: CatalogSnapshot = match serde_json::from_slice(&bytes) {
Ok(snapshot) => snapshot,
Err(_) => return CodexModelRoster::fallback(CodexModelCacheFreshness::Invalid, None),
};
let age = now.signed_duration_since(snapshot.fetched_at);
if age < -MAX_FUTURE_CLOCK_SKEW
|| snapshot.models.iter().any(|model| {
!crate::catalog_cache::valid_catalog_model_id(&model.id)
|| model
.display_name
.as_ref()
.is_some_and(|name| name.len() > 512 || name.chars().any(char::is_control))
|| model.efforts.len() > 16
|| model.efforts.iter().any(|effort| !valid_effort(effort))
|| model
.context_window
.is_some_and(|window| !(1..=16_000_000).contains(&window))
})
{
return CodexModelRoster::fallback(
CodexModelCacheFreshness::Invalid,
Some(snapshot.fetched_at),
);
}
if age > MODEL_CACHE_MAX_AGE {
return CodexModelRoster {
models: snapshot.models,
freshness: CodexModelCacheFreshness::Stale,
fetched_at: Some(snapshot.fetched_at),
observed_at: None,
source: "chatgpt_plan_api",
observation_persisted: true,
};
}
CodexModelRoster {
models: snapshot.models,
freshness: CodexModelCacheFreshness::Fresh,
fetched_at: Some(snapshot.fetched_at),
observed_at: None,
source: "chatgpt_plan_api",
observation_persisted: true,
}
}
fn valid_effort(effort: &str) -> bool {
!effort.is_empty()
&& effort.len() <= 32
&& effort
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
}
fn read_cache_bytes(path: &Path) -> Result<Vec<u8>, CodexModelCacheFreshness> {
let path_metadata = match std::fs::symlink_metadata(path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(CodexModelCacheFreshness::Missing);
}
Err(_) => return Err(CodexModelCacheFreshness::Invalid),
};
if !path_metadata.file_type().is_file() || path_metadata.len() > MAX_MODEL_CACHE_BYTES {
return Err(CodexModelCacheFreshness::Invalid);
}
let mut file = match open_cache_file(path) {
Ok(file) => file,
Err(_) => return Err(CodexModelCacheFreshness::Invalid),
};
let metadata = match file.metadata() {
Ok(metadata) => metadata,
Err(_) => return Err(CodexModelCacheFreshness::Invalid),
};
if !metadata.file_type().is_file() || metadata.len() > MAX_MODEL_CACHE_BYTES {
return Err(CodexModelCacheFreshness::Invalid);
}
let mut bytes = Vec::with_capacity(metadata.len().min(MAX_MODEL_CACHE_BYTES) as usize);
if file
.by_ref()
.take(MAX_MODEL_CACHE_BYTES + 1)
.read_to_end(&mut bytes)
.is_err()
|| bytes.len() as u64 > MAX_MODEL_CACHE_BYTES
{
return Err(CodexModelCacheFreshness::Invalid);
}
Ok(bytes)
}
#[cfg(any(test, feature = "test-support"))]
pub fn install_test_chatgpt_roster(config: &Config, ids: &[&str]) -> anyhow::Result<()> {
install_test_chatgpt_roster_with_metadata(
config,
ids.iter()
.map(|id| CodexModelMetadata {
id: (*id).to_string(),
display_name: None,
context_window: None,
reasoning: None,
efforts: Vec::new(),
})
.collect(),
)
}
#[cfg(any(test, feature = "test-support"))]
pub fn install_test_chatgpt_roster_with_metadata(
config: &Config,
models: Vec<CodexModelMetadata>,
) -> anyhow::Result<()> {
let path = snapshot_path(config)
.ok_or_else(|| anyhow::anyhow!("test needs an owned ChatGPT registration"))?;
let snapshot = CatalogSnapshot {
fetched_at: Utc::now(),
models,
};
codewhale_config::persistence::atomic_write(&path, &serde_json::to_vec(&snapshot)?)?;
if let Ok(mut memo) = ROSTER_MEMO.lock() {
*memo = None;
}
Ok(())
}
fn open_cache_file(path: &Path) -> std::io::Result<std::fs::File> {
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_NOFOLLOW);
options.open(path)
}
pub fn invalidate_roster_cache() {
if let Ok(mut memo) = ROSTER_MEMO.lock() {
*memo = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn model(id: &str) -> CodexModelMetadata {
CodexModelMetadata {
id: id.to_string(),
display_name: Some(format!("Label for {id}")),
context_window: None,
reasoning: None,
efforts: Vec::new(),
}
}
fn save(path: &Path, fetched_at: DateTime<Utc>, models: Vec<CodexModelMetadata>) {
std::fs::write(
path,
serde_json::to_vec(&CatalogSnapshot { fetched_at, models }).unwrap(),
)
.unwrap();
}
#[test]
fn registration_scope_separates_accounts_workspaces_and_issuers() {
let key = registration_key("https://auth.openai.com", "oaiapp_workspace_a", "account_a");
for other in [
registration_key("https://auth.openai.com", "oaiapp_workspace_a", "account_b"),
registration_key("https://auth.openai.com", "oaiapp_workspace_b", "account_a"),
registration_key("https://other.example", "oaiapp_workspace_a", "account_a"),
] {
assert_ne!(key, other);
}
assert!(!key.contains("account_a"));
assert!(!key.contains("workspace_a"));
}
#[test]
fn own_snapshot_preserves_provider_order_and_labels_without_inventing_limits() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("roster.json");
let now = Utc::now();
save(&path, now, vec![model("z-first"), model("a-second")]);
let roster = load_snapshot(&path, now);
assert_eq!(roster.model_ids(), ["z-first", "a-second"]);
assert_eq!(roster.preferred_model_id(), Some("z-first"));
let metadata = roster.metadata_for("a-second").unwrap();
assert_eq!(metadata.display_name.as_deref(), Some("Label for a-second"));
assert_eq!(metadata.context_window, None);
assert_eq!(metadata.reasoning, None);
assert!(metadata.efforts.is_empty());
assert_eq!(roster.source, "chatgpt_plan_api");
assert!(roster.observation_persisted);
}
#[test]
fn missing_stale_future_and_unsafe_snapshots_offer_no_entitlements() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("roster.json");
let now = Utc::now();
assert!(load_snapshot(&path, now).model_ids().is_empty());
save(
&path,
now - MODEL_CACHE_MAX_AGE - Duration::seconds(1),
vec![model("old")],
);
let stale = load_snapshot(&path, now);
assert_eq!(stale.freshness, CodexModelCacheFreshness::Stale);
assert_eq!(stale.model_ids(), ["old"]);
assert_eq!(stale.preferred_model_id(), None);
save(
&path,
now + MAX_FUTURE_CLOCK_SKEW + Duration::seconds(1),
vec![model("future")],
);
assert_eq!(
load_snapshot(&path, now).freshness,
CodexModelCacheFreshness::Invalid
);
let mut unsafe_label = model("safe-id");
unsafe_label.display_name = Some("Unsafe\u{1b}[31m".to_string());
save(&path, now, vec![unsafe_label]);
assert_eq!(
load_snapshot(&path, now).freshness,
CodexModelCacheFreshness::Invalid
);
assert!(model_roster().models.is_empty());
}
#[cfg(unix)]
#[test]
fn cache_symlinks_are_rejected() {
let directory = tempfile::tempdir().unwrap();
let target = directory.path().join("target.json");
let path = directory.path().join("roster.json");
let now = Utc::now();
save(&target, now, vec![model("safe")]);
std::os::unix::fs::symlink(target, &path).unwrap();
assert_eq!(
load_snapshot(&path, now).freshness,
CodexModelCacheFreshness::Invalid
);
}
}
File diff suppressed because it is too large. Load diff
@@ -64,14 +64,14 @@
//! existing helper and discards the value with `.is_some()`.
use super::*;
use codewhale_runtime::credentials::{
use crate::credentials::{
AuthContext, CredentialProbe, CredentialResolution, CredentialSource,
context::ProcessAuthContext,
};
/// Resolve which place holds a credential for `provider`, using the real
/// process environment.
pub(crate) fn resolve_credential_source(
pub fn resolve_credential_source(
config: &Config,
identity: &ProviderIdentity,
) -> CredentialResolution {
@@ -226,7 +226,7 @@ pub(crate) fn resolve_credential_source_with(
"DeepSeek Harness",
"codewhale auth external-consent --provider deepseek --mode read-only",
|grant| {
codewhale_runtime::dsh_credentials::deepseek_api_key_from_grant(grant)
crate::dsh_credentials::deepseek_api_key_from_grant(grant)
.ok()
.flatten()
.is_some()
@@ -398,8 +398,12 @@ fn resolve_external_grant(
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{EnvVarGuard, lock_test_env};
use codewhale_runtime::credentials::context::MapAuthContext;
use crate::test_support::EnvVarGuard;
use crate::test_support::lock_test_env;
use crate::credentials::context::MapAuthContext;
fn deepseek_config() -> Config {
Config {
@@ -533,153 +537,4 @@ mod tests {
assert!(resolution.is_present());
assert!(resolution.checked_places().is_empty());
}
/// The resolver is the sole authority; `has_api_key_for` must agree with
/// it for every provider, or two surfaces can disagree again.
#[test]
fn has_api_key_for_agrees_with_the_resolver_for_every_provider() {
let _lock = lock_test_env();
let mut config = Config::default();
config
.providers
.get_or_insert_with(Default::default)
.custom
.insert(
"custom".into(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".into()),
base_url: Some("http://localhost:1234/v1".into()),
model: Some("fixture-model".into()),
..Default::default()
},
);
for provider in ProviderKind::all() {
let identity = config.test_identity_for_kind(*provider);
let resolution = resolve_credential_source(&config, &identity);
assert_eq!(
has_api_key_for(&config, &identity),
resolution.is_present(),
"{provider:?} disagreed: {:?}",
resolution.source
);
}
}
/// #5772: with reuse off, an existing external CLI file must not be
/// stat'ed, read, or adopted — and the probe must not even claim whether
/// the candidate exists.
#[test]
fn unconsented_external_candidates_are_never_probed() {
let _lock = lock_test_env();
let temp = tempfile::tempdir().expect("external fixture");
let codex_path = temp
.path()
.canonicalize()
.expect("canonical temp root")
.join("auth.json");
std::fs::write(&codex_path, "{\"tokens\":{\"access_token\":\"x\"}}").expect("fixture");
let _auth = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
let _access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
let _legacy_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let config = Config {
provider: Some("openai-codex".to_string()),
..Config::default()
};
codewhale_runtime::external_credentials::reset_side_effect_trap();
let resolution = resolve_credential_source(
&config,
&(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
);
assert!(!resolution.is_present());
assert!(!has_api_key_for(
&config,
&(config).test_identity_for_kind(ProviderKind::OpenaiCodex)
));
let checked = resolution.checked_places();
assert!(
checked.contains("Codewhale-owned ChatGPT sign-in"),
"the probe names the owned connection without accessing external state: {checked}"
);
assert!(
!checked.contains("(absent)") && !checked.contains("present, not consented"),
"no stat means no existence claim: {checked}"
);
assert_eq!(
codewhale_runtime::external_credentials::complete_side_effect_trap_counts(),
(0, 0, 0, 0, 0),
"resolution must not touch external credential state"
);
}
/// #5772: a consent record is not a credential. With a persisted consent
/// record whose pinned file is absent, resolution performs exactly the
/// read the user authorized — one secure open of the exact consented path —
/// and resolves as *missing* rather than masquerading as a stored
/// credential. No write, refresh, or network side effect is permitted.
#[test]
fn chatgpt_resolution_does_not_import_even_consented_external_files() {
let _lock = lock_test_env();
let temp = tempfile::tempdir().expect("external fixture");
let codex_path = temp
.path()
.canonicalize()
.expect("canonical temp root")
.join("absent-auth.json");
let _auth = EnvVarGuard::set("OPENAI_CODEX_AUTH_FILE", &codex_path);
let _access = EnvVarGuard::remove("OPENAI_CODEX_ACCESS_TOKEN");
let _legacy_access = EnvVarGuard::remove("CODEX_ACCESS_TOKEN");
let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let config = Config {
provider: Some("openai-codex".to_string()),
providers: Some(ProvidersConfig {
openai_codex: ProviderConfig {
auth_mode: Some("oauth".to_string()),
external_credentials: Some(
codewhale_config::ExternalCredentialConsentToml::read_only(
codewhale_config::ProviderKind::OpenaiCodex,
codewhale_config::ExternalCredentialSource::CodexCli,
codex_path.clone(),
),
),
..ProviderConfig::default()
},
..ProvidersConfig::default()
}),
..Config::default()
};
codewhale_runtime::external_credentials::reset_side_effect_trap();
let resolution = resolve_credential_source(
&config,
&(config).test_identity_for_kind(ProviderKind::OpenaiCodex),
);
assert!(
!resolution.is_present(),
"a consent record whose file is gone must resolve as missing: {:?}",
resolution.source
);
assert!(
resolution
.checked_places()
.contains("Codewhale-owned ChatGPT sign-in"),
"the probe requires Codewhale sign-in despite external consent: {}",
resolution.checked_places()
);
assert_eq!(
codewhale_runtime::external_credentials::complete_side_effect_trap_counts(),
(0, 0, 0, 0, 0),
"external consent cannot authorize the public ChatGPT API"
);
assert!(!has_api_key_for(
&config,
&(config).test_identity_for_kind(ProviderKind::OpenaiCodex)
));
assert_eq!(
codewhale_runtime::external_credentials::complete_side_effect_trap_counts(),
(0, 0, 0, 0, 0),
"has_api_key_for never reads another client credential"
);
}
}
@@ -13,12 +13,12 @@
use std::path::{Path, PathBuf};
pub(crate) fn default_config_path() -> anyhow::Result<PathBuf> {
pub fn default_config_path() -> anyhow::Result<PathBuf> {
try_default_config_path()
}
pub(crate) fn try_default_config_path() -> anyhow::Result<PathBuf> {
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
with_test_state_path(try_default_config_path_from_environment, || {
Ok(crate::test_support::unsealed_test_state_root()
@@ -26,7 +26,7 @@ pub(crate) fn try_default_config_path() -> anyhow::Result<PathBuf> {
})
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
try_default_config_path_from_environment()
}
@@ -39,7 +39,7 @@ fn try_default_config_path_from_environment() -> anyhow::Result<PathBuf> {
/// trusting it routed them at a populated `~/.codewhale/config.toml` (#5355,
/// #5359). Settings already requires a sealed `EnvVarGuard`; config paths
/// must use the same gate.
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
fn with_test_state_path<T>(
from_environment: impl FnOnce() -> T,
isolated: impl FnOnce() -> T,
@@ -65,8 +65,8 @@ pub(crate) fn codewhale_home_dir() -> Result<Option<PathBuf>, codewhale_paths::P
/// Credential writes are rerouted here when the ambient config path resolves
/// to a workspace-scoped document (#5045, #5193); non-credential settings keep
/// the ambient scoping.
pub(crate) fn home_config_path() -> Option<PathBuf> {
#[cfg(test)]
pub fn home_config_path() -> Option<PathBuf> {
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
with_test_state_path(home_config_path_from_environment, || {
Some(
@@ -76,13 +76,13 @@ pub(crate) fn home_config_path() -> Option<PathBuf> {
})
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
home_config_path_from_environment()
}
/// Compare the physical user-global document using the same parent path
/// normalization and file-symlink rejection as ConfigStore.
pub(crate) fn is_home_config_path(path: &Path) -> bool {
pub fn is_home_config_path(path: &Path) -> bool {
let Some(home) = home_config_path() else {
return false;
};
@@ -127,16 +127,16 @@ pub(crate) fn workspace_config_key(workspace: &Path) -> String {
.into_owned()
}
pub(crate) fn canonicalize_or_keep(path: &Path) -> PathBuf {
pub fn canonicalize_or_keep(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
pub(crate) fn env_config_path() -> Result<Option<PathBuf>, codewhale_paths::PathOverrideError> {
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
with_test_state_path(env_config_path_unlocked, || Ok(None))
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
{
env_config_path_unlocked()
}
@@ -187,7 +187,7 @@ pub(crate) fn default_requirements_path() -> Option<PathBuf> {
}
}
pub(crate) fn expand_path(path: &str) -> PathBuf {
pub fn expand_path(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix('~')
&& (stripped.is_empty() || stripped.starts_with('/') || stripped.starts_with('\\'))
&& let Some(mut home) = codewhale_paths::user_home()
@@ -203,7 +203,7 @@ pub(crate) fn expand_path(path: &str) -> PathBuf {
PathBuf::from(expanded.as_ref())
}
pub(crate) fn default_skills_dir() -> Option<PathBuf> {
pub fn default_skills_dir() -> Option<PathBuf> {
default_user_state_path("skills")
}
@@ -220,7 +220,7 @@ pub(crate) fn default_memory_path() -> Option<PathBuf> {
}
fn default_user_state_path(name: &str) -> Option<PathBuf> {
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
with_test_state_path(
|| default_user_state_path_from_environment(name),
@@ -228,7 +228,7 @@ fn default_user_state_path(name: &str) -> Option<PathBuf> {
)
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
default_user_state_path_from_environment(name)
}
@@ -261,12 +261,12 @@ fn default_user_state_path_from_environment(name: &str) -> Option<PathBuf> {
/// `~/.codewhale/clipboard-images/` so the path is stable across worktrees and
/// matches the location described in user-facing docs; falls back to
/// `<workspace>/clipboard-images/` if the home dir is unavailable.
pub(crate) fn clipboard_images_dir(workspace: &Path) -> PathBuf {
pub fn clipboard_images_dir(workspace: &Path) -> PathBuf {
let home = codewhale_paths::user_home();
clipboard_images_dir_for_home(workspace, home.as_deref())
}
pub(crate) fn clipboard_images_dir_for_home(workspace: &Path, home: Option<&Path>) -> PathBuf {
pub fn clipboard_images_dir_for_home(workspace: &Path, home: Option<&Path>) -> PathBuf {
if let Some(home) = home {
return home.join(".codewhale").join("clipboard-images");
}
File renamed without changes.
+599
View File
@@ -0,0 +1,599 @@
use super::*;
use crate::test_support::EnvVarGuard;
use std::env;
use std::ffi::OsString;
pub struct EnvGuard {
// Seal path overrides through EnvVarGuard so default_config_path honors
// this fixture instead of the isolated test root (#5355, #5359).
_sealed_home: EnvVarGuard,
_sealed_userprofile: EnvVarGuard,
_sealed_codewhale_home: EnvVarGuard,
_sealed_codewhale_config_path: EnvVarGuard,
_sealed_deepseek_config_path: EnvVarGuard,
home: Option<OsString>,
userprofile: Option<OsString>,
codewhale_home: Option<OsString>,
codewhale_config_path: Option<OsString>,
deepseek_config_path: Option<OsString>,
codewhale_secret_backend: Option<OsString>,
deepseek_secret_backend: Option<OsString>,
deepseek_provider: Option<OsString>,
active_route_api_key: Option<OsString>,
active_route_base_url: Option<OsString>,
deepseek_http_headers: Option<OsString>,
deepseek_model: Option<OsString>,
deepseek_default_text_model: Option<OsString>,
codewhale_provider: Option<OsString>,
codewhale_model: Option<OsString>,
codewhale_base_url: Option<OsString>,
nvidia_api_key: Option<OsString>,
nvidia_nim_api_key: Option<OsString>,
nim_base_url: Option<OsString>,
nvidia_base_url: Option<OsString>,
nvidia_nim_base_url: Option<OsString>,
nvidia_nim_model: Option<OsString>,
openai_api_key: Option<OsString>,
openai_base_url: Option<OsString>,
openai_model: Option<OsString>,
atlascloud_api_key: Option<OsString>,
atlascloud_base_url: Option<OsString>,
atlascloud_model: Option<OsString>,
wanjie_ark_api_key: Option<OsString>,
wanjie_api_key: Option<OsString>,
wanjie_maas_api_key: Option<OsString>,
wanjie_ark_base_url: Option<OsString>,
wanjie_base_url: Option<OsString>,
wanjie_maas_base_url: Option<OsString>,
wanjie_ark_model: Option<OsString>,
wanjie_model: Option<OsString>,
wanjie_maas_model: Option<OsString>,
openrouter_api_key: Option<OsString>,
openrouter_base_url: Option<OsString>,
openrouter_model: Option<OsString>,
volcengine_api_key: Option<OsString>,
volcengine_ark_api_key: Option<OsString>,
ark_api_key: Option<OsString>,
volcengine_base_url: Option<OsString>,
volcengine_ark_base_url: Option<OsString>,
ark_base_url: Option<OsString>,
volcengine_model: Option<OsString>,
volcengine_ark_model: Option<OsString>,
xiaomi_mimo_token_plan_api_key: Option<OsString>,
mimo_token_plan_api_key: Option<OsString>,
xiaomi_mimo_api_key: Option<OsString>,
xiaomi_api_key: Option<OsString>,
mimo_api_key: Option<OsString>,
xiaomi_mimo_base_url: Option<OsString>,
mimo_base_url: Option<OsString>,
xiaomi_mimo_model: Option<OsString>,
mimo_model: Option<OsString>,
xiaomi_mimo_mode: Option<OsString>,
mimo_mode: Option<OsString>,
novita_api_key: Option<OsString>,
novita_base_url: Option<OsString>,
novita_model: Option<OsString>,
fireworks_api_key: Option<OsString>,
fireworks_base_url: Option<OsString>,
fireworks_model: Option<OsString>,
siliconflow_api_key: Option<OsString>,
siliconflow_base_url: Option<OsString>,
siliconflow_model: Option<OsString>,
arcee_api_key: Option<OsString>,
arcee_base_url: Option<OsString>,
arcee_model: Option<OsString>,
moonshot_api_key: Option<OsString>,
moonshot_base_url: Option<OsString>,
moonshot_model: Option<OsString>,
kimi_api_key: Option<OsString>,
kimi_base_url: Option<OsString>,
kimi_model: Option<OsString>,
kimi_model_name: Option<OsString>,
kimi_code_home: Option<OsString>,
kimi_share_dir: Option<OsString>,
sglang_api_key: Option<OsString>,
sglang_base_url: Option<OsString>,
sglang_model: Option<OsString>,
vllm_api_key: Option<OsString>,
vllm_base_url: Option<OsString>,
vllm_model: Option<OsString>,
ollama_cloud_api_key: Option<OsString>,
ollama_cloud_base_url: Option<OsString>,
ollama_cloud_model: Option<OsString>,
ollama_api_key: Option<OsString>,
ollama_base_url: Option<OsString>,
ollama_model: Option<OsString>,
huggingface_api_key: Option<OsString>,
huggingface_token: Option<OsString>,
huggingface_base_url: Option<OsString>,
hf_base_url: Option<OsString>,
huggingface_model: Option<OsString>,
hf_model: Option<OsString>,
}
impl EnvGuard {
pub fn new(home: &Path) -> Self {
let home_str = OsString::from(home.as_os_str());
let config_path = home.join(".deepseek").join("config.toml");
let config_str = OsString::from(config_path.as_os_str());
let home_prev = env::var_os("HOME");
let userprofile_prev = env::var_os("USERPROFILE");
let codewhale_home_prev = env::var_os("CODEWHALE_HOME");
let codewhale_config_prev = env::var_os("CODEWHALE_CONFIG_PATH");
let deepseek_config_prev = env::var_os("DEEPSEEK_CONFIG_PATH");
let codewhale_secret_backend_prev = env::var_os("CODEWHALE_SECRET_BACKEND");
let deepseek_secret_backend_prev = env::var_os("DEEPSEEK_SECRET_BACKEND");
let deepseek_provider_prev = env::var_os("DEEPSEEK_PROVIDER");
let api_key_prev = env::var_os("DEEPSEEK_API_KEY");
let base_url_prev = env::var_os("DEEPSEEK_BASE_URL");
let http_headers_prev = env::var_os("DEEPSEEK_HTTP_HEADERS");
let model_prev = env::var_os("DEEPSEEK_MODEL");
let default_text_model_prev = env::var_os("DEEPSEEK_DEFAULT_TEXT_MODEL");
let codewhale_provider_prev = env::var_os("CODEWHALE_PROVIDER");
let codewhale_model_prev = env::var_os("CODEWHALE_MODEL");
let codewhale_base_url_prev = env::var_os("CODEWHALE_BASE_URL");
let nvidia_api_key_prev = env::var_os("NVIDIA_API_KEY");
let nvidia_nim_api_key_prev = env::var_os("NVIDIA_NIM_API_KEY");
let nim_base_url_prev = env::var_os("NIM_BASE_URL");
let nvidia_base_url_prev = env::var_os("NVIDIA_BASE_URL");
let nvidia_nim_base_url_prev = env::var_os("NVIDIA_NIM_BASE_URL");
let nvidia_nim_model_prev = env::var_os("NVIDIA_NIM_MODEL");
let openai_api_key_prev = env::var_os("OPENAI_API_KEY");
let openai_base_url_prev = env::var_os("OPENAI_BASE_URL");
let openai_model_prev = env::var_os("OPENAI_MODEL");
let atlascloud_api_key_prev = env::var_os("ATLASCLOUD_API_KEY");
let atlascloud_base_url_prev = env::var_os("ATLASCLOUD_BASE_URL");
let atlascloud_model_prev = env::var_os("ATLASCLOUD_MODEL");
let wanjie_ark_api_key_prev = env::var_os("WANJIE_ARK_API_KEY");
let wanjie_api_key_prev = env::var_os("WANJIE_API_KEY");
let wanjie_maas_api_key_prev = env::var_os("WANJIE_MAAS_API_KEY");
let wanjie_ark_base_url_prev = env::var_os("WANJIE_ARK_BASE_URL");
let wanjie_base_url_prev = env::var_os("WANJIE_BASE_URL");
let wanjie_maas_base_url_prev = env::var_os("WANJIE_MAAS_BASE_URL");
let wanjie_ark_model_prev = env::var_os("WANJIE_ARK_MODEL");
let wanjie_model_prev = env::var_os("WANJIE_MODEL");
let wanjie_maas_model_prev = env::var_os("WANJIE_MAAS_MODEL");
let openrouter_api_key_prev = env::var_os("OPENROUTER_API_KEY");
let openrouter_base_url_prev = env::var_os("OPENROUTER_BASE_URL");
let openrouter_model_prev = env::var_os("OPENROUTER_MODEL");
let volcengine_api_key_prev = env::var_os("VOLCENGINE_API_KEY");
let volcengine_ark_api_key_prev = env::var_os("VOLCENGINE_ARK_API_KEY");
let ark_api_key_prev = env::var_os("ARK_API_KEY");
let volcengine_base_url_prev = env::var_os("VOLCENGINE_BASE_URL");
let volcengine_ark_base_url_prev = env::var_os("VOLCENGINE_ARK_BASE_URL");
let ark_base_url_prev = env::var_os("ARK_BASE_URL");
let volcengine_model_prev = env::var_os("VOLCENGINE_MODEL");
let volcengine_ark_model_prev = env::var_os("VOLCENGINE_ARK_MODEL");
let xiaomi_mimo_token_plan_api_key_prev = env::var_os("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
let mimo_token_plan_api_key_prev = env::var_os("MIMO_TOKEN_PLAN_API_KEY");
let xiaomi_mimo_api_key_prev = env::var_os("XIAOMI_MIMO_API_KEY");
let xiaomi_api_key_prev = env::var_os("XIAOMI_API_KEY");
let mimo_api_key_prev = env::var_os("MIMO_API_KEY");
let xiaomi_mimo_base_url_prev = env::var_os("XIAOMI_MIMO_BASE_URL");
let mimo_base_url_prev = env::var_os("MIMO_BASE_URL");
let xiaomi_mimo_model_prev = env::var_os("XIAOMI_MIMO_MODEL");
let mimo_model_prev = env::var_os("MIMO_MODEL");
let xiaomi_mimo_mode_prev = env::var_os("XIAOMI_MIMO_MODE");
let mimo_mode_prev = env::var_os("MIMO_MODE");
let novita_api_key_prev = env::var_os("NOVITA_API_KEY");
let novita_base_url_prev = env::var_os("NOVITA_BASE_URL");
let novita_model_prev = env::var_os("NOVITA_MODEL");
let fireworks_api_key_prev = env::var_os("FIREWORKS_API_KEY");
let fireworks_base_url_prev = env::var_os("FIREWORKS_BASE_URL");
let fireworks_model_prev = env::var_os("FIREWORKS_MODEL");
let siliconflow_api_key_prev = env::var_os("SILICONFLOW_API_KEY");
let siliconflow_base_url_prev = env::var_os("SILICONFLOW_BASE_URL");
let siliconflow_model_prev = env::var_os("SILICONFLOW_MODEL");
let arcee_api_key_prev = env::var_os("ARCEE_API_KEY");
let arcee_base_url_prev = env::var_os("ARCEE_BASE_URL");
let arcee_model_prev = env::var_os("ARCEE_MODEL");
let moonshot_api_key_prev = env::var_os("MOONSHOT_API_KEY");
let moonshot_base_url_prev = env::var_os("MOONSHOT_BASE_URL");
let moonshot_model_prev = env::var_os("MOONSHOT_MODEL");
let kimi_api_key_prev = env::var_os("KIMI_API_KEY");
let kimi_base_url_prev = env::var_os("KIMI_BASE_URL");
let kimi_model_prev = env::var_os("KIMI_MODEL");
let kimi_model_name_prev = env::var_os("KIMI_MODEL_NAME");
let kimi_code_home_prev = env::var_os("KIMI_CODE_HOME");
let kimi_share_dir_prev = env::var_os("KIMI_SHARE_DIR");
let sglang_api_key_prev = env::var_os("SGLANG_API_KEY");
let sglang_base_url_prev = env::var_os("SGLANG_BASE_URL");
let sglang_model_prev = env::var_os("SGLANG_MODEL");
let vllm_api_key_prev = env::var_os("VLLM_API_KEY");
let vllm_base_url_prev = env::var_os("VLLM_BASE_URL");
let vllm_model_prev = env::var_os("VLLM_MODEL");
let ollama_cloud_api_key_prev = env::var_os("OLLAMA_CLOUD_API_KEY");
let ollama_cloud_base_url_prev = env::var_os("OLLAMA_CLOUD_BASE_URL");
let ollama_cloud_model_prev = env::var_os("OLLAMA_CLOUD_MODEL");
let ollama_api_key_prev = env::var_os("OLLAMA_API_KEY");
let ollama_base_url_prev = env::var_os("OLLAMA_BASE_URL");
let ollama_model_prev = env::var_os("OLLAMA_MODEL");
let huggingface_api_key_prev = env::var_os("HUGGINGFACE_API_KEY");
let huggingface_token_prev = env::var_os("HF_TOKEN");
let huggingface_base_url_prev = env::var_os("HUGGINGFACE_BASE_URL");
let hf_base_url_prev = env::var_os("HF_BASE_URL");
let huggingface_model_prev = env::var_os("HUGGINGFACE_MODEL");
let hf_model_prev = env::var_os("HF_MODEL");
let sealed_home = EnvVarGuard::set("HOME", &home_str);
let sealed_userprofile = EnvVarGuard::set("USERPROFILE", &home_str);
let sealed_codewhale_home = EnvVarGuard::remove("CODEWHALE_HOME");
let sealed_codewhale_config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
let sealed_deepseek_config_path = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_str);
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
env::remove_var("CODEWHALE_SECRET_BACKEND");
env::remove_var("DEEPSEEK_SECRET_BACKEND");
env::remove_var("DEEPSEEK_PROVIDER");
env::remove_var("DEEPSEEK_API_KEY");
env::remove_var("DEEPSEEK_BASE_URL");
env::remove_var("DEEPSEEK_HTTP_HEADERS");
env::remove_var("DEEPSEEK_MODEL");
env::remove_var("DEEPSEEK_DEFAULT_TEXT_MODEL");
env::remove_var("CODEWHALE_PROVIDER");
env::remove_var("CODEWHALE_MODEL");
env::remove_var("CODEWHALE_BASE_URL");
env::remove_var("NVIDIA_API_KEY");
env::remove_var("NVIDIA_NIM_API_KEY");
env::remove_var("NIM_BASE_URL");
env::remove_var("NVIDIA_BASE_URL");
env::remove_var("NVIDIA_NIM_BASE_URL");
env::remove_var("NVIDIA_NIM_MODEL");
env::remove_var("OPENAI_API_KEY");
env::remove_var("OPENAI_BASE_URL");
env::remove_var("OPENAI_MODEL");
env::remove_var("ATLASCLOUD_API_KEY");
env::remove_var("ATLASCLOUD_BASE_URL");
env::remove_var("ATLASCLOUD_MODEL");
env::remove_var("WANJIE_ARK_API_KEY");
env::remove_var("WANJIE_API_KEY");
env::remove_var("WANJIE_MAAS_API_KEY");
env::remove_var("WANJIE_ARK_BASE_URL");
env::remove_var("WANJIE_BASE_URL");
env::remove_var("WANJIE_MAAS_BASE_URL");
env::remove_var("WANJIE_ARK_MODEL");
env::remove_var("WANJIE_MODEL");
env::remove_var("WANJIE_MAAS_MODEL");
env::remove_var("OPENROUTER_API_KEY");
env::remove_var("OPENROUTER_BASE_URL");
env::remove_var("OPENROUTER_MODEL");
env::remove_var("VOLCENGINE_API_KEY");
env::remove_var("VOLCENGINE_ARK_API_KEY");
env::remove_var("ARK_API_KEY");
env::remove_var("VOLCENGINE_BASE_URL");
env::remove_var("VOLCENGINE_ARK_BASE_URL");
env::remove_var("ARK_BASE_URL");
env::remove_var("VOLCENGINE_MODEL");
env::remove_var("VOLCENGINE_ARK_MODEL");
env::remove_var("XIAOMI_MIMO_TOKEN_PLAN_API_KEY");
env::remove_var("MIMO_TOKEN_PLAN_API_KEY");
env::remove_var("XIAOMI_MIMO_API_KEY");
env::remove_var("XIAOMI_API_KEY");
env::remove_var("MIMO_API_KEY");
env::remove_var("XIAOMI_MIMO_BASE_URL");
env::remove_var("MIMO_BASE_URL");
env::remove_var("XIAOMI_MIMO_MODEL");
env::remove_var("MIMO_MODEL");
env::remove_var("XIAOMI_MIMO_MODE");
env::remove_var("MIMO_MODE");
env::remove_var("NOVITA_API_KEY");
env::remove_var("NOVITA_BASE_URL");
env::remove_var("NOVITA_MODEL");
env::remove_var("FIREWORKS_API_KEY");
env::remove_var("FIREWORKS_BASE_URL");
env::remove_var("FIREWORKS_MODEL");
env::remove_var("SILICONFLOW_API_KEY");
env::remove_var("SILICONFLOW_BASE_URL");
env::remove_var("SILICONFLOW_MODEL");
env::remove_var("ARCEE_API_KEY");
env::remove_var("ARCEE_BASE_URL");
env::remove_var("ARCEE_MODEL");
env::remove_var("MOONSHOT_API_KEY");
env::remove_var("MOONSHOT_BASE_URL");
env::remove_var("MOONSHOT_MODEL");
env::remove_var("KIMI_API_KEY");
env::remove_var("KIMI_BASE_URL");
env::remove_var("KIMI_MODEL");
env::remove_var("KIMI_MODEL_NAME");
env::remove_var("KIMI_CODE_HOME");
env::remove_var("KIMI_SHARE_DIR");
env::remove_var("SGLANG_API_KEY");
env::remove_var("SGLANG_BASE_URL");
env::remove_var("SGLANG_MODEL");
env::remove_var("VLLM_API_KEY");
env::remove_var("VLLM_BASE_URL");
env::remove_var("VLLM_MODEL");
env::remove_var("OLLAMA_CLOUD_API_KEY");
env::remove_var("OLLAMA_CLOUD_BASE_URL");
env::remove_var("OLLAMA_CLOUD_MODEL");
env::remove_var("OLLAMA_API_KEY");
env::remove_var("OLLAMA_BASE_URL");
env::remove_var("OLLAMA_MODEL");
env::remove_var("HUGGINGFACE_API_KEY");
env::remove_var("HF_TOKEN");
env::remove_var("HUGGINGFACE_BASE_URL");
env::remove_var("HF_BASE_URL");
env::remove_var("HUGGINGFACE_MODEL");
env::remove_var("HF_MODEL");
}
Self {
_sealed_home: sealed_home,
_sealed_userprofile: sealed_userprofile,
_sealed_codewhale_home: sealed_codewhale_home,
_sealed_codewhale_config_path: sealed_codewhale_config_path,
_sealed_deepseek_config_path: sealed_deepseek_config_path,
home: home_prev,
userprofile: userprofile_prev,
codewhale_home: codewhale_home_prev,
codewhale_config_path: codewhale_config_prev,
deepseek_config_path: deepseek_config_prev,
codewhale_secret_backend: codewhale_secret_backend_prev,
deepseek_secret_backend: deepseek_secret_backend_prev,
deepseek_provider: deepseek_provider_prev,
active_route_api_key: api_key_prev,
active_route_base_url: base_url_prev,
deepseek_http_headers: http_headers_prev,
deepseek_model: model_prev,
deepseek_default_text_model: default_text_model_prev,
codewhale_provider: codewhale_provider_prev,
codewhale_model: codewhale_model_prev,
codewhale_base_url: codewhale_base_url_prev,
nvidia_api_key: nvidia_api_key_prev,
nvidia_nim_api_key: nvidia_nim_api_key_prev,
nim_base_url: nim_base_url_prev,
nvidia_base_url: nvidia_base_url_prev,
nvidia_nim_base_url: nvidia_nim_base_url_prev,
nvidia_nim_model: nvidia_nim_model_prev,
openai_api_key: openai_api_key_prev,
openai_base_url: openai_base_url_prev,
openai_model: openai_model_prev,
atlascloud_api_key: atlascloud_api_key_prev,
atlascloud_base_url: atlascloud_base_url_prev,
atlascloud_model: atlascloud_model_prev,
wanjie_ark_api_key: wanjie_ark_api_key_prev,
wanjie_api_key: wanjie_api_key_prev,
wanjie_maas_api_key: wanjie_maas_api_key_prev,
wanjie_ark_base_url: wanjie_ark_base_url_prev,
wanjie_base_url: wanjie_base_url_prev,
wanjie_maas_base_url: wanjie_maas_base_url_prev,
wanjie_ark_model: wanjie_ark_model_prev,
wanjie_model: wanjie_model_prev,
wanjie_maas_model: wanjie_maas_model_prev,
openrouter_api_key: openrouter_api_key_prev,
openrouter_base_url: openrouter_base_url_prev,
openrouter_model: openrouter_model_prev,
volcengine_api_key: volcengine_api_key_prev,
volcengine_ark_api_key: volcengine_ark_api_key_prev,
ark_api_key: ark_api_key_prev,
volcengine_base_url: volcengine_base_url_prev,
volcengine_ark_base_url: volcengine_ark_base_url_prev,
ark_base_url: ark_base_url_prev,
volcengine_model: volcengine_model_prev,
volcengine_ark_model: volcengine_ark_model_prev,
xiaomi_mimo_token_plan_api_key: xiaomi_mimo_token_plan_api_key_prev,
mimo_token_plan_api_key: mimo_token_plan_api_key_prev,
xiaomi_mimo_api_key: xiaomi_mimo_api_key_prev,
xiaomi_api_key: xiaomi_api_key_prev,
mimo_api_key: mimo_api_key_prev,
xiaomi_mimo_base_url: xiaomi_mimo_base_url_prev,
mimo_base_url: mimo_base_url_prev,
xiaomi_mimo_model: xiaomi_mimo_model_prev,
mimo_model: mimo_model_prev,
xiaomi_mimo_mode: xiaomi_mimo_mode_prev,
mimo_mode: mimo_mode_prev,
novita_api_key: novita_api_key_prev,
novita_base_url: novita_base_url_prev,
novita_model: novita_model_prev,
fireworks_api_key: fireworks_api_key_prev,
fireworks_base_url: fireworks_base_url_prev,
fireworks_model: fireworks_model_prev,
siliconflow_api_key: siliconflow_api_key_prev,
siliconflow_base_url: siliconflow_base_url_prev,
siliconflow_model: siliconflow_model_prev,
arcee_api_key: arcee_api_key_prev,
arcee_base_url: arcee_base_url_prev,
arcee_model: arcee_model_prev,
moonshot_api_key: moonshot_api_key_prev,
moonshot_base_url: moonshot_base_url_prev,
moonshot_model: moonshot_model_prev,
kimi_api_key: kimi_api_key_prev,
kimi_base_url: kimi_base_url_prev,
kimi_model: kimi_model_prev,
kimi_model_name: kimi_model_name_prev,
kimi_code_home: kimi_code_home_prev,
kimi_share_dir: kimi_share_dir_prev,
sglang_api_key: sglang_api_key_prev,
sglang_base_url: sglang_base_url_prev,
sglang_model: sglang_model_prev,
vllm_api_key: vllm_api_key_prev,
vllm_base_url: vllm_base_url_prev,
vllm_model: vllm_model_prev,
ollama_cloud_api_key: ollama_cloud_api_key_prev,
ollama_cloud_base_url: ollama_cloud_base_url_prev,
ollama_cloud_model: ollama_cloud_model_prev,
ollama_api_key: ollama_api_key_prev,
ollama_base_url: ollama_base_url_prev,
ollama_model: ollama_model_prev,
huggingface_api_key: huggingface_api_key_prev,
huggingface_token: huggingface_token_prev,
huggingface_base_url: huggingface_base_url_prev,
hf_base_url: hf_base_url_prev,
huggingface_model: huggingface_model_prev,
hf_model: hf_model_prev,
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
// Safety: test-only environment mutation guarded by a global mutex.
unsafe {
Self::restore_var("HOME", self.home.take());
Self::restore_var("USERPROFILE", self.userprofile.take());
Self::restore_var("CODEWHALE_HOME", self.codewhale_home.take());
Self::restore_var("CODEWHALE_CONFIG_PATH", self.codewhale_config_path.take());
Self::restore_var("DEEPSEEK_CONFIG_PATH", self.deepseek_config_path.take());
Self::restore_var(
"CODEWHALE_SECRET_BACKEND",
self.codewhale_secret_backend.take(),
);
Self::restore_var(
"DEEPSEEK_SECRET_BACKEND",
self.deepseek_secret_backend.take(),
);
Self::restore_var("DEEPSEEK_PROVIDER", self.deepseek_provider.take());
Self::restore_var("DEEPSEEK_API_KEY", self.active_route_api_key.take());
Self::restore_var("DEEPSEEK_BASE_URL", self.active_route_base_url.take());
Self::restore_var("DEEPSEEK_HTTP_HEADERS", self.deepseek_http_headers.take());
Self::restore_var("DEEPSEEK_MODEL", self.deepseek_model.take());
Self::restore_var(
"DEEPSEEK_DEFAULT_TEXT_MODEL",
self.deepseek_default_text_model.take(),
);
Self::restore_var("CODEWHALE_PROVIDER", self.codewhale_provider.take());
Self::restore_var("CODEWHALE_MODEL", self.codewhale_model.take());
Self::restore_var("CODEWHALE_BASE_URL", self.codewhale_base_url.take());
Self::restore_var("NVIDIA_API_KEY", self.nvidia_api_key.take());
Self::restore_var("NVIDIA_NIM_API_KEY", self.nvidia_nim_api_key.take());
Self::restore_var("NIM_BASE_URL", self.nim_base_url.take());
Self::restore_var("NVIDIA_BASE_URL", self.nvidia_base_url.take());
Self::restore_var("NVIDIA_NIM_BASE_URL", self.nvidia_nim_base_url.take());
Self::restore_var("NVIDIA_NIM_MODEL", self.nvidia_nim_model.take());
Self::restore_var("OPENAI_API_KEY", self.openai_api_key.take());
Self::restore_var("OPENAI_BASE_URL", self.openai_base_url.take());
Self::restore_var("OPENAI_MODEL", self.openai_model.take());
Self::restore_var("ATLASCLOUD_API_KEY", self.atlascloud_api_key.take());
Self::restore_var("ATLASCLOUD_BASE_URL", self.atlascloud_base_url.take());
Self::restore_var("ATLASCLOUD_MODEL", self.atlascloud_model.take());
Self::restore_var("WANJIE_ARK_API_KEY", self.wanjie_ark_api_key.take());
Self::restore_var("WANJIE_API_KEY", self.wanjie_api_key.take());
Self::restore_var("WANJIE_MAAS_API_KEY", self.wanjie_maas_api_key.take());
Self::restore_var("WANJIE_ARK_BASE_URL", self.wanjie_ark_base_url.take());
Self::restore_var("WANJIE_BASE_URL", self.wanjie_base_url.take());
Self::restore_var("WANJIE_MAAS_BASE_URL", self.wanjie_maas_base_url.take());
Self::restore_var("WANJIE_ARK_MODEL", self.wanjie_ark_model.take());
Self::restore_var("WANJIE_MODEL", self.wanjie_model.take());
Self::restore_var("WANJIE_MAAS_MODEL", self.wanjie_maas_model.take());
Self::restore_var("OPENROUTER_API_KEY", self.openrouter_api_key.take());
Self::restore_var("OPENROUTER_BASE_URL", self.openrouter_base_url.take());
Self::restore_var("OPENROUTER_MODEL", self.openrouter_model.take());
Self::restore_var("VOLCENGINE_API_KEY", self.volcengine_api_key.take());
Self::restore_var("VOLCENGINE_ARK_API_KEY", self.volcengine_ark_api_key.take());
Self::restore_var("ARK_API_KEY", self.ark_api_key.take());
Self::restore_var("VOLCENGINE_BASE_URL", self.volcengine_base_url.take());
Self::restore_var(
"VOLCENGINE_ARK_BASE_URL",
self.volcengine_ark_base_url.take(),
);
Self::restore_var("ARK_BASE_URL", self.ark_base_url.take());
Self::restore_var("VOLCENGINE_MODEL", self.volcengine_model.take());
Self::restore_var("VOLCENGINE_ARK_MODEL", self.volcengine_ark_model.take());
Self::restore_var(
"XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
self.xiaomi_mimo_token_plan_api_key.take(),
);
Self::restore_var(
"MIMO_TOKEN_PLAN_API_KEY",
self.mimo_token_plan_api_key.take(),
);
Self::restore_var("XIAOMI_MIMO_API_KEY", self.xiaomi_mimo_api_key.take());
Self::restore_var("XIAOMI_API_KEY", self.xiaomi_api_key.take());
Self::restore_var("MIMO_API_KEY", self.mimo_api_key.take());
Self::restore_var("XIAOMI_MIMO_BASE_URL", self.xiaomi_mimo_base_url.take());
Self::restore_var("MIMO_BASE_URL", self.mimo_base_url.take());
Self::restore_var("XIAOMI_MIMO_MODEL", self.xiaomi_mimo_model.take());
Self::restore_var("MIMO_MODEL", self.mimo_model.take());
Self::restore_var("XIAOMI_MIMO_MODE", self.xiaomi_mimo_mode.take());
Self::restore_var("MIMO_MODE", self.mimo_mode.take());
Self::restore_var("NOVITA_API_KEY", self.novita_api_key.take());
Self::restore_var("NOVITA_BASE_URL", self.novita_base_url.take());
Self::restore_var("NOVITA_MODEL", self.novita_model.take());
Self::restore_var("FIREWORKS_API_KEY", self.fireworks_api_key.take());
Self::restore_var("FIREWORKS_BASE_URL", self.fireworks_base_url.take());
Self::restore_var("FIREWORKS_MODEL", self.fireworks_model.take());
Self::restore_var("SILICONFLOW_API_KEY", self.siliconflow_api_key.take());
Self::restore_var("SILICONFLOW_BASE_URL", self.siliconflow_base_url.take());
Self::restore_var("SILICONFLOW_MODEL", self.siliconflow_model.take());
Self::restore_var("ARCEE_API_KEY", self.arcee_api_key.take());
Self::restore_var("ARCEE_BASE_URL", self.arcee_base_url.take());
Self::restore_var("ARCEE_MODEL", self.arcee_model.take());
Self::restore_var("MOONSHOT_API_KEY", self.moonshot_api_key.take());
Self::restore_var("MOONSHOT_BASE_URL", self.moonshot_base_url.take());
Self::restore_var("MOONSHOT_MODEL", self.moonshot_model.take());
Self::restore_var("KIMI_API_KEY", self.kimi_api_key.take());
Self::restore_var("KIMI_BASE_URL", self.kimi_base_url.take());
Self::restore_var("KIMI_MODEL", self.kimi_model.take());
Self::restore_var("KIMI_MODEL_NAME", self.kimi_model_name.take());
Self::restore_var("KIMI_CODE_HOME", self.kimi_code_home.take());
Self::restore_var("KIMI_SHARE_DIR", self.kimi_share_dir.take());
Self::restore_var("SGLANG_API_KEY", self.sglang_api_key.take());
Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
Self::restore_var("SGLANG_MODEL", self.sglang_model.take());
Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
Self::restore_var("VLLM_MODEL", self.vllm_model.take());
Self::restore_var("OLLAMA_CLOUD_API_KEY", self.ollama_cloud_api_key.take());
Self::restore_var("OLLAMA_CLOUD_BASE_URL", self.ollama_cloud_base_url.take());
Self::restore_var("OLLAMA_CLOUD_MODEL", self.ollama_cloud_model.take());
Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
Self::restore_var("OLLAMA_MODEL", self.ollama_model.take());
Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take());
Self::restore_var("HF_TOKEN", self.huggingface_token.take());
Self::restore_var("HUGGINGFACE_BASE_URL", self.huggingface_base_url.take());
Self::restore_var("HF_BASE_URL", self.hf_base_url.take());
Self::restore_var("HUGGINGFACE_MODEL", self.huggingface_model.take());
Self::restore_var("HF_MODEL", self.hf_model.take());
}
}
}
impl EnvGuard {
/// Restore an env var to its prior value (or remove it if it was unset).
///
/// # Safety
/// Must only be called from test code guarded by a global mutex.
pub(super) unsafe fn restore_var(key: &str, prev: Option<OsString>) {
if let Some(value) = prev {
unsafe { env::set_var(key, value) };
} else {
unsafe { env::remove_var(key) };
}
}
}
pub fn session_custom_provider_config(name: &str, kind: &str, base_url: &str) -> Config {
let mut custom = HashMap::new();
custom.insert(
name.to_string(),
ProviderConfig {
kind: Some(kind.to_string()),
base_url: Some(base_url.to_string()),
model: Some("local-model".to_string()),
..Default::default()
},
);
Config {
provider: Some(name.to_string()),
providers: Some(ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
}
}
// === Identity-owned endpoint resolution (provider-truth regressions) ===
//
// Every test here is offline and env-locked. No credential is invented and no
// provider is contacted: the assertions are about which host string a route
// resolves to, and about the classifications derived from it.
/// A managed-config guard pointing at a path that does not exist, so an
/// operator-installed managed file on the developer's machine cannot leak into
/// these route assertions.
pub fn no_managed_config(root: &std::path::Path) -> EnvVarGuard {
EnvVarGuard::set(
"DEEPSEEK_MANAGED_CONFIG_PATH",
root.join("absent-managed.toml"),
)
}
File diff suppressed because it is too large. Load diff
@@ -168,7 +168,7 @@ fn settable_keys() -> impl Iterator<Item = &'static str> {
}
/// The closest of `candidates` to `query`, if any is close enough to suggest.
pub(crate) fn nearest_key<'a>(
pub fn nearest_key<'a>(
query: &str,
candidates: impl IntoIterator<Item = &'a str>,
) -> Option<&'a str> {
@@ -229,7 +229,7 @@ pub fn settings_value(key: &str) -> Result<Option<String>> {
/// accepts under its own name. Internal flags, actions, receipts and retired
/// schema defs are not settable and are left out, so `/config <key>` and its
/// did-you-mean only ever name a key a user can change.
pub(crate) fn settings_toml_keys() -> impl Iterator<Item = &'static str> {
pub fn settings_toml_keys() -> impl Iterator<Item = &'static str> {
SETTINGS_SCHEMA
.iter()
.map(|def| def.key)
@@ -21,7 +21,7 @@ use codewhale_config::ProviderKind;
/// This is the single write path for TUI config mutations: `toml_edit` keeps
/// user comments and formatting intact, and the temp-file + rename write can
/// never leave a half-written config behind.
pub(crate) fn mutate_config_document<F>(path: &Path, mutate: F) -> anyhow::Result<()>
pub fn mutate_config_document<F>(path: &Path, mutate: F) -> anyhow::Result<()>
where
F: FnOnce(&mut toml_edit::DocumentMut) -> anyhow::Result<()>,
{
@@ -47,7 +47,7 @@ where
/// atomic config write. Settings remains untouched, so an interrupted cleanup
/// or an older binary cannot erase the user's historical choices. After this
/// marker, Config never consults those legacy route fields again.
pub(crate) fn migrate_legacy_route_preferences(
pub fn migrate_legacy_route_preferences(
path: &Path,
doc: &mut toml_edit::DocumentMut,
) -> anyhow::Result<()> {
@@ -64,7 +64,6 @@ pub(crate) fn migrate_legacy_route_preferences(
"Could not parse configuration for route preference migration; contents omitted"
)
})?;
crate::plugins::providers::apply_startup_providers(&mut config)?;
let previous_config = config.clone();
let settings =
crate::settings::Settings::load_legacy_route_preferences_read_only().map_err(|_| {
@@ -82,7 +81,27 @@ pub(crate) fn migrate_legacy_route_preferences(
settings.load_error.is_none(),
"Could not read legacy route preferences; configuration was not changed"
);
if let Some(provider) = settings
.default_provider
.as_deref()
.filter(|provider| !provider.trim().is_empty())
{
let identity = config.resolve_provider_identity(provider).map_err(|_| anyhow::anyhow!("Legacy saved provider is unavailable in this configuration; route preference migration did not change files"))?;
let mut scoped = config.clone();
scoped.scope_to_provider_identity(&identity).map_err(|_| anyhow::anyhow!("Legacy saved provider cannot be selected from this configuration; route preference migration did not change files"))?;
}
config.apply_saved_selection(&settings);
if let Some(models) = &settings.provider_models {
for (provider, model) in models {
let model = model.trim();
if model.is_empty() || model.chars().any(char::is_control) {
continue;
}
let identity = config.legacy_selection_identity(provider).map_err(|_| anyhow::anyhow!("Legacy saved model refers to an unavailable provider; route preference migration did not change files"))?;
let mut scoped = config.clone();
scoped.scope_to_provider_identity(&identity).map_err(|_| anyhow::anyhow!("Legacy saved model provider cannot be selected from this configuration; route preference migration did not change files"))?;
}
}
let active_identity = config.active_provider_identity().ok();
let selector = active_identity
.as_ref()
@@ -164,28 +183,16 @@ pub(crate) fn migrate_legacy_route_preferences(
old_model,
)?;
}
set_provider_model_document(doc, &identity, model)?;
set_verified_provider_model_document(doc, &config, &identity, model)?;
}
}
set_document_value(doc, &["route_preferences_version"], 1_i64)
}
pub(crate) fn set_provider_model_document(
doc: &mut toml_edit::DocumentMut,
identity: &ProviderIdentity,
model: &str,
) -> anyhow::Result<()> {
let mut config = crate::config::parse_config_base(&doc.to_string()).map_err(|_| {
anyhow::anyhow!("Could not parse destination route identity; contents omitted")
})?;
crate::plugins::providers::apply_startup_providers(&mut config)?;
set_verified_provider_model_document(doc, &config, identity, model)
}
// `config` is the freshly parsed same-lock document, before any intended delta.
// Reuse that projection so a root receipt is not dropped by a second parse of
// the canonicalized document. Every path verifies before writing the delta.
fn set_verified_provider_model_document(
pub fn set_verified_provider_model_document(
doc: &mut toml_edit::DocumentMut,
config: &crate::config::Config,
identity: &ProviderIdentity,
@@ -202,182 +209,6 @@ fn set_verified_provider_model_document(
set_document_value(doc, &["providers", provider_key, "model"], model)
}
/// One persistent owner and atomic write for an explicitly saved route.
///
/// Also returns the undo for exactly this write, taken under the config lock,
/// so a caller whose follow-up apply step is rejected can take back only the
/// switch.
pub(crate) fn persist_provider_selection(
config_path: Option<&Path>,
identity: &ProviderIdentity,
model: Option<&str>,
) -> anyhow::Result<(PathBuf, codewhale_config::ConfigDocumentUndo)> {
let path = config_toml_path(config_path)?;
let ((), undo) =
codewhale_config::mutate_config_document_undoable_with_migration(&path, |doc, moved| {
migrate_legacy_route_preferences(&path, doc)?;
let mut config =
crate::config::parse_config_after_locked_migration(&doc.to_string(), moved)
.map_err(|_| {
anyhow::anyhow!("Could not parse destination route; contents omitted")
})?;
crate::plugins::providers::apply_startup_providers(&mut config)?;
config
.verify_provider_identity(identity)
.map_err(anyhow::Error::msg)?;
if let Some(model) = model {
set_verified_provider_model_document(doc, &config, identity, model)?;
}
set_document_value(
doc,
&["provider"],
identity.persisted_id().unwrap_or(identity.key.as_str()),
)?;
reconcile_root_model_aliases(doc, &config, identity)
})?;
Ok((path, undo))
}
/// Keep a root `default_text_model` alias from stranding a route switch,
/// without discarding the choice it holds.
///
/// The root alias is the *active* route's fallback: `Config` resolves it only
/// when the selected route has no model of its own. A switch that leaves the
/// incoming route without a leaf therefore hands it an alias naming the route
/// on its way out, and `Config::load` rejects the file the caller just wrote —
/// a committed switch that produces an unloadable config — or, on a
/// pass-through incoming route that loads it, a model the incoming route never
/// chose while the outgoing route comes back on its catalog default.
///
/// When the outgoing route has no leaf and was resolving the alias, relocate
/// that value onto the outgoing route's own canonical leaf, because it is real
/// saved state, and only then clear the alias. Any other alias the incoming
/// route already shadows with its own leaf is inert and stays: deleting a
/// saved choice to satisfy validation of a value nothing resolves is data
/// loss, not a repair. Likewise an unnamed custom route stores its
/// model in the alias itself and has no leaf to receive it. If the incoming
/// route cannot shadow that value, refuse the switch atomically and ask for an
/// explicit destination model instead of saving an unloadable configuration.
///
/// `previous` is the document's configuration before the switch; `incoming` is
/// the identity now selected. Every route writer calls this, so no writer can
/// keep a private rule about which route owns the root alias.
pub(crate) fn reconcile_root_model_aliases(
doc: &mut toml_edit::DocumentMut,
previous: &crate::config::Config,
incoming: &crate::config::ProviderIdentity,
) -> anyhow::Result<()> {
// A freshly admitted id-less root keeps its legacy alias as a receipt.
// Its effective model is written to the canonical leaf created by this
// same-lock migration; the alias is never another route's saved choice.
if incoming.provider == ProviderKind::Custom && incoming.persisted_id().is_none() {
return Ok(());
}
// The outgoing route was resolving the alias as its own model. Move it
// onto that route's leaf even when the incoming route could load it: a
// pass-through incoming route would otherwise inherit a model it never
// chose, and the outgoing route would come back on its catalog default.
if let Some((outgoing, value)) = previous.root_model_alias_owned_by_outgoing(incoming) {
set_verified_provider_model_document(doc, previous, &outgoing, &value)?;
unset_root_model_aliases(doc)?;
return Ok(());
}
// Below, only `default_text_model` is read. The legacy root `model` key is
// never what blocks a load: `Config::default_model` already refuses to
// route a foreign legacy value to a provider that cannot serve it, and
// `Config::validate` does not consult it, so relocating it would move a
// value nothing is asking about. It is still cleared together with the
// alias that shadows it (`unset_root_model_aliases`).
const ROOT_KEY: &str = "default_text_model";
let Some(value) = doc
.get(ROOT_KEY)
.and_then(toml_edit::Item::as_str)
.map(str::to_owned)
else {
return Ok(());
};
let mut switched = crate::config::parse_config_base(&doc.to_string())
.map_err(|_| anyhow::anyhow!("Could not parse switched route; contents omitted"))?;
crate::plugins::providers::apply_startup_providers(&mut switched)?;
// `Config::validate` is the single authority on what the incoming route can
// serve, so a writer cannot disagree with the loader. Act only when this
// alias is what the loader rejects: a document already broken for an
// unrelated reason is not this writer's to rewrite.
let mut without_alias = switched.clone();
without_alias.default_text_model = None;
if switched
.provider_config_for(incoming)
.and_then(|entry| entry.model.as_deref())
.is_some()
|| switched.validate().is_ok()
|| without_alias.validate().is_err()
{
return Ok(());
}
// An empty or control-bearing alias names no saved model. Nothing to
// relocate, and clearing it loses nothing.
if value.trim().is_empty() || value.chars().any(char::is_control) {
unset_root_model_aliases(doc)?;
return Ok(());
}
// No leaf can hold this value. Keep the only copy of the user's choice
// rather than discard it for a document the incoming route loads as soon as
// it saves a model of its own.
let outgoing = previous.active_provider_identity().ok();
if outgoing.as_ref().is_some_and(|outgoing| {
outgoing != incoming
&& outgoing.provider == ProviderKind::Custom
&& outgoing.persisted_id().is_none()
}) {
// This same-lock canonicalizer copied the root model before moving
// its endpoint. Clear an alias only when that exact outgoing leaf
// proves the choice survives; otherwise retain the refusal.
if outgoing.as_ref().is_some_and(|outgoing| {
previous
.provider_config_for(outgoing)
.and_then(|entry| entry.model.as_deref())
== Some(value.as_str())
}) {
return unset_root_model_aliases(doc);
}
anyhow::bail!(
"Choose a model for the destination provider before switching from a legacy custom connection; the saved configuration was not changed"
);
}
let Some(outgoing) = outgoing.as_ref().filter(|outgoing| *outgoing != incoming) else {
return Ok(());
};
let mut scoped = switched;
scoped
.scope_to_provider_identity(outgoing)
.map_err(anyhow::Error::msg)?;
if scoped
.provider_config_for(outgoing)
.and_then(|entry| entry.model.as_deref())
.is_none()
{
set_verified_provider_model_document(doc, previous, outgoing, &value)?;
}
// The outgoing route either already saved its own choice or has just
// received this one, so the alias is now a shadowed duplicate that only
// blocks the incoming route.
unset_root_model_aliases(doc)?;
Ok(())
}
/// Clear the root model alias: `default_text_model` and the legacy root
/// `model` that `Config::default_model` falls back to when the former is
/// unset. While `default_text_model` is present the legacy key resolves on no
/// route, so removing only the alias would resurrect a stale legacy model on
/// the incoming route (a pass-through route would then use a model nobody
/// chose for it). When the legacy key *is* the alias, the caller has already
/// moved its value onto the outgoing route's leaf.
fn unset_root_model_aliases(doc: &mut toml_edit::DocumentMut) -> anyhow::Result<()> {
unset_document_value(doc, &["default_text_model"])?;
unset_document_value(doc, &["model"])?;
Ok(())
}
/// Atomically replace `path` with `body` via a same-directory temp file and
/// rename. On Unix the file lands with 0o600 permissions: config.toml can
/// hold API keys, so this matches `ConfigStore::save` and the auth save path.
@@ -391,7 +222,7 @@ pub(crate) fn write_config_toml_atomic(path: &Path, body: &str) -> anyhow::Resul
///
/// Segments are separate strings rather than one dotted key, so table names
/// that need quoting (`[providers."my.provider"]`) resolve correctly.
pub(crate) fn set_document_value(
pub fn set_document_value(
doc: &mut toml_edit::DocumentMut,
segments: &[&str],
value: impl Into<toml_edit::Value>,
@@ -401,7 +232,7 @@ pub(crate) fn set_document_value(
/// Remove the value at `segments`. Returns `Ok(true)` when an entry was
/// removed; missing keys and missing (or non-table) parents are a no-op.
pub(crate) fn unset_document_value(
pub fn unset_document_value(
doc: &mut toml_edit::DocumentMut,
segments: &[&str],
) -> anyhow::Result<bool> {
@@ -477,7 +308,7 @@ fn leading_prefix_for_key(
})
}
pub(crate) fn persist_status_items(items: &[StatusItem]) -> anyhow::Result<PathBuf> {
pub fn persist_status_items(items: &[StatusItem]) -> anyhow::Result<PathBuf> {
let path = config_toml_path(None)?;
let items: toml_edit::Array = items.iter().map(|item| item.key()).collect();
mutate_config_document(&path, |doc| {
@@ -486,7 +317,7 @@ pub(crate) fn persist_status_items(items: &[StatusItem]) -> anyhow::Result<PathB
Ok(path)
}
pub(crate) fn persist_root_string_key(
pub fn persist_root_string_key(
config_path: Option<&Path>,
key: &str,
value: &str,
@@ -496,16 +327,13 @@ pub(crate) fn persist_root_string_key(
Ok(path)
}
pub(crate) fn persist_unset_root_key(
config_path: Option<&Path>,
key: &str,
) -> anyhow::Result<PathBuf> {
pub fn persist_unset_root_key(config_path: Option<&Path>, key: &str) -> anyhow::Result<PathBuf> {
let path = config_toml_path(config_path)?;
mutate_config_document(&path, |doc| unset_document_value(doc, &[key]).map(|_| ()))?;
Ok(path)
}
pub(crate) fn persist_root_bool_key(
pub fn persist_root_bool_key(
config_path: Option<&Path>,
key: &str,
value: bool,
@@ -515,7 +343,7 @@ pub(crate) fn persist_root_bool_key(
Ok(path)
}
pub(crate) fn persist_subagents_bool_key(
pub fn persist_subagents_bool_key(
config_path: Option<&Path>,
key: &str,
value: bool,
@@ -523,7 +351,7 @@ pub(crate) fn persist_subagents_bool_key(
persist_table_value_key(config_path, "subagents", key, value.into())
}
pub(crate) fn persist_mini_window_bool_key(
pub fn persist_mini_window_bool_key(
config_path: Option<&Path>,
key: &str,
value: bool,
@@ -531,7 +359,7 @@ pub(crate) fn persist_mini_window_bool_key(
persist_table_value_key(config_path, "mini_window", key, value.into())
}
pub(crate) fn persist_subagents_integer_key(
pub fn persist_subagents_integer_key(
config_path: Option<&Path>,
key: &str,
value: u64,
@@ -540,7 +368,7 @@ pub(crate) fn persist_subagents_integer_key(
persist_table_value_key(config_path, "subagents", key, value.into())
}
pub(crate) fn persist_table_bool_key(
pub fn persist_table_bool_key(
config_path: Option<&Path>,
table_name: &str,
key: &str,
@@ -549,7 +377,7 @@ pub(crate) fn persist_table_bool_key(
persist_table_value_key(config_path, table_name, key, value.into())
}
pub(crate) fn persist_table_string_key(
pub fn persist_table_string_key(
config_path: Option<&Path>,
table_name: &str,
key: &str,
@@ -558,7 +386,7 @@ pub(crate) fn persist_table_string_key(
persist_table_value_key(config_path, table_name, key, value.into())
}
pub(crate) fn persist_table_value_key(
pub fn persist_table_value_key(
config_path: Option<&Path>,
table_name: &str,
key: &str,
@@ -578,7 +406,7 @@ pub(crate) fn persist_table_value_key(
/// built-in, the exact `[providers.<name>]` table for a custom route. There is
/// no top-level `base_url` any more (#6394); writing one used to hand a
/// DeepSeek endpoint to every route that inherited it.
pub(crate) fn persist_route_base_url(
pub fn persist_route_base_url(
config_path: Option<&Path>,
identity: &ProviderIdentity,
value: &str,
@@ -606,7 +434,7 @@ pub(crate) fn persist_route_base_url(
/// Built-in providers write to their typed `[providers.<name>]` table, while
/// named custom routes use their exact user-owned table id. The locked writer first preserves legacy root provenance, then writes the
/// canonical leaf created by its one-way migration.
pub(crate) fn persist_provider_model_key(
pub fn persist_provider_model_key(
config_path: Option<&Path>,
identity: &ProviderIdentity,
value: &str,
@@ -627,7 +455,7 @@ fn provider_base_url_table_key(identity: &ProviderIdentity) -> anyhow::Result<&'
.context("custom providers store base_url in their named [providers.<name>] table")
}
pub(crate) fn persist_custom_provider(
pub fn persist_custom_provider(
config_path: Option<&Path>,
provider_id: &str,
base_url: &str,
@@ -717,7 +545,7 @@ fn normalize_optional_custom_provider_field(raw: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_string())
}
pub(crate) fn persist_hotbar_bindings(
pub fn persist_hotbar_bindings(
config_path: Option<&Path>,
bindings: &[codewhale_config::HotbarBindingToml],
) -> anyhow::Result<PathBuf> {
@@ -748,7 +576,7 @@ pub(crate) fn persist_hotbar_bindings(
Ok(path)
}
pub(crate) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<PathBuf> {
pub fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<PathBuf> {
if let Some(path) = config_path {
return Ok(expand_path(path.to_string_lossy().as_ref()));
}
@@ -758,21 +586,19 @@ pub(crate) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<Pat
#[cfg(test)]
mod tests {
// The fixture resolves the exact declaration from its destination document.
// Persistence still re-reads and validates under the shared write lock.
fn selection_identity(path: &Path, id: &str) -> crate::config::ProviderIdentity {
crate::config::parse_config_base(&fs::read_to_string(path).unwrap())
.unwrap()
.resolve_provider_pin_identity(id)
.unwrap()
}
use super::*;
use std::env;
use std::ffi::OsString;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::SystemTime;
use super::test_support::*;
use std::time::UNIX_EPOCH;
struct EnvGuard {
_home: crate::test_support::EnvVarGuard,
@@ -1567,57 +1393,6 @@ slot = 1
assert_eq!(mode, 0o600, "config.toml can hold api keys");
}
/// Clears every model override the dispatcher's env layer reads for the
/// providers exercised below, so the assertion is about config precedence
/// and cannot be flipped by an ambient variable on a developer machine.
struct ModelEnvGuard {
saved: Vec<(&'static str, Option<OsString>)>,
}
impl ModelEnvGuard {
const VARS: &'static [&'static str] = &[
"CODEWHALE_MODEL",
"DEEPSEEK_MODEL",
"DEEPSEEK_DEFAULT_TEXT_MODEL",
"GLM_MODEL",
"BIGMODEL_MODEL",
"ZAI_MODEL",
"XAI_MODEL",
"GROK_MODEL",
"OPENROUTER_MODEL",
"OLLAMA_MODEL",
];
fn new() -> Self {
let saved = Self::VARS
.iter()
.map(|name| (*name, env::var_os(name)))
.collect();
// Safety: test-only environment mutation; the caller holds the
// process-wide test-env lock via `EnvGuard`.
unsafe {
for name in Self::VARS {
env::remove_var(name);
}
}
Self { saved }
}
}
impl Drop for ModelEnvGuard {
fn drop(&mut self) {
// Safety: test-only environment restoration under the same lock.
unsafe {
for (name, value) in &self.saved {
match value {
Some(value) => env::set_var(name, value),
None => env::remove_var(name),
}
}
}
}
}
/// The active model must be one answer, not two.
///
/// The TUI resolves it with `Config::default_model()`, which is what
@@ -1715,466 +1490,7 @@ slot = 1
let _ = fs::remove_dir_all(&temp_root);
}
}
#[test]
fn route_migration_is_atomic_preserves_conflicts_and_ignores_scoped_settings() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let project = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _scope = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", project.path().join("config.toml"));
let path = home.path().join("config.toml");
let source = "# keep this comment\nprovider = 'deepseek'\n[providers.zai]\nmodel = 'GLM-5.2'\n[providers.TeamA]\nkind = 'openai-compatible'\nbase_url = 'https://upper.example.test/v1'\nmodel = 'Old-Upper'\n[providers.teama]\nkind = 'openai-compatible'\nbase_url = 'https://lower.example.test/v1'\nmodel = 'Old-Lower'\n";
let legacy = "default_provider = 'zai'\n[provider_models]\nzai = 'GLM-5.3'\nTeamA = 'New-Upper'\nteama = 'New-Lower'\n";
fs::write(&path, source).unwrap();
fs::write(home.path().join("settings.toml"), legacy).unwrap();
fs::write(
project.path().join("settings.toml"),
"default_provider = 'openai'\n[provider_models]\nzai = 'wrong-scoped-model'\n",
)
.unwrap();
let admitted_source = format!(
"{source}\n[providers.Missing]\nkind = 'openai-compatible'\nbase_url = 'https://missing.example.test/v1'\n"
);
let captured_missing = crate::config::parse_config_base(&admitted_source)
.unwrap()
.resolve_provider_pin_identity("Missing")
.unwrap();
let error = persist_provider_selection(Some(&path), &captured_missing, Some("new-model"));
assert!(error.is_err());
assert_eq!(
fs::read_to_string(&path).unwrap(),
source,
"failed save must not commit migration separately"
);
persist_provider_selection(
Some(&path),
&selection_identity(&path, "deepseek"),
Some("deepseek-v4-pro"),
)
.unwrap();
let body = fs::read_to_string(&path).unwrap();
let doc: toml::Value = toml::from_str(&body).unwrap();
assert!(body.contains("# keep this comment"));
assert_eq!(doc["route_preferences_version"].as_integer(), Some(1));
assert_eq!(doc["provider"].as_str(), Some("deepseek"));
assert_eq!(
doc["providers"]["deepseek"]["model"].as_str(),
Some("deepseek-v4-pro")
);
assert_eq!(doc["providers"]["zai"]["model"].as_str(), Some("GLM-5.3"));
assert_eq!(
doc["providers"]["TeamA"]["model"].as_str(),
Some("New-Upper")
);
assert_eq!(
doc["providers"]["teama"]["model"].as_str(),
Some("New-Lower")
);
assert_eq!(
doc["providers"]["TeamA"]["base_url"].as_str(),
Some("https://upper.example.test/v1")
);
assert_eq!(
doc["route_preferences_migration"]["previous_models"]["zai"].as_str(),
Some("GLM-5.2")
);
assert_eq!(
fs::read_to_string(home.path().join("settings.toml")).unwrap(),
legacy
);
persist_provider_model_key(Some(&path), &selection_identity(&path, "zai"), "GLM-5.1")
.unwrap();
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(doc["providers"]["zai"]["model"].as_str(), Some("GLM-5.1"));
assert_eq!(
doc["route_preferences_migration"]["previous_models"]["zai"].as_str(),
Some("GLM-5.2")
);
}
#[test]
fn moving_root_default_off_the_root_does_not_resurrect_a_shadowed_legacy_model() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _model_guard = ModelEnvGuard::new();
let path = home.path().join("config.toml");
// `default_text_model` shadows the legacy root `model` on every route.
// Moving the alias onto openai's leaf must not un-shadow the legacy
// value for the pass-through incoming route.
fs::write(
&path,
"route_preferences_version = 1\nprovider = 'openai'\nmodel = 'deepseek-v4-flash'\ndefault_text_model = 'gpui-fixture'\n",
)
.unwrap();
persist_provider_selection(Some(&path), &selection_identity(&path, "openrouter"), None)
.unwrap();
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(doc.get("default_text_model").is_none(), "{doc:?}");
assert!(doc.get("model").is_none(), "{doc:?}");
assert_eq!(
doc["providers"]["openai"]["model"].as_str(),
Some("gpui-fixture")
);
let switched = crate::config::Config::load(Some(path.clone()), None).unwrap();
assert_eq!(
switched.active_provider_identity().unwrap().provider,
ProviderKind::Openrouter
);
// OpenRouter normalizes the stale value to `deepseek/deepseek-v4-flash`.
assert!(
!switched.default_model().contains("deepseek-v4-flash"),
"{}",
switched.default_model()
);
assert_ne!(switched.default_model(), "gpui-fixture");
persist_provider_selection(Some(&path), &selection_identity(&path, "openai"), None)
.unwrap();
let returned = crate::config::Config::load(Some(path.clone()), None).unwrap();
assert_eq!(
returned.active_provider_identity().unwrap().provider,
ProviderKind::Openai
);
assert_eq!(returned.default_model(), "gpui-fixture");
}
#[test]
fn switching_provider_away_and_back_preserves_every_route_selection() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _model_guard = ModelEnvGuard::new();
let path = home.path().join("config.toml");
for root_key in ["default_text_model", "model"] {
for existing in [None, Some("GLM-4.5-Air")] {
let mut source = format!(
"route_preferences_version = 1\nprovider = 'zai'\n{root_key} = 'GLM-4.6'\n"
);
if let Some(model) = existing {
source.push_str(&format!("[providers.zai]\nmodel = '{model}'\n"));
}
fs::write(&path, source).unwrap();
persist_provider_selection(
Some(&path),
&selection_identity(&path, "deepseek"),
Some("deepseek-v4-pro"),
)
.unwrap();
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
// The outgoing route was resolving the root alias only when it
// had no leaf; then the choice moves onto that leaf. Otherwise
// the alias is inert saved state and stays. Neither case may
// delete it.
if existing.is_none() {
assert!(doc.get(root_key).is_none(), "{doc:?}");
assert_eq!(
doc["providers"]["zai"]["model"].as_str(),
Some("GLM-4.6"),
"the outgoing route's choice must follow it onto its leaf"
);
} else {
assert_eq!(
doc[root_key].as_str(),
Some("GLM-4.6"),
"an inert root fallback must survive the switch"
);
}
assert_eq!(
doc["providers"]["deepseek"]["model"].as_str(),
Some("deepseek-v4-pro")
);
let restored = crate::config::Config::load(Some(path.clone()), None)
.expect("saved provider switch must remain loadable");
assert_eq!(
restored.active_provider_identity().unwrap().provider,
ProviderKind::Deepseek
);
assert_eq!(restored.default_model(), "deepseek-v4-pro");
// Switching back must find the choice this route had, whether
// it was stored on its own leaf or in the root fallback.
persist_provider_selection(Some(&path), &selection_identity(&path, "zai"), None)
.unwrap();
let returned = crate::config::Config::load(Some(path.clone()), None)
.expect("switching back must remain loadable");
assert_eq!(
returned.active_provider_identity().unwrap().provider,
ProviderKind::Zai
);
assert_eq!(
returned.default_model(),
existing.unwrap_or("GLM-4.6"),
"switching away and back must restore the outgoing selection"
);
}
}
}
#[test]
fn a_bare_switch_relocates_a_root_alias_the_incoming_route_cannot_serve() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _model_guard = ModelEnvGuard::new();
let path = home.path().join("config.toml");
// No model argument, and the outgoing route keeps its only model in the
// root fallback. Official DeepSeek cannot serve that id, so leaving it
// behind would commit a switch whose config no longer loads.
fs::write(
&path,
"route_preferences_version = 1\nprovider = 'volcengine'\ndefault_text_model = 'ark-private-id'\n",
)
.unwrap();
persist_provider_selection(Some(&path), &selection_identity(&path, "deepseek"), None)
.unwrap();
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(
doc.get("default_text_model").is_none(),
"an alias the incoming route cannot serve must not be left behind"
);
assert_eq!(
doc["providers"]["volcengine"]["model"].as_str(),
Some("ark-private-id"),
"the displaced choice moves onto its own route's leaf, it is not dropped"
);
crate::config::Config::load(Some(path.clone()), None)
.expect("a bare provider switch must remain loadable");
persist_provider_selection(Some(&path), &selection_identity(&path, "volcengine"), None)
.unwrap();
let returned = crate::config::Config::load(Some(path.clone()), None)
.expect("switching back must remain loadable");
assert_eq!(
returned.active_provider_identity().unwrap().provider,
ProviderKind::Volcengine
);
assert_eq!(returned.default_model(), "ark-private-id");
}
#[test]
fn bare_switch_from_legacy_custom_commits_a_loadable_config() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _model_guard = ModelEnvGuard::new();
let path = home.path().join("config.toml");
let original = "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://proxy.example.test/v1'\ndefault_text_model = 'proxy-wire-id'\n[providers.deepseek]\nbase_url = 'https://api.deepseek.com/beta'\n";
fs::write(&path, original).unwrap();
crate::config::Config::load(Some(path.clone()), None).unwrap();
// The write moves the top-level route into `[providers.custom]`,
// model included (#6394), so the custom model is no longer the
// switch's only copy and a bare switch commits a loadable config.
persist_provider_selection(Some(&path), &selection_identity(&path, "deepseek"), None)
.unwrap();
let switched = crate::config::Config::load(Some(path.clone()), None)
.expect("a bare provider switch must remain loadable");
assert_eq!(
switched.active_provider_identity().unwrap().provider,
ProviderKind::Deepseek
);
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
doc["providers"]["custom"]["model"].as_str(),
Some("proxy-wire-id")
);
assert_eq!(
doc["providers"]["custom"]["base_url"].as_str(),
Some("https://proxy.example.test/v1")
);
}
#[test]
fn an_unnamed_custom_route_keeps_its_root_model_across_a_switch() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let _model_guard = ModelEnvGuard::new();
let path = home.path().join("config.toml");
// The one-way root migration copies the model to the exact custom
// leaf. A switch with its own model retains the historical root alias
// too; both choices survive without granting missing-id table authority.
fs::write(
&path,
"route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://proxy.example.test/v1'\ndefault_text_model = 'proxy-wire-id'\n",
)
.unwrap();
persist_provider_selection(
Some(&path),
&selection_identity(&path, "deepseek"),
Some("deepseek-v4-pro"),
)
.unwrap();
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
doc["default_text_model"].as_str(),
Some("proxy-wire-id"),
"the custom route's only saved model must survive the switch"
);
let restored = crate::config::Config::load(Some(path.clone()), None)
.expect("the switched config must remain loadable");
assert_eq!(restored.default_model(), "deepseek-v4-pro");
// Nothing can re-select an unnamed custom route by identity once the
// selector names another provider, so restoring it is a `provider`
// edit. The model has to still be there when it happens.
let returning = fs::read_to_string(&path)
.unwrap()
.replace("provider = 'deepseek'", "provider = 'custom'")
.replace("provider = \"deepseek\"", "provider = 'custom'");
fs::write(&path, returning).unwrap();
let returned = crate::config::Config::load(Some(path.clone()), None)
.expect("returning to the custom route must remain loadable");
assert_eq!(returned.default_model(), "proxy-wire-id");
}
#[test]
fn canonical_model_writer_targets_the_literal_custom_table_and_exact_named_ids() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let path = home.path().join("config.toml");
fs::write(&path, "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'https://legacy.example.test/v1'\ndefault_text_model = 'old-wire-id'\n").unwrap();
persist_provider_selection(
Some(&path),
&selection_identity(&path, "custom"),
Some("Exact-New-ID"),
)
.unwrap();
// The literal route is the `[providers.custom]` table since #6394.
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
doc["providers"]["custom"]["model"].as_str(),
Some("Exact-New-ID")
);
assert!(doc.get("base_url").is_none());
fs::write(&path, "route_preferences_version = 1\nprovider = 'Team.A'\n[providers.'Team.A']\nkind = 'openai-compatible'\nbase_url = 'https://named.example.test/v1'\nmodel = 'old'\n").unwrap();
persist_provider_selection(
Some(&path),
&selection_identity(&path, "Team.A"),
Some("Exact-Named-ID"),
)
.unwrap();
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
doc["providers"]["Team.A"]["model"].as_str(),
Some("Exact-Named-ID")
);
let admitted_lower = crate::config::parse_config_base(
"provider = 'team.a'\n[providers.'team.a']\nkind = 'openai-compatible'\nbase_url = 'https://lower.example.test/v1'\n"
).unwrap().resolve_provider_pin_identity("team.a").unwrap();
let before_wrong_case = fs::read_to_string(&path).unwrap();
assert!(persist_provider_selection(Some(&path), &admitted_lower, Some("wrong")).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), before_wrong_case);
persist_provider_model_key(
Some(&path),
&selection_identity(&path, "deepseek-cn"),
"deepseek-v4-pro",
)
.unwrap();
let doc: toml::Value = toml::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(
doc["providers"]["deepseek_cn"]["model"].as_str(),
Some("deepseek-v4-pro")
);
}
#[test]
fn malformed_legacy_preferences_do_not_partially_commit_a_route_save() {
use crate::test_support::{EnvVarGuard, lock_test_env};
let _lock = lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = EnvVarGuard::set("CODEWHALE_HOME", home.path());
let path = home.path().join("config.toml");
let source = "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n";
fs::write(&path, source).unwrap();
let settings_path = home.path().join("settings.toml");
let malformed = "default_provider = [\n";
fs::write(&settings_path, malformed).unwrap();
let error = persist_provider_selection(
Some(&path),
&selection_identity(&path, "zai"),
Some("GLM-5.3"),
)
.expect_err("unreadable legacy preferences must block the entire save");
assert!(error.to_string().contains("configuration was not changed"));
assert_eq!(fs::read_to_string(&path).unwrap(), source);
assert_eq!(fs::read_to_string(&settings_path).unwrap(), malformed);
}
#[test]
fn fresh_locked_root_migration_admits_only_the_captured_table_and_keeps_undo_cas() {
let _lock = crate::test_support::lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let path = home.path().join("config.toml");
let original = "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\n";
fs::write(&path, original).unwrap();
let captured = crate::config::parse_config_base(original)
.unwrap()
.active_provider_identity()
.unwrap();
assert!(captured.persisted_id().is_none());
let (_, undo) =
persist_provider_selection(Some(&path), &captured, Some("selected-model")).unwrap();
let saved = fs::read_to_string(&path).unwrap();
let parsed = crate::config::parse_config_base(&saved).unwrap();
assert_eq!(parsed.default_model(), "selected-model");
assert_eq!(
parsed
.provider_config_for(&parsed.active_provider_identity().unwrap())
.unwrap()
.model
.as_deref(),
Some("selected-model")
);
let newer = saved.replace("selected-model", "newer-model");
fs::write(&path, &newer).unwrap();
assert!(!undo.undo().unwrap());
assert_eq!(fs::read_to_string(&path).unwrap(), newer);
}
#[test]
fn locked_root_write_refuses_table_only_profile_conflict_and_changed_generation() {
let _lock = crate::test_support::lock_test_env();
let home = tempfile::tempdir().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", home.path());
let path = home.path().join("config.toml");
let root = "route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\n";
let captured = crate::config::parse_config_base(root)
.unwrap()
.active_provider_identity()
.unwrap();
for source in [
"route_preferences_version = 1\nprovider = 'custom'\n[providers.custom]\nbase_url = 'http://127.0.0.1:18180/v1'\nmodel = 'old-model'\n",
"route_preferences_version = 1\nprovider = 'deepseek'\n[profiles.dev]\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\n",
"route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\n[providers.custom]\nbase_url = 'http://127.0.0.1:19191/v1'\n",
"route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'changed-model'\n",
"route_preferences_version = 1\nprovider = 'custom'\nbase_url = 'http://127.0.0.1:18180/v1'\ndefault_text_model = 'old-model'\napi_key = 'changed-key'\n",
] {
fs::write(&path, source).unwrap();
assert!(
persist_provider_selection(Some(&path), &captured, Some("must-not-save")).is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), source);
assert!(persist_provider_model_key(Some(&path), &captured, "must-not-save").is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), source);
assert!(
persist_route_base_url(Some(&path), &captured, "http://127.0.0.1:22222/v1")
.is_err()
);
assert_eq!(fs::read_to_string(&path).unwrap(), source);
}
}
}
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
@@ -0,0 +1,64 @@
use super::*;
use std::env;
use std::ffi::OsString;
use std::fs;
// The fixture resolves the exact declaration from its destination document.
// Persistence still re-reads and validates under the shared write lock.
pub fn selection_identity(path: &Path, id: &str) -> crate::config::ProviderIdentity {
crate::config::parse_config_base(&fs::read_to_string(path).unwrap())
.unwrap()
.resolve_provider_pin_identity(id)
.unwrap()
}
/// Clears every model override the dispatcher's env layer reads for the
/// providers exercised below, so the assertion is about config precedence
/// and cannot be flipped by an ambient variable on a developer machine.
pub struct ModelEnvGuard {
saved: Vec<(&'static str, Option<OsString>)>,
}
impl ModelEnvGuard {
const VARS: &'static [&'static str] = &[
"CODEWHALE_MODEL",
"DEEPSEEK_MODEL",
"DEEPSEEK_DEFAULT_TEXT_MODEL",
"GLM_MODEL",
"BIGMODEL_MODEL",
"ZAI_MODEL",
"XAI_MODEL",
"GROK_MODEL",
"OPENROUTER_MODEL",
"OLLAMA_MODEL",
];
pub fn new() -> Self {
let saved = Self::VARS
.iter()
.map(|name| (*name, env::var_os(name)))
.collect();
// Safety: test-only environment mutation; the caller holds the
// process-wide test-env lock via `EnvGuard`.
unsafe {
for name in Self::VARS {
env::remove_var(name);
}
}
Self { saved }
}
}
impl Drop for ModelEnvGuard {
fn drop(&mut self) {
// Safety: test-only environment restoration under the same lock.
unsafe {
for (name, value) in &self.saved {
match value {
Some(value) => env::set_var(name, value),
None => env::remove_var(name),
}
}
}
}
}
+15 -1
View File
@@ -1,6 +1,6 @@
//! Codewhale runtime: the headless half of the runtime/TUI split.
//!
//! Modules move here from `crates/tui/src` with `git mv`, in the order
//! Modules move here from `crates/tui/src` by explicit path, in the order
//! `docs/design/TUI_DECONSTRUCTION.md` records. The TUI calls them by their
//! `codewhale_runtime::<module>` paths; there is no alias block.
//!
@@ -50,3 +50,17 @@ pub mod turn_budget;
pub mod workspace_discovery;
pub const CODEWHALE_MAIN_STACK_BYTES: usize = 32 * 1024 * 1024;
pub mod catalog_cache;
pub mod codex_model_roster;
pub mod config;
pub mod config_keys;
pub mod config_persistence;
pub mod oauth;
pub mod settings;
File diff suppressed because it is too large. Load diff
+144
View File
@@ -0,0 +1,144 @@
use super::*;
use std::sync::Mutex;
pub(super) type MockForm = Vec<(String, String)>;
pub(super) type MockPost = (String, MockForm);
pub(super) struct SiwcSigningFixture {
pair: ring::signature::Ed25519KeyPair,
pub(super) keys: JwkSet,
}
impl SiwcSigningFixture {
pub(super) fn new() -> Self {
use ring::signature::KeyPair as _;
let pkcs8 =
ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new())
.unwrap();
let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
let keys = serde_json::from_value(serde_json::json!({"keys": [{
"kty": "OKP", "crv": "Ed25519", "kid": "siwc-fixture", "alg": "EdDSA", "use": "sig",
"x": URL_SAFE_NO_PAD.encode(pair.public_key().as_ref())
}]}))
.unwrap();
Self { pair, keys }
}
pub(super) fn token(&self, claims: Value) -> String {
let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","kid":"siwc-fixture"}"#);
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
let message = format!("{header}.{payload}");
format!(
"{message}.{}",
URL_SAFE_NO_PAD.encode(self.pair.sign(message.as_bytes()).as_ref())
)
}
#[cfg(test)]
pub(super) fn id_claims(&self) -> Value {
serde_json::json!({"iss": CHATGPT_OAUTH_ISSUER, "aud": "oaiapp_codewhale_test", "sub": "test-sub", "nonce": "pending-nonce", "exp": now_unix_secs().unwrap() + 3600})
}
pub(super) fn access(&self, subject: &str) -> String {
self.token(serde_json::json!({"iss": CHATGPT_OAUTH_ISSUER, "aud": CHATGPT_OAUTH_RESOURCE, "sub": subject, "client_id": "oaiapp_codewhale_test", "scope": CHATGPT_OAUTH_SCOPE, "exp": now_unix_secs().unwrap() + 3600}))
}
}
struct SiwcRefreshClient {
pub(super) keys: JwkSet,
access: String,
posts: Mutex<Vec<MockPost>>,
}
impl OAuthFormClient for SiwcRefreshClient {
fn post_form(&self, url: &str, form: &[(&str, &str)]) -> Result<(u16, String)> {
self.posts.lock().unwrap().push((
url.to_string(),
form.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
));
Ok((200, serde_json::json!({"access_token": self.access, "refresh_token": "rotated-refresh", "expires_in": 3600, "scope": CHATGPT_OAUTH_SCOPE, "token_type": "Bearer"}).to_string()))
}
fn chatgpt_jwks(&self, _: &str) -> Result<JwkSet> {
Ok(self.keys.clone())
}
}
pub fn siwc_refresh_rotates_atomically_and_rejects_a_different_account<
T: PartialEq + std::fmt::Debug,
>(
scope_of: impl Fn(&Config) -> T,
) {
let _lock = crate::test_support::lock_test_env();
let home = tempfile::tempdir().unwrap();
let root = home.path().canonicalize().unwrap();
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &root);
let signer = SiwcSigningFixture::new();
let mut config = Config::default();
config.provider = Some("openai-codex".into());
install_test_chatgpt_registration(&mut config).unwrap();
let original = get_owned_credentials_read_only(OAuthProvider::Chatgpt, &config).unwrap();
let original_scope = scope_of(&config);
let path = configured_owned_auth_file_path(OAuthProvider::Chatgpt, &config)
.unwrap()
.unwrap();
let name = path.file_name().unwrap().to_str().unwrap();
codewhale_config::with_xai_oauth_lifecycle_lock(|store| {
let mut file = load_owned_auth_file_from_store(store, name)?.unwrap();
for entry in file.values_mut() {
entry.expires_at = Some("2000-01-01T00:00:00Z".to_string());
}
write_auth_file_to_store(store, name, &file, true)
})
.unwrap();
let before = std::fs::read(&path).unwrap();
let wrong = SiwcRefreshClient {
keys: signer.keys.clone(),
access: signer.access("another-account"),
posts: Mutex::new(Vec::new()),
};
assert!(get_owned_credentials_with(OAuthProvider::Chatgpt, &config, &wrong).is_err());
assert_eq!(std::fs::read(&path).unwrap(), before);
let right = SiwcRefreshClient {
keys: signer.keys.clone(),
access: signer.access("test-sub"),
posts: Mutex::new(Vec::new()),
};
let refreshed = get_owned_credentials_with(OAuthProvider::Chatgpt, &config, &right).unwrap();
assert_ne!(original.access_token, refreshed.access_token);
assert_eq!(original_scope, scope_of(&config));
assert_eq!(refreshed.refresh_token.as_deref(), Some("rotated-refresh"));
assert_eq!(
official_chatgpt_registration(&config).unwrap().subject,
"test-sub"
);
assert!(get_owned_credentials_read_only(OAuthProvider::Chatgpt, &config).is_ok());
let posts = right.posts.lock().unwrap();
assert_eq!(
posts[0].0,
"https://auth.openai.com/api/accounts/oauth/token"
);
assert!(
posts[0]
.1
.contains(&("client_id".to_string(), "oaiapp_codewhale_test".to_string()))
);
assert!(
posts[0]
.1
.contains(&("resource".to_string(), CHATGPT_OAUTH_RESOURCE.to_string()))
);
assert!(
posts[0]
.1
.contains(&("refresh_token".to_string(), "siwc-test-refresh".to_string()))
);
}
pub fn descriptor() -> PluginOAuthConfig {
PluginOAuthConfig {
issuer: "https://issuer.example".into(),
authorization_endpoint: "https://issuer.example/authorize".into(),
token_endpoint: "https://issuer.example/token".into(),
client_id: "public-client".into(),
scopes: vec!["models:invoke".into()],
resource: Some("https://api.example/oauth".into()),
callback_path: plugin_callback_path(),
}
}
@@ -25,8 +25,8 @@ use codewhale_config::settings_schema::DEFAULT_TUI_THEME;
/// goal / to-do / Agent projection without turning the rail into invisible
/// keyboard state. Older releases accepted two rows, which left only one
/// content row and could hide every actionable item behind the goal title.
pub(crate) const WORK_SURFACE_TOP_HEIGHT_MIN: u16 = 5;
pub(crate) const WORK_SURFACE_TOP_HEIGHT_MAX: u16 = 16;
pub const WORK_SURFACE_TOP_HEIGHT_MIN: u16 = 5;
pub const WORK_SURFACE_TOP_HEIGHT_MAX: u16 = 16;
const TUI_PREFS_FILE_NAME: &str = "tui.toml";
/// How successful structured file mutations are represented in the live
@@ -250,7 +250,7 @@ pub struct Settings {
/// preference. Runtime defaults must not be written back as user intent
/// when an unrelated setting is saved.
#[serde(skip)]
pub(crate) auto_compact_explicit: bool,
pub auto_compact_explicit: bool,
/// Reduce status noise and collapse details more aggressively
pub calm_mode: bool,
/// Use the pet as the main view on the next launch.
@@ -365,7 +365,7 @@ pub struct Settings {
/// Show a border around the composer input area
pub composer_border: bool,
/// Keep bare Enter available for multiline drafting. When enabled,
/// Shift+Enter submits; Ctrl+J and Alt+Enter remain newline shortcuts.
/// Alt+Enter and supported Shift+Enter submit; Ctrl+J remains a newline shortcut.
#[serde(default)]
pub composer_multiline_mode: bool,
/// Composer editing mode: "normal" (default) or "vim" for modal editing.
@@ -516,18 +516,18 @@ pub struct Settings {
/// the old bundled Full Access choice without weakening project or managed
/// approval policy. It is never written back to disk.
#[serde(skip)]
pub(crate) legacy_yolo_default: bool,
pub legacy_yolo_default: bool,
/// Receipt for the one-time `tui.toml` fold performed by this load.
/// Never serialized: it describes what happened to a file, not a setting.
#[serde(skip)]
pub(crate) tui_prefs_migration: Option<TuiPrefsMigration>,
pub tui_prefs_migration: Option<TuiPrefsMigration>,
/// Which layer supplied the in-force value of each schema key: user
/// config for keys `settings.toml` named at load, the default for the
/// rest, session for keys `set()` touched since. Runtime only — the
/// resolver reads it, disk never sees it. CLI flags (2D) and managed
/// policy / project producers mark their own layers when they land.
#[serde(skip)]
pub(crate) provenance: std::collections::BTreeMap<String, Layer>,
pub provenance: std::collections::BTreeMap<String, Layer>,
}
impl Default for Settings {
@@ -765,7 +765,7 @@ impl Settings {
/// environment overlays as [`Settings::load`]. Unlike an interactive
/// startup, diagnostics must not create `~/.codewhale/settings.toml` just
/// because they inspected a legacy `~/.deepseek/settings.toml` file.
pub(crate) fn load_read_only() -> Result<Self> {
pub fn load_read_only() -> Result<Self> {
let mut settings = Self::load_persisted_read_only()?;
settings.apply_env_overrides();
Ok(settings)
@@ -775,7 +775,7 @@ impl Settings {
///
/// Canonical config migration must not inherit a project config's sibling
/// settings or runtime environment overlays, and must not migrate files.
pub(crate) fn load_legacy_route_preferences_read_only() -> Result<Self> {
pub fn load_legacy_route_preferences_read_only() -> Result<Self> {
let (primary, legacy_home, legacy_config_dir) = settings_path_candidates_for_scope(false);
let settings = Self::load_persisted_from_candidates_with_migration(
primary,
@@ -792,7 +792,7 @@ impl Settings {
/// Load the normalized values stored on disk without terminal/runtime
/// overlays. Configuration editors use this path so a value labelled
/// "saved" never silently reports a tmux, SSH, or accessibility override.
pub(crate) fn load_persisted() -> Result<Self> {
pub fn load_persisted() -> Result<Self> {
with_settings_transaction(SettingsTransaction::load)
}
@@ -834,7 +834,7 @@ impl Settings {
legacy_config_dir: Option<PathBuf>,
migrate_legacy_file: bool,
) -> Result<Self> {
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
crate::test_support::with_test_state_io_lock(|| {
Self::load_persisted_from_candidates_with_migration_unlocked(
@@ -845,7 +845,7 @@ impl Settings {
)
})
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
Self::load_persisted_from_candidates_with_migration_unlocked(
primary,
legacy_home,
@@ -1045,12 +1045,12 @@ impl Settings {
///
/// This is migration provenance, not a user-facing mode. New writes accept
/// only Agent or Plan and serialize the independent permission posture.
pub(crate) fn legacy_yolo_default_detected(&self) -> bool {
pub fn legacy_yolo_default_detected(&self) -> bool {
self.legacy_yolo_default
}
/// Receipt for the one-time `tui.toml` fold, when this load performed one.
pub(crate) fn tui_prefs_migration(&self) -> Option<&TuiPrefsMigration> {
pub fn tui_prefs_migration(&self) -> Option<&TuiPrefsMigration> {
self.tui_prefs_migration.as_ref()
}
@@ -1058,7 +1058,7 @@ impl Settings {
/// defaulting to [`Layer::Default`] for keys the document never named.
/// Aliases resolve to their canonical key first, so `/set collapse`
/// reports the same layer as the `tool_collapse` row.
pub(crate) fn provenance(&self, key: &str) -> Layer {
pub fn provenance(&self, key: &str) -> Layer {
let canonical = Self::canonical_key(key).unwrap_or(key);
self.provenance
.get(canonical)
@@ -1109,13 +1109,13 @@ impl Settings {
/// model-aware default.
pub fn auto_compact_explicitly_configured() -> bool {
let candidates = settings_path_candidates();
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
crate::test_support::with_test_state_io_lock(|| {
auto_compact_explicitly_configured_from_candidates(candidates)
})
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
auto_compact_explicitly_configured_from_candidates(candidates)
}
}
@@ -1375,7 +1375,7 @@ impl Settings {
/// Not callable from inside a transaction: the cross-process lock is not
/// re-entrant, so a nested acquisition would deadlock against itself. Inside
/// a critical section use [`SettingsTransaction::save`].
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn save(&self) -> Result<()> {
with_settings_transaction(|transaction| transaction.save(self))
}
@@ -1384,11 +1384,11 @@ impl Settings {
/// replace the file atomically. The caller already holds both the
/// process-wide mutex and the cross-process file lock.
fn save_locked(&self, path: &Path) -> Result<()> {
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
crate::test_support::with_test_state_io_lock(|| self.save_to_path(path))
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
self.save_to_path(path)
}
@@ -1431,7 +1431,7 @@ impl Settings {
/// match arm below. `None` means `set()` rejects the spelling, so the
/// ledger never learns it. Keep in sync with the arms — the
/// `set_marks_session_provenance` test enforces it per spelling.
pub(crate) fn canonical_key(key: &str) -> Option<&'static str> {
pub fn canonical_key(key: &str) -> Option<&'static str> {
Some(match key {
"auto_compact" | "compact" => "auto_compact",
"auto_compact_threshold" | "auto_compact_threshold_percent" => {
@@ -1805,7 +1805,7 @@ impl Settings {
/// Get all settings as a displayable string
/// Test-only whole-report renderer; the command renders from [`Self::command_report`].
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub fn display(&self, locale: codewhale_localization::Locale) -> String {
use codewhale_localization::{MessageId, tr};
let report = self.command_report();
@@ -2210,18 +2210,18 @@ fn resolve_settings_path_from_candidates(
/// Only [`with_settings_transaction`] can hand one out, so a `load`/`save` pair
/// on this type is by construction covered by both the process-wide mutex and
/// the cross-process file lock.
pub(crate) struct SettingsTransaction {
pub struct SettingsTransaction {
path: PathBuf,
}
impl SettingsTransaction {
/// Read the on-disk values inside the critical section.
pub(crate) fn load(&self) -> Result<Settings> {
pub fn load(&self) -> Result<Settings> {
Settings::load_persisted_locked()
}
/// Write the whole file inside the critical section.
pub(crate) fn save(&self, settings: &Settings) -> Result<()> {
pub fn save(&self, settings: &Settings) -> Result<()> {
settings.save_locked(&self.path)
}
}
@@ -2272,7 +2272,7 @@ impl SettingsTransaction {
///
/// Neither lock is re-entrant. `operation` must not call back into `transact`,
/// `Settings::save`, or this function.
pub(crate) fn with_settings_transaction<T>(
pub fn with_settings_transaction<T>(
operation: impl FnOnce(&SettingsTransaction) -> Result<T>,
) -> Result<T> {
let path = Settings::path()?;
@@ -2500,7 +2500,7 @@ fn settings_transaction_mutex(path: &Path) -> &'static std::sync::Mutex<()> {
/// The mutex protects ordering, not an invariant, so a panic inside one
/// transaction must not wedge settings persistence for the rest of the session:
/// a poisoned guard is recovered rather than propagated.
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
fn lock_settings_transaction(
mutex: &'static std::sync::Mutex<()>,
) -> std::sync::MutexGuard<'static, ()> {
@@ -2523,7 +2523,7 @@ fn lock_settings_transaction(
/// held by a deliberately slow child process in the cross-process regressions —
/// so the watchdog only has to be longer than the slowest honest transaction and
/// shorter than a CI job timeout, not tight.
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
fn lock_settings_transaction(
mutex: &'static std::sync::Mutex<()>,
) -> std::sync::MutexGuard<'static, ()> {
@@ -2562,7 +2562,7 @@ fn settings_path_candidates_for_scope(
home_settings_path_candidates_from_environment()
}
};
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
let honor_guarded_environment =
crate::test_support::guarded_environment_provides_state_paths();
@@ -2592,7 +2592,7 @@ fn settings_path_candidates_for_scope(
})
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
from_environment()
}
@@ -2638,11 +2638,11 @@ fn config_override_parent() -> Option<PathBuf> {
None
}
#[cfg(test)]
#[cfg(any(test, all(feature = "test-thread-scoped-state", debug_assertions)))]
{
crate::test_support::with_test_env_lock(read)
}
#[cfg(not(test))]
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
{
read()
}
@@ -3023,6 +3023,15 @@ fn env_truthy(name: &str) -> bool {
mod tests {
use super::*;
/// The shared guard, under this module's historical name.
///
/// It was a byte-for-byte copy of `EnvVarGuard` until #5359 gave the shared
/// one a second job: recording which variables a test actually redirected,
/// so state-path resolution can tell a sealed environment from a test that
/// holds the lock for unrelated reasons. A private copy silently opts every
/// caller here out of that record.
use crate::test_support::EnvVarGuard as EnvVarRestore;
/// The override detector names the same winner `apply_env_overrides`
/// applies: `NO_ANIMATIONS` is first in precedence and is environment,
/// not terminal, authority.
@@ -4050,24 +4059,6 @@ mod tests {
assert!(err.to_string().contains("invalid locale"));
}
#[test]
fn default_settings_resolve_to_the_underwater_theme() {
// The fresh-install default is the Underwater theme, end to end from
// `Settings::default()` through theme resolution.
let settings = Settings::default();
assert_eq!(settings.theme, "underwater");
let (name, id, theme) = codewhale_palette::resolve_theme_setting(&settings.theme, None)
.expect("default resolves");
assert_eq!(id, codewhale_palette::ThemeId::Underwater);
assert_eq!(name, "underwater");
assert_eq!(theme.name, "underwater");
let saved: Settings = toml::from_str("theme = \"shoreline\"\n").expect("saved theme");
assert_eq!(
saved.theme, "shoreline",
"upgrades preserve an explicit choice"
);
}
#[test]
fn theme_normalizes_supported_values_and_rejects_unknowns() {
let mut settings = Settings::default();
@@ -5217,15 +5208,6 @@ zai = ["GLM-5.2", "GLM-5.3"]
crate::test_support::lock_test_env()
}
/// The shared guard, under this module's historical name.
///
/// It was a byte-for-byte copy of `EnvVarGuard` until #5359 gave the shared
/// one a second job: recording which variables a test actually redirected,
/// so state-path resolution can tell a sealed environment from a test that
/// holds the lock for unrelated reasons. A private copy silently opts every
/// caller here out of that record.
use crate::test_support::EnvVarGuard as EnvVarRestore;
#[test]
fn startup_mode_writes_accept_act_plan_operate() {
let mut settings = Settings::default();
+9
View File
@@ -550,3 +550,12 @@ mod tests {
);
}
}
/// Build a syntactically valid, non-secret JWT fixture without embedding a
/// high-entropy token-shaped literal in Git history.
pub fn future_test_jwt(label: &str) -> String {
use base64::Engine as _;
let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(br#"{"exp":9999999999}"#);
format!("test.{payload}.{label}")
}
+47
View File
@@ -0,0 +1,47 @@
[package]
name = "codewhale-sandbox"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Codewhale command sandbox preparation and platform enforcement"
[lints]
workspace = true
[features]
test-support = ["codewhale-runtime/test-support"]
[dependencies]
anyhow.workspace = true
async-trait.workspace = true
chrono.workspace = true
codewhale-app-server = { path = "../app-server", version = "0.10.2" }
codewhale-paths = { path = "../paths", version = "0.10.2" }
codewhale-release = { path = "../release", version = "0.10.2" }
codewhale-runtime = { path = "../runtime", version = "0.10.2" }
dirs.workspace = true
reqwest.workspace = true
serde.workspace = true
tracing.workspace = true
uuid.workspace = true
[dev-dependencies]
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.2" }
codewhale-runtime = { path = "../runtime", version = "0.10.2", features = ["test-support", "test-thread-scoped-state"] }
serde_json.workspace = true
tempfile.workspace = true
tokio.workspace = true
[target.'cfg(unix)'.dependencies]
libc.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
url = "2"
[target.'cfg(all(target_os = "linux", not(target_env = "ohos")))'.dependencies]
wait-timeout = "0.2"
[target.'cfg(not(target_env = "ohos"))'.dependencies]
portable-pty = "0.9"
@@ -70,7 +70,7 @@ pub trait SandboxBackend: Send + Sync {
async fn exec(&self, cmd: &str, env: &HashMap<String, String>) -> Result<SandboxOutput>;
}
use crate::config::Config;
use codewhale_runtime::config::Config;
/// Create the configured sandbox backend from config.
///
@@ -122,9 +122,11 @@ mod tests {
#[tokio::test]
async fn unsupported_backend_refuses_execution_instead_of_falling_back_to_local() {
for name in ["shannon", "shannonnet", "shannon-net", "levee", "unknown"] {
let config = Config {
sandbox_backend: Some(name.into()),
..Config::default()
let config = {
let __cw_config_init_0_0 = Some(name.into());
let mut __cw_config_init_0_value: Config = Config::default();
__cw_config_init_0_value.sandbox_backend = __cw_config_init_0_0;
__cw_config_init_0_value
};
let backend = create_backend(&config)
.unwrap()
@@ -136,9 +138,11 @@ mod tests {
assert!(error.to_string().contains("Unsupported sandbox_backend"));
}
for name in [None, Some("none"), Some("")] {
let config = Config {
sandbox_backend: name.map(str::to_owned),
..Config::default()
let config = {
let __cw_config_init_1_0 = name.map(str::to_owned);
let mut __cw_config_init_1_value: Config = Config::default();
__cw_config_init_1_value.sandbox_backend = __cw_config_init_1_0;
__cw_config_init_1_value
};
assert!(create_backend(&config).unwrap().is_none());
}
@@ -125,7 +125,7 @@ pub fn is_available() -> bool {
&[],
&[],
false,
&crate::sandbox::BwrapMountExtensions::default(),
&crate::BwrapMountExtensions::default(),
&[],
&[],
);
@@ -196,9 +196,34 @@ pub fn build_bwrap_command(
args: &[String],
writable_roots: &[WritableRoot],
network_access: bool,
extensions: &crate::sandbox::BwrapMountExtensions,
extensions: &crate::BwrapMountExtensions,
denied_read_subpaths: &[std::path::PathBuf],
denied_read_exceptions: &[std::path::PathBuf],
) -> Vec<String> {
build_bwrap_command_with_read_paths(
cwd,
program,
args,
writable_roots,
network_access,
extensions,
denied_read_subpaths,
denied_read_exceptions,
None,
)
}
#[cfg(target_os = "linux")]
pub(crate) fn build_bwrap_command_with_read_paths(
cwd: &std::path::Path,
program: &str,
args: &[String],
writable_roots: &[WritableRoot],
network_access: bool,
extensions: &crate::BwrapMountExtensions,
denied_read_subpaths: &[std::path::PathBuf],
denied_read_exceptions: &[std::path::PathBuf],
readable_paths: Option<&[PathBuf]>,
) -> Vec<String> {
let (writable_mounts, read_only_mounts) = safe_mounts(writable_roots);
let (extra_read_only, device_mounts) = extensions.resolve();
@@ -218,10 +243,12 @@ pub fn build_bwrap_command(
// too and takes every process in the sandbox with it (#6654).
cmd.push("--die-with-parent".to_string());
// Read-only bind-mount the entire root filesystem.
cmd.push("--ro-bind".to_string());
cmd.push("/".to_string());
cmd.push("/".to_string());
// Default commands read the host filesystem; Native hosts bind only their grants.
if readable_paths.is_none() {
cmd.push("--ro-bind".to_string());
cmd.push("/".to_string());
cmd.push("/".to_string());
}
// Standard container essentials (#5410): a private `/dev` (so device
// nodes exist fresh and writable — `>/dev/null` under the read-only
@@ -234,6 +261,14 @@ pub fn build_bwrap_command(
cmd.push("--tmpfs".to_string());
cmd.push("/tmp".to_string());
if let Some(paths) = readable_paths {
for path in paths {
cmd.push("--ro-bind".to_string());
cmd.push(path.to_string_lossy().into_owned());
cmd.push(path.to_string_lossy().into_owned());
}
}
// User-configured writable device nodes (#5410), e.g. a host `/dev/null`
// when the caller needs the host's, not the fresh private one.
for device in device_mounts {
@@ -373,7 +408,7 @@ mod tests {
&["-c".to_string(), "echo hi".to_string()],
&[WritableRoot::new(cwd.to_path_buf())],
false,
&crate::sandbox::BwrapMountExtensions::default(),
&crate::BwrapMountExtensions::default(),
&[],
&[],
);
@@ -416,7 +451,7 @@ mod tests {
&[],
&[],
false,
&crate::sandbox::BwrapMountExtensions::default(),
&crate::BwrapMountExtensions::default(),
&[],
&[],
);
@@ -451,7 +486,7 @@ mod tests {
&[],
&roots,
true,
&crate::sandbox::BwrapMountExtensions::default(),
&crate::BwrapMountExtensions::default(),
&[],
&[],
);
@@ -487,7 +522,7 @@ mod tests {
let extra_ro = dir.path().join("vendor-libs");
std::fs::create_dir_all(&extra_ro).expect("extra ro dir");
let extensions = crate::sandbox::BwrapMountExtensions {
let extensions = crate::BwrapMountExtensions {
read_only_roots: vec![
extra_ro.clone(),
dir.path().join("missing-ro"), // silently skipped
@@ -533,7 +568,7 @@ mod tests {
std::fs::create_dir_all(&narrowed).expect("dirs");
let roots = vec![WritableRoot::new(workspace.clone())];
let extensions = crate::sandbox::BwrapMountExtensions {
let extensions = crate::BwrapMountExtensions {
read_only_roots: vec![narrowed.clone()],
device_roots: vec![],
};
@@ -590,7 +625,7 @@ mod tests {
&[],
&[WritableRoot::new(data.clone())],
false,
&crate::sandbox::BwrapMountExtensions::default(),
&crate::BwrapMountExtensions::default(),
std::slice::from_ref(&home),
&[bundle.clone(), home.join("plugins")],
);
@@ -1,5 +1,3 @@
#![allow(dead_code)]
//! Sandbox module for secure command execution.
//!
//! This module provides sandboxing capabilities for shell commands executed by
@@ -11,12 +9,11 @@
//! - **macOS**: Uses Seatbelt (`sandbox-exec`) when the runtime probe succeeds
//! - **Linux**: Uses bubblewrap by default whenever `/usr/bin/bwrap` is
//! installed and a probe shows it can create its namespaces; `prefer_bwrap
//! = false` opts out. The seccomp helper is not wired into child execution
//! and therefore is not advertised. The extension host uses bubblewrap on
//! the same condition, probed per launch
//! = false` opts out. The extension host uses bubblewrap on the same
//! condition, probed per launch
//! (`extension_host::supervisor::plan_launch`).
//! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap,
//! seccomp, and Linux `prctl` hardening are gated out under `target_env =
//! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap
//! and Linux `prctl` hardening are gated out under `target_env =
//! "ohos"`.
//! - **Windows**: No OS sandbox is advertised yet. The planned first helper
//! contract is process-tree containment only via a Windows Job Object; it
@@ -25,7 +22,7 @@
//! # Usage
//!
//! ```rust,ignore
//! use sandbox::{SandboxManager, CommandSpec, SandboxPolicy};
//! use codewhale_sandbox::{SandboxManager, CommandSpec, SandboxPolicy};
//!
//! let manager = SandboxManager::new();
//! let spec = CommandSpec::shell("ls -la", PathBuf::from("."), Duration::from_secs(30))
@@ -40,13 +37,11 @@ pub mod opensandbox;
pub mod policy;
pub mod process_hardening;
pub mod read_guard;
pub mod shell_dispatcher;
#[cfg(target_os = "macos")]
pub mod seatbelt;
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
pub mod seccomp;
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
pub mod bwrap;
@@ -192,6 +187,7 @@ impl CommandSpec {
}
/// Set a justification for this command (for logging/audit).
#[cfg(test)]
pub fn with_justification(mut self, justification: &str) -> Self {
self.justification = Some(justification.to_string());
self
@@ -254,6 +250,7 @@ impl CommandSpec {
}
}
#[cfg(any(test, windows))]
fn windows_shell_default_env() -> HashMap<String, String> {
HashMap::from([("PYTHONIOENCODING".to_string(), "utf-8".to_string())])
}
@@ -402,6 +399,7 @@ impl ExecEnv {
}
/// Detect what sandbox technology is available on the current platform.
#[cfg(test)]
pub fn get_platform_sandbox() -> Option<SandboxType> {
get_platform_sandbox_with_bwrap_preference(false)
}
@@ -442,6 +440,7 @@ pub fn get_platform_sandbox_with_bwrap_preference(prefer_bwrap: bool) -> Option<
}
/// Check if sandboxing is available on this platform.
#[cfg(test)]
pub fn is_sandbox_available() -> bool {
get_platform_sandbox().is_some()
}
@@ -505,15 +504,6 @@ impl BwrapMountExtensions {
.collect();
(read_only, devices)
}
/// Same resolution on non-Linux platforms, where the sandbox manager
/// carries the type but never uses it: there is no bwrap to build a
/// command for, so the extension lists resolve empty rather than doing
/// filesystem work whose result would be discarded.
#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
fn resolve(&self) -> (Vec<PathBuf>, Vec<PathBuf>) {
(Vec::new(), Vec::new())
}
}
/// Expand a leading `~` or `~/` to the user's home directory. Paths without
@@ -540,6 +530,7 @@ fn expand_home_prefix(path: PathBuf) -> PathBuf {
/// an existing file, nothing for a path that does not exist (there is nothing
/// to deny). Blocking. Platform-neutral so it is exercised on every host; only
/// the Linux bwrap builder emits it.
#[cfg(any(test, all(target_os = "linux", not(target_env = "ohos"))))]
fn bwrap_mask_args(path: &std::path::Path) -> Vec<String> {
let Ok(resolved) = std::fs::canonicalize(path) else {
return Vec::new();
@@ -572,6 +563,7 @@ fn bwrap_mask_args(path: &std::path::Path) -> Vec<String> {
/// (`.codewhale` inside a writable root) or an extra read-only root inside a
/// masked directory is not re-applied. Only the extension host sets
/// exceptions, and its data dir has neither.
#[cfg(any(test, all(target_os = "linux", not(target_env = "ohos"))))]
fn bwrap_exception_args(
denied: &[PathBuf],
exceptions: &[PathBuf],
@@ -636,18 +628,30 @@ pub struct SandboxManager {
/// User-configured bwrap bind-mount extensions (#5410): extra
/// read-only roots and writable device nodes.
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
bwrap_extensions: BwrapMountExtensions,
/// Opt-in read deny-list (S1, #5568): paths sandboxed commands must not
/// be able to read even though the sandbox otherwise grants full-disk
/// read (Seatbelt appends last-match-wins deny rules; bubblewrap masks
/// each path). Empty by default — today's behavior unchanged.
#[cfg(any(
test,
target_os = "macos",
all(target_os = "linux", not(target_env = "ohos"))
))]
denied_read_subpaths: Vec<PathBuf>,
/// Paths inside a denied directory that stay visible (bubblewrap only,
/// [`bwrap_exception_args`]; Seatbelt ignores this list). Empty by
/// `bwrap_exception_args`; Seatbelt ignores this list). Empty by
/// default.
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
denied_read_exceptions: Vec<PathBuf>,
#[cfg(any(
target_os = "macos",
all(target_os = "linux", not(target_env = "ohos"))
))]
readable_paths: Option<Vec<PathBuf>>,
}
impl SandboxManager {
@@ -676,7 +680,12 @@ impl SandboxManager {
/// Set user-configured bwrap mount extensions (#5410): extra read-only
/// roots and writable device nodes such as `/dev/null`.
pub fn set_bwrap_extensions(&mut self, extensions: BwrapMountExtensions) {
self.bwrap_extensions = extensions;
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
{
self.bwrap_extensions = extensions;
}
#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
let _ = extensions;
}
/// Set the opt-in read deny-list (S1, #5568). A leading `~` in a path
@@ -698,15 +707,48 @@ impl SandboxManager {
resolved.push(path);
}
}
self.denied_read_subpaths = resolved;
#[cfg(any(
test,
target_os = "macos",
all(target_os = "linux", not(target_env = "ohos"))
))]
{
self.denied_read_subpaths = resolved;
}
#[cfg(not(any(
test,
target_os = "macos",
all(target_os = "linux", not(target_env = "ohos"))
)))]
let _ = resolved;
}
/// Set the paths inside a denied directory that stay visible under
/// bubblewrap ([`bwrap_exception_args`]). The extension host uses it to
/// bubblewrap (`bwrap_exception_args`). The extension host uses it to
/// deny a Codewhale home whole while keeping its own files and plugin
/// code readable.
pub fn set_denied_read_exceptions(&mut self, paths: Vec<PathBuf>) {
self.denied_read_exceptions = paths;
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
{
self.denied_read_exceptions = paths;
}
#[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
let _ = paths;
}
pub fn set_readable_paths(&mut self, paths: Vec<PathBuf>) {
#[cfg(any(
target_os = "macos",
all(target_os = "linux", not(target_env = "ohos"))
))]
{
self.readable_paths = Some(paths);
}
#[cfg(not(any(
target_os = "macos",
all(target_os = "linux", not(target_env = "ohos"))
)))]
let _ = paths;
}
/// Test-only view of the resolved deny-list (post home-expansion and
@@ -792,12 +834,21 @@ impl SandboxManager {
original_command.extend(spec.args.clone());
// Generate sandbox-exec arguments
let seatbelt_args = seatbelt::create_seatbelt_args(
original_command,
&spec.sandbox_policy,
&spec.cwd,
&self.denied_read_subpaths,
);
let seatbelt_args = if let Some(paths) = &self.readable_paths {
seatbelt::create_restricted_seatbelt_args(
original_command,
&spec.cwd,
paths,
&self.denied_read_subpaths,
)
} else {
seatbelt::create_seatbelt_args(
original_command,
&spec.sandbox_policy,
&spec.cwd,
&self.denied_read_subpaths,
)
};
// Prepend sandbox-exec to the command
let mut command = vec![seatbelt::SANDBOX_EXEC_PATH.to_string()];
@@ -831,8 +882,12 @@ impl SandboxManager {
/// writable device nodes) apply after the defaults.
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
fn prepare_bwrap(&self, spec: &CommandSpec) -> ExecEnv {
let writable_roots = spec.sandbox_policy.get_writable_roots(&spec.cwd);
let command = bwrap::build_bwrap_command(
let writable_roots = if self.readable_paths.is_some() {
vec![policy::WritableRoot::new(spec.cwd.clone())]
} else {
spec.sandbox_policy.get_writable_roots(&spec.cwd)
};
let command = bwrap::build_bwrap_command_with_read_paths(
&spec.cwd,
&spec.program,
&spec.args,
@@ -841,6 +896,7 @@ impl SandboxManager {
&self.bwrap_extensions,
&self.denied_read_subpaths,
&self.denied_read_exceptions,
self.readable_paths.as_deref(),
);
let mut env = spec.env.clone();
@@ -1387,7 +1443,7 @@ mod tests {
let env = manager.prepare(&spec);
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
{
if crate::sandbox::bwrap::is_available() {
if crate::bwrap::is_available() {
let primary_marker = env.env.get("CODEWHALE_SANDBOX");
let marker = env.env.get("DEEPSEEK_SANDBOX");
assert_eq!(primary_marker.map(String::as_str), Some("bwrap"));
@@ -42,7 +42,6 @@ struct SandboxRunResponse {
pub struct OpenSandboxBackend {
base_url: String,
api_key: Option<String>,
timeout_secs: u64,
client: reqwest::Client,
}
@@ -62,7 +61,6 @@ impl OpenSandboxBackend {
Ok(Self {
base_url,
api_key,
timeout_secs,
client,
})
}
@@ -1,5 +1,3 @@
#![allow(dead_code)]
//! Sandbox policy definitions for command execution restrictions.
//!
//! This module defines the policies that control what resources a sandboxed
@@ -10,6 +8,7 @@ use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[cfg(test)]
use codewhale_execpolicy::command_safety::SafetyLevel;
/// Determines execution restrictions for shell commands.
@@ -526,6 +525,7 @@ impl WritableRoot {
/// - `Safe` / `WorkspaceSafe` → use the default sandbox policy
/// - `RequiresApproval` → user must approve before execution (handled by caller)
/// - `Dangerous` → blocked unless in YOLO mode with trust
#[cfg(test)]
pub fn map_safety_level_to_behavior(
level: SafetyLevel,
default_policy: &SandboxPolicy,
@@ -540,6 +540,7 @@ pub fn map_safety_level_to_behavior(
}
/// Behavior decision for a sandboxed command based on safety level.
#[cfg(test)]
#[derive(Debug, Clone)]
pub enum SandboxPolicyBehavior {
/// Execute with the given sandbox policy.
@@ -3,8 +3,7 @@
//! This module applies kernel-level restrictions to the codewhale-tui process
//! itself. These hardening measures protect the *parent* TUI process and its
//! descendants from information leaks and privilege-escalation vectors; they
//! are not a filesystem or network sandbox for child commands. The seccomp
//! source module is not wired into child execution yet.
//! are not a filesystem or network sandbox for child commands.
//!
//! # Ordering constraints
//!
@@ -48,12 +47,14 @@
/// exactly this one measure; any other set value forces it on, and leaving it
/// unset lets the startup sandbox posture decide (#5723). `PR_SET_DUMPABLE`
/// and `RLIMIT_CORE` are never skipped.
#[cfg(any(test, all(target_os = "linux", not(target_env = "ohos"))))]
pub(crate) const NO_NEW_PRIVS_ENV: &str = "CODEWHALE_NO_NEW_PRIVS";
/// Whether a `CODEWHALE_NO_NEW_PRIVS` value requests skipping the
/// no-new-privileges flag. Falsey per the workspace env convention — the same
/// value set (`""`, `0`, `false`, `no`, `off`, `disabled`) that
/// `docs/CONFIGURATION.md` treats as "not set".
#[cfg(any(test, all(target_os = "linux", not(target_env = "ohos"))))]
fn is_no_new_privs_opt_out(value: &str) -> bool {
matches!(
value.trim().to_ascii_lowercase().as_str(),
@@ -62,9 +63,10 @@ fn is_no_new_privs_opt_out(value: &str) -> bool {
}
/// The explicit `CODEWHALE_NO_NEW_PRIVS` decision as the tri-state
/// [`should_apply_no_new_privs`] consumes: `Some(false)` opts out (falsey
/// `should_apply_no_new_privs` consumes: `Some(false)` opts out (falsey
/// value), `Some(true)` forces the flag on (any other set value), and `None`
/// leaves the decision to the startup posture.
#[cfg(any(test, all(target_os = "linux", not(target_env = "ohos"))))]
fn no_new_privs_env_override() -> Option<bool> {
std::env::var_os(NO_NEW_PRIVS_ENV)
.map(|value| !is_no_new_privs_opt_out(&value.to_string_lossy()))
@@ -89,6 +91,7 @@ fn no_new_privs_env_override() -> Option<bool> {
/// `sandbox_mode` (`Config::validate`): trimmed, ASCII case-insensitive. An
/// unrecognized value keeps the flag on — it cannot silently relax into a
/// posture the runtime would reject.
#[cfg(any(test, all(target_os = "linux", not(target_env = "ohos"))))]
pub(crate) fn should_apply_no_new_privs(
resolved_startup_mode: Option<&str>,
env_override: Option<bool>,
@@ -107,7 +110,7 @@ pub(crate) fn should_apply_no_new_privs(
/// and `None` on platforms where the flag does not exist or the query fails.
/// Status and denial surfaces use this to disclose the residual setuid block
/// truthfully instead of replaying the startup decision (#5723).
pub(crate) fn no_new_privs_active() -> Option<bool> {
pub fn no_new_privs_active() -> Option<bool> {
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
{
// Safety: PR_GET_NO_NEW_PRIVS only reads the calling process's flag.
@@ -125,7 +128,7 @@ pub(crate) fn no_new_privs_active() -> Option<bool> {
/// On Linux, this:
/// - Sets `PR_SET_DUMPABLE` to 0 (prevents ptrace, core dumps)
/// - Sets `PR_SET_NO_NEW_PRIVS` to 1 (irreversible no-new-privileges), unless
/// [`should_apply_no_new_privs`] skips it: an explicit falsey
/// `should_apply_no_new_privs` skips it: an explicit falsey
/// `CODEWHALE_NO_NEW_PRIVS` (#5413), or a startup sandbox mode resolved to
/// `danger-full-access` (#5723)
/// - Sets `RLIMIT_CORE` to 0 (disables core dumps)
@@ -134,7 +137,7 @@ pub(crate) fn no_new_privs_active() -> Option<bool> {
///
/// `resolved_startup_mode` is the narrow pre-parse read of the startup sandbox
/// mode (`CODEWHALE_SANDBOX_MODE`, else the config file's `sandbox_mode` key);
/// see `run_with_args` in `crate::lib` for the seam and its limits.
/// see `run_with_args` in the `codewhale-tui` crate root for the seam and its limits.
///
/// # Panics
///
@@ -227,7 +227,7 @@ impl ReadDenylist {
/// only the rules scoped to the process, which excludes a tool's own auth
/// config.
#[must_use]
pub(crate) fn from_config(config: &crate::config::Config) -> Self {
pub fn from_config(config: &codewhale_runtime::config::Config) -> Self {
Self::build(
config.sandbox_read_denylist_defaults.unwrap_or(true),
&config.sandbox_denied_read_paths,
@@ -417,6 +417,19 @@ pub fn active() -> Arc<ReadDenylist> {
Arc::clone(FALLBACK.get_or_init(|| Arc::new(ReadDenylist::build(true, &[], &[]))))
}
#[cfg(any(test, feature = "test-support"))]
pub fn with_test_active_denylist<R>(list: ReadDenylist, callback: impl FnOnce() -> R) -> R {
struct RestoreActive(Option<Arc<ReadDenylist>>);
impl Drop for RestoreActive {
fn drop(&mut self) {
*ACTIVE.write().unwrap() = self.0.take();
}
}
let previous = std::mem::replace(&mut *ACTIVE.write().unwrap(), Some(Arc::new(list)));
let _restore = RestoreActive(previous);
callback()
}
/// `.env`, `.env.local`, `.env.production` — but deliberately NOT
/// `.env.example`, `.env.sample`, `.env.template`, `.env.defaults`, or
/// `.env.dist`. Those are committed placeholders that a coding agent has a
@@ -491,7 +504,7 @@ fn default_denied_subtrees() -> Vec<(PathBuf, &'static str, DenyScope)> {
));
}
out.push((
crate::oauth::auth_file_path(),
codewhale_runtime::oauth::auth_file_path(),
"Codex CLI login (resolved auth file)",
Process,
));
@@ -929,66 +942,6 @@ mod tests {
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
// Unix only: the fixture redirects the home directory through `HOME`,
// and Windows resolves the profile through the known-folder API instead.
#[cfg(unix)]
#[test]
fn sandbox_read_guard_denies_active_and_known_agent_credentials_in_real_file_tool() {
use crate::tools::spec::{ToolContext, ToolSpec};
let _env = crate::test_support::lock_test_env();
let temp = tempfile::tempdir().expect("private fixture");
let home = temp.path().join("home");
let active_home = temp.path().join("active");
let codex = temp.path().join("codex");
let _home = crate::test_support::EnvVarGuard::set("HOME", home.to_str().unwrap());
let _active =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", active_home.to_str().unwrap());
let _codex = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex.to_str().unwrap());
let _auth = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_AUTH_FILE");
assert_eq!(dirs::home_dir().as_deref(), Some(home.as_path()));
let denied = [
active_home.join("credentials/fixture.json"),
codex.join("auth.json"),
home.join(".kimi/credentials/fixture.json"),
home.join(".claude/.credentials.json"),
home.join(".codewhale-cu/recordings/trajectories/fixture.jsonl"),
];
for path in &denied {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, "read-s10-synthetic-value").unwrap();
}
struct RestoreActive(Option<Arc<ReadDenylist>>);
impl Drop for RestoreActive {
fn drop(&mut self) {
*ACTIVE.write().unwrap() = self.0.take();
}
}
let previous = ACTIVE.write().unwrap().take();
let _restore = RestoreActive(previous);
set_active(ReadDenylist::build(true, &[], &[]));
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let context = ToolContext::new(temp.path());
for path in denied {
let result = runtime.block_on(
crate::tools::file::ReadFileTool
.execute(serde_json::json!({"path": path}), &context),
);
assert!(result.is_err(), "file tool read a credential source");
}
let safe = home.join(".claude/settings.json");
std::fs::write(&safe, "ordinary-readable-setting").unwrap();
let allowed = runtime
.block_on(
crate::tools::file::ReadFileTool
.execute(serde_json::json!({"path": safe}), &context),
)
.expect("ordinary settings remain readable");
assert!(allowed.content.contains("ordinary-readable-setting"));
}
#[test]
fn empty_denylist_denies_nothing_and_reports_full_disk_read() {
let list = ReadDenylist::empty();
@@ -174,6 +174,52 @@ pub fn create_seatbelt_args(
args
}
pub(crate) fn create_restricted_seatbelt_args(
command: Vec<String>,
sandbox_cwd: &Path,
readable_paths: &[PathBuf],
denied_read_subpaths: &[PathBuf],
) -> Vec<String> {
let mut profile = String::from(
"(version 1)\n(deny default)\n(allow process-exec)\n(allow process-fork)\n(allow signal (target same-sandbox))\n(allow process-info* (target same-sandbox))\n(allow sysctl-read)\n(allow file-read* (literal \"/\"))\n(allow file-write-data (literal \"/dev/null\"))\n(allow file-ioctl (literal \"/dev/dtracehelper\"))\n",
);
let escape = |path: &Path| {
path.to_string_lossy()
.replace('\\', "\\\\")
.replace('"', "\\\"")
};
let mut ancestors = std::collections::BTreeSet::new();
for path in readable_paths {
let predicate = if path.is_dir() { "subpath" } else { "literal" };
profile.push_str(&format!(
"(allow file-read* ({predicate} \"{}\"))\n",
escape(path)
));
ancestors.extend(path.ancestors().skip(1).map(Path::to_path_buf));
}
for path in ancestors {
profile.push_str(&format!(
"(allow file-read* (literal \"{}\"))\n",
escape(&path)
));
}
if let Ok(data) = sandbox_cwd.canonicalize() {
profile.push_str(&format!(
"(allow file-write* (subpath \"{}\"))\n",
escape(&data)
));
}
for path in denied_read_subpaths {
profile.push_str(&format!(
"\n(deny file-read* (subpath \"{}\"))",
escape(path)
));
}
let mut args = vec!["-p".to_string(), profile, "--".to_string()];
args.extend(command);
args
}
/// Generate the complete Seatbelt policy string for the given policy.
fn generate_policy(
policy: &SandboxPolicy,
@@ -535,7 +581,7 @@ mod tests;
mod existing_tests {
use super::*;
// Tests that mutate HOME/CARGO_HOME use crate::test_support::lock_test_env()
// Tests that mutate HOME/CARGO_HOME use codewhale_runtime::test_support::lock_test_env()
// so they don't race with sibling tests in this crate that read those vars.
#[test]
fn test_generate_policy_with_network() {
@@ -598,7 +644,7 @@ mod existing_tests {
/// sandbox-exec refuse to load the profile.
#[test]
fn test_cargo_home_paths_emitted_in_policy_and_params_when_home_set() {
let _guard = crate::test_support::lock_test_env();
let _guard = codewhale_runtime::test_support::lock_test_env();
// SAFETY: HOME / CARGO_HOME are process-global. lock_test_env
// serializes tests that mutate them, and we always restore the
@@ -654,7 +700,7 @@ mod existing_tests {
/// would crash sandbox-exec on profile load.
#[test]
fn test_cargo_home_skipped_when_no_env() {
let _guard = crate::test_support::lock_test_env();
let _guard = codewhale_runtime::test_support::lock_test_env();
let saved_home = std::env::var_os("HOME");
let saved_cargo = std::env::var_os("CARGO_HOME");
@@ -693,7 +739,7 @@ mod existing_tests {
/// sandbox-exec refuse to load the profile.
#[test]
fn test_npm_cache_paths_emitted_in_policy_and_params_when_home_set() {
let _guard = crate::test_support::lock_test_env();
let _guard = codewhale_runtime::test_support::lock_test_env();
let saved_home = std::env::var_os("HOME");
let saved_npm = std::env::var_os("NPM_CONFIG_CACHE");
@@ -752,7 +798,7 @@ mod existing_tests {
/// and their param must both be omitted.
#[test]
fn test_npm_cache_skipped_when_no_env() {
let _guard = crate::test_support::lock_test_env();
let _guard = codewhale_runtime::test_support::lock_test_env();
let saved_home = std::env::var_os("HOME");
let saved_npm = std::env::var_os("NPM_CONFIG_CACHE");
@@ -917,7 +963,7 @@ mod existing_tests {
// generate_policy/generate_params both read HOME/CARGO_HOME; take the
// env lock so sibling tests mutating those vars can't desync the
// policy text from its -DKEY=VALUE params mid-call.
let _guard = crate::test_support::lock_test_env();
let _guard = codewhale_runtime::test_support::lock_test_env();
assert!(
is_available(),
@@ -222,7 +222,7 @@ fn denied_read_subpaths_block_reads_under_every_sandboxed_posture() {
// macOS tempdirs live behind the /var -> /private/var symlink, and
// Seatbelt matches the kernel-resolved path, so a literal-only rule
// silently never fires (caught live by this very test).
let mut manager = crate::sandbox::SandboxManager::new();
let mut manager = crate::SandboxManager::new();
manager.set_denied_read_subpaths(vec![secret_dir.path().to_path_buf()]);
let denied_args = create_seatbelt_args(
read_cmd.clone(),
@@ -50,28 +50,17 @@ static LOG_MUTEX: Mutex<()> = Mutex::new(());
/// The concrete shell that the dispatcher will use.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellKind {
// Which variants are live is exactly a platform split: `detect` builds the
// four Windows shells under `cfg(windows)` and `Sh`/`Custom` under
// `cfg(not(windows))`. So each `expect(dead_code)` has to name the platform
// it is dead on, or it fires as unfulfilled on the other one — which is how
// the Windows build broke while unix stayed green.
/// PowerShell 7+ (`pwsh.exe`).
#[cfg_attr(all(not(test), not(windows)), expect(dead_code))]
Pwsh,
/// Windows PowerShell 5.1 (`powershell.exe`).
#[cfg_attr(all(not(test), not(windows)), expect(dead_code))]
WindowsPowerShell,
/// Command Prompt (`cmd.exe`).
#[cfg_attr(all(not(test), not(windows)), expect(dead_code))]
Cmd,
/// Unix `/bin/sh` fallback.
#[cfg_attr(all(not(test), windows), expect(dead_code))]
Sh,
/// Bash — detected via `$SHELL` on WSL/Git Bash, or constructed explicitly.
#[cfg_attr(all(not(test), not(windows)), expect(dead_code))]
Bash,
/// The exact shell executable selected by Unix `$SHELL`.
#[cfg_attr(all(not(test), windows), expect(dead_code))]
Custom { binary: String, flag: String },
}
@@ -494,7 +483,7 @@ impl ShellDispatcher {
// -- Detection --------------------------------------------------------
fn detect_shell() -> ShellKind {
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
{
// Non-blocking on purpose. This runs inside the `LazyLock`
// initializer in `global_dispatcher()`, and a test that holds the
@@ -507,7 +496,7 @@ impl ShellDispatcher {
Self::detect_shell_unlocked,
)
}
#[cfg(not(test))]
#[cfg(not(any(test, feature = "test-support")))]
{
Self::detect_shell_unlocked()
}
File renamed without changes.
+12 -10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority. (CW-6713)
- **MCP compatibility fixes:** older servers that answer the discovery probe with an error or an unrelated result now connect through the legacy handshake instead of being refused after the probe. A server that rejects sign-in reports its real 401 and the `/mcp login` hint rather than the probe's "method not found". A stale OAuth token is refreshed once when the probe itself is rejected. A server whose command cannot start now says why (for example, command not found) instead of a generic broker refusal.
- **DeepSeek Harness plugins:** reviewed DSH plugins (definition API pinned to DSH 0.2.1-rc.2 in a shared compatibility table) run in the Native extension host. Tool output is validated against its declared schema, unsupported options are refused instead of ignored, and plugin commands can submit a prompt with visible plugin attribution. Terminal command receipts are kept in thread snapshots without entering model history. Changing the compatibility table invalidates earlier trust. Plugin UI views remain disabled. (CW-7054)
- **Computer Use is installed, not bundled:** the Computer Use plugin is no longer vendored into the binary. Install it from the `codewhale` marketplace, which points at its independent release; its consent tools load on first use, and decisions still require a human approval card. (CW-6732)
- **Tool image validation:** an image returned by a tool must decode before it is sent, so truncated or corrupt files are omitted with a receipt even when they are already within the delivery budget.
- **Image-heavy session recovery:** ordinary image reads use a 256 KiB delivery budget. Requests measure serialized bytes, omit older images from an outbound copy with receipts, and retry an initial HTTP 413 once only with a smaller body. Saved images and executed tool actions remain intact. (CW-7550)
- **Unix uninstall:** `codewhale uninstall` previews receipt-owned release files and `--yes` removes only checked files, with the running command last. Both Unix installers record command ownership; self-update renews existing receipts. User data, modified/unreceipted files and package-managed installations are preserved. Windows removal remains pending. (Refs #7091)
- **GitHub Action reviews:** analysis can export a structured Engine review for a separate trusted publisher job. The publisher checks the current PR revision and line anchors, deduplicates its findings, and keeps uncertain or preexisting claims out of inline comments.
- **Telegram recovery:** prompts and their initiating identity are durable before the polling cursor advances. Restart recovery reuses the original Engine operation, questions preserve human ownership, and uncertain final replies remain available through `/status` without an automatic resend.
- **DingTalk bridge:** an optional internal-app Stream adapter reuses the same Engine, shared admission and approval helpers. Tenant/staff allowlists, durable acceptance before acknowledgement and retained uncertain deliveries protect recovery. Managed account linking is separate from this advanced self-hosted bridge.
@@ -17,6 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **OrcaRouter recommendation:** `/provider` and `/model` offer a shared partner recommendation with the DeepSeek V4.1 Flash 30% top-up-match link, public referral disclosure and localized offer details. F7 or a click opens the existing pager while retaining the current route.
- **Per-scope instruction budgets:** global and project standing instructions now have separate token budgets (100,000 estimated tokens each, capped at 25% of the usable context window), every trim, skip or refusal reaches the model and `/context report`, and `[instruction_budget] policy = "refuse"` stops a request before dispatch. Other tools' instruction files stay opt-in through `project_instruction_imports` or `claude_fallback`, and the table is user config only. Thanks [@7jrxt42BxFZo4iAnN4CX](https://github.com/7jrxt42BxFZo4iAnN4CX) for the design and implementation. (#6526, #7086)
- Long drafts now show a localized external-editor hint when the actual composer
viewport clips their text. The hint uses the shared F4 binding and preserves
queue, running-turn, history, and menu hints (#7049). Thanks @Lstarsky0.
@@ -48,16 +60,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
cancellation wiring stays in place; its stale unused-code suppressions are
removed. No supported command or feature is retired by this cleanup.
- Standing instructions are budgeted per scope (`global` and `project`) in
estimated tokens instead of one 48 KiB byte ceiling shared by everything, and
every trim, skip, and refusal is reported — as a system cell at session start,
on stderr in `exec`, to the model as a runtime-provenance message, and per
source in `/context report`. `AGENTS.md` still wins; a directory without one
now falls back to its `CLAUDE.md` (`claude_fallback = false` opts out).
Configured `instructions = [...]` entries are charged to the global scope.
New `[instruction_budget]` table (user config only) with per-scope token
budgets, percentages of the usable window, `file_max_bytes`, `policy`, the
static-prefix guard, and `claude_fallback` (#6526).
- Local Ollama defaults now come from chat-capable tags, and `/provider ollama`
refreshes the live catalog before switching. Exec and Runtime API defaults use
the same discovery path. Fresh context observations come from loaded models'
+8 -9
View File
@@ -62,8 +62,6 @@ exclude = [
"extension-host/dist/shell-hooks.mjs",
"extension-host/dist/skills.mjs",
"extension-host/dist/stock-adapters.mjs",
"plugins/computer-use/tests/**",
"plugins/computer-use/scripts/smoke.mjs",
]
[lints]
@@ -102,11 +100,14 @@ codewhale-hooks = { path = "../hooks", version = "0.10.2" }
codewhale-lane = { path = "../lane", version = "0.10.2" }
codewhale-mcp = { path = "../mcp", version = "0.10.2" }
codewhale-models = { path = "../models", version = "0.10.2" }
codewhale-net = { path = "../net", version = "0.10.2" }
codewhale-notify = { path = "../notify", version = "0.10.2" }
codewhale-palette = { path = "../palette", version = "0.10.2" }
codewhale-paths = { path = "../paths", version = "0.10.2" }
codewhale-protocol = { path = "../protocol", version = "0.10.2" }
codewhale-release = { path = "../release", version = "0.10.2" }
codewhale-runtime = { path = "../runtime", version = "0.10.2" }
codewhale-sandbox = { path = "../sandbox", version = "0.10.2" }
codewhale-secrets = { path = "../secrets", version = "0.10.2" }
codewhale-telemetry = { path = "../telemetry", version = "0.10.2" }
codewhale-tools = { path = "../tools", version = "0.10.2" }
@@ -124,9 +125,7 @@ encoding_rs.workspace = true
fd-lock = "4.0.4"
dirs.workspace = true
futures-util = "0.3.31"
hyper-util = { version = "=0.1.21", features = ["client-proxy"] }
oauth2 = { version = "5", default-features = false }
jsonwebtoken = { version = "10", default-features = false, features = ["aws_lc_rs"] }
# No ratatui-core pin: ColorCompatBackend answers `get_cursor_position()`
# from tracked state (ratatui#2640's recommended workaround), so the CPR query
# inside `Terminal::clear()` no longer races the input loop. See
@@ -135,7 +134,7 @@ ratatui = { version = "=0.30.2", features = ["unstable-rendered-line-info"] }
regex = "1.11"
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2"] }
rquickjs.workspace = true
rmcp = { version = "3.2.0", default-features = false, features = ["auth", "client"] }
rmcp = { version = "3.7.0", default-features = false, features = ["auth", "client", "server", "transport-io"] }
rustls.workspace = true
qrcode = { version = "0.14", default-features = false }
similar = { version = "3", features = ["unicode"] }
@@ -205,6 +204,8 @@ shell-words = "1.1.1"
codewhale-build-support = { path = "../build-support", version = "0.10.2" }
[dev-dependencies]
codewhale-notify = { path = "../notify", version = "0.10.2", features = ["test-thread-scoped-state"] }
codewhale-config = { path = "../config", version = "0.10.2", features = ["schema", "test-support"] }
codewhale-agent-contract = { path = "../agent-contract", version = "0.10.2", features = [
"test-support",
] }
@@ -212,6 +213,7 @@ codewhale-runtime = { path = "../runtime", version = "0.10.2", features = [
"test-support",
"test-thread-scoped-state",
] }
codewhale-sandbox = { path = "../sandbox", version = "0.10.2", features = ["test-support"] }
codewhale-models = { path = "../models", version = "0.10.2", features = [
"test-support",
] }
@@ -230,9 +232,6 @@ 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_Security_Isolation", "Win32_Storage_FileSystem", "Win32_System_LibraryLoader", "Win32_System_Pipes", "Win32_System_Threading", "Win32_System_SystemServices", "Win32_System_WindowsProgramming"] }
[target.'cfg(all(target_os = "windows", target_arch = "x86_64"))'.dependencies]
# JWT verification uses upstream assembly objects when NASM is unavailable.
aws-lc-rs = { version = "1.18.1", default-features = false, features = ["prebuilt-nasm"] }
[target.'cfg(any(target_os = "macos", target_os = "windows", all(target_os = "linux", not(target_env = "ohos"))))'.dependencies]
arboard = { version = "3.4", features = ["wayland-data-control"] }
@@ -245,4 +244,4 @@ objc2 = "0.6.3"
objc2-foundation = { version = "0.3.2", default-features = false, features = ["std", "NSArray", "NSDictionary", "NSError", "NSObject", "NSString", "NSURL"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Media_Audio", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] }
+21 -21
View File
@@ -1,6 +1,6 @@
{
"repository": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
"revision": "2e3a6fba453c9b34d5b1444ab5bd6edc7b119189",
"revision": "a5f1d210372ff48c9a278a0a39737956ac0f2d93",
"catalog": {
"name": "codewhale",
"description": "First-party Codewhale extensions: the plugins and skills Codewhale ships, maintained in the open so anyone can propose a change.",
@@ -8,7 +8,7 @@
"plugins": [
{
"name": "chromewhale",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/chromewhale",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/chromewhale",
"version": "0.4.0",
"description": "Let Codewhale read and act on selected Chrome tabs and frames, one granted site at a time. Needs the bundled Chrome extension loaded.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace/tree/main/plugins/chromewhale",
@@ -17,7 +17,7 @@
},
{
"name": "cloudflare",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/cloudflare",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/cloudflare",
"version": "0.1.0",
"description": "Cloudflare Workers workflows for Codewhale: build and test Workers, deploy with cf only after you approve, use KV, R2, D1 and Durable Objects, and move Pages projects to Workers.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -26,7 +26,7 @@
},
{
"name": "cloudflare-docs",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/cloudflare-docs",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/cloudflare-docs",
"version": "0.1.0",
"description": "Search current Cloudflare documentation through its official remote MCP. No account or credential required.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -35,7 +35,7 @@
},
{
"name": "codewhale-skills",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=skills",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=skills",
"version": "1.1.0",
"description": "47 workflows for coding, research, documents, email, calendar, travel, shopping and local audio. Account and tool setup is separate; see the skill directory.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -44,8 +44,8 @@
},
{
"name": "computer-use",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/computer-use",
"version": "0.12.1",
"source": "https://github.com/codewhale-hq/codewhale-cu-plugin/releases/latest/download/computer-use.tar.gz",
"version": "0.13.0",
"description": "Control apps with Codewhale. macOS beta; unsigned Windows preview and experimental Linux/Docker support.",
"homepage": "https://codewhale.net/computer-use",
"display_name": "Computer Use",
@@ -57,7 +57,7 @@
},
{
"name": "design-review",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/design-review",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/design-review",
"version": "0.1.0",
"description": "Review a user interface three ways: a severity-ranked design critique, a WCAG-based accessibility audit with a static scanner and contrast calculator, and a UX copy review.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -66,7 +66,7 @@
},
{
"name": "github-workflow",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/github-workflow",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/github-workflow",
"version": "0.1.0",
"description": "Review pull requests, triage issues and file well-formed issues through GitHub's official remote MCP server. Needs your own GitHub token; never posts without your approval.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -75,7 +75,7 @@
},
{
"name": "hello-extension",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/samples/hello-extension",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/samples/hello-extension",
"version": "0.1.0",
"description": "A runnable sample of a native TypeScript extension: one greeting tool and /hello-greet. Install it to see the review flow; it only runs with the experimental extension host turned on.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace/tree/main/plugins/samples/hello-extension",
@@ -88,7 +88,7 @@
},
{
"name": "linear-workflow",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/linear-workflow",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/linear-workflow",
"version": "0.1.0",
"description": "Triage Linear issues, claim work with evidence and post status updates through Linear's official remote MCP server. Needs your own Linear API key.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -97,7 +97,7 @@
},
{
"name": "loop",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/loop",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/loop",
"version": "0.1.0",
"description": "Run a task again and again until it is done: /loop <prompt> [--max N] [--until <phrase>] re-prompts the session after every reply, with a hard iteration cap, a completion phrase and /cancel-loop.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -110,7 +110,7 @@
},
{
"name": "review-toolkit",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/review-toolkit",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/review-toolkit",
"version": "0.1.0",
"description": "Six read-only specialist reviewers (silent failures, type design, test gaps, comment accuracy, simplification, general correctness) and a /review-pr command that fans them out over your diff and merges one ranked report.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -119,7 +119,7 @@
},
{
"name": "skills-dev-process",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/skills-dev-process",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/skills-dev-process",
"version": "0.1.0",
"description": "Software process workflows: plan, implement, debug, test, review, security-review, simplify, verify, hand off, and interview for missing requirements.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -128,7 +128,7 @@
},
{
"name": "skills-docs",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/skills-docs",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/skills-docs",
"version": "0.1.0",
"description": "Workflows for Word, PDF, PowerPoint and Excel files, plus writing repository documentation. No account or connection needed.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -137,7 +137,7 @@
},
{
"name": "skills-google",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/skills-google",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/skills-google",
"version": "0.1.0",
"description": "Workflows for Gmail and Google Calendar through a connected tool or your own OAuth client. Does not connect an account by itself.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -146,7 +146,7 @@
},
{
"name": "skills-personal",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/skills-personal",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/skills-personal",
"version": "0.1.0",
"description": "Everyday workflows for bank balances, shopping, flights, local Photos, Spotify, speech, podcasts and image search. Each skill states its own setup.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -155,7 +155,7 @@
},
{
"name": "vercel-workflow",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/vercel-workflow",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/vercel-workflow",
"version": "0.1.0",
"description": "Vercel workflows for Codewhale: preview and production deploys only after you approve, environment variables, runtime and build logs, and Next.js configuration.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -164,7 +164,7 @@
},
{
"name": "web-artifacts-builder",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/web-artifacts-builder",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/web-artifacts-builder",
"version": "0.1.0",
"description": "Build a self-contained single-file or small React and Tailwind page locally, check that it is really self-contained, and preview it on loopback.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -173,7 +173,7 @@
},
{
"name": "whalesong",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/whalesong",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/whalesong",
"version": "0.2.0",
"description": "Review agent traces and failures, compare runs, and turn session timing into audio. Requires the local Whalesong platform.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
@@ -182,7 +182,7 @@
},
{
"name": "whalewiki",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/2e3a6fba453c9b34d5b1444ab5bd6edc7b119189#path=plugins/whalewiki",
"source": "https://codeload.github.com/codewhale-hq/codewhale-plugin-marketplace/tar.gz/a5f1d210372ff48c9a278a0a39737956ac0f2d93#path=plugins/whalewiki",
"version": "0.2.0",
"description": "Understand a repo, find where to change it, and see which docs depend on a file. Source citations, freshness checks and a searchable offline reader.",
"homepage": "https://github.com/codewhale-hq/codewhale-plugin-marketplace",
-84
View File
@@ -7,89 +7,5 @@ fn main() {
);
println!("cargo:rerun-if-env-changed=CARGO_MANIFEST_DIR");
codewhale_build_support::declare_rerun_conditions(&manifest_dir);
build_computer_use_helper(&manifest_dir);
codewhale_build_support::emit_build_version(&manifest_dir, env!("CARGO_PKG_VERSION"));
}
/// Ship native computer-use support with macOS binaries. Requiring clang on
/// the customer's machine would make the built-in plugin a source-only demo.
fn build_computer_use_helper(manifest_dir: &std::path::Path) {
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("macos") {
return;
}
let source = manifest_dir.join("plugins/computer-use/src/backends/darwin-accessibility.m");
println!("cargo:rerun-if-changed={}", source.display());
println!(
"cargo:rerun-if-changed={}",
source.with_file_name("darwin-recording.h").display()
);
println!(
"cargo:rerun-if-changed={}",
source.with_file_name("darwin-ocr.h").display()
);
println!("cargo:rerun-if-env-changed=CODEWHALE_CU_SIGN_IDENTITY");
let arch = match std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() {
Ok("aarch64") => "arm64",
Ok("x86_64") => "x86_64",
other => panic!("unsupported macOS computer-use architecture: {other:?}"),
};
let output = PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo sets OUT_DIR"))
.join("computer-use-accessibility");
let compiled = std::process::Command::new("xcrun")
.args([
"clang",
"-fobjc-arc",
"-Os",
"-arch",
arch,
"-mmacosx-version-min=13.0",
"-framework",
"Cocoa",
"-framework",
"ApplicationServices",
"-framework",
"ScreenCaptureKit",
"-framework",
"AVFoundation",
"-framework",
"CoreMedia",
"-framework",
"Vision",
])
.arg(&source)
.arg("-o")
.arg(&output)
.output()
.expect("macOS builds require Xcode Command Line Tools to package Computer Use");
assert!(
compiled.status.success(),
"Computer Use helper compilation failed: {}",
String::from_utf8_lossy(&compiled.stderr)
);
// Developer builds use ad-hoc signing; release builders can supply the
// same Developer ID as the app. No keychain lookup or credential copying.
let identity = std::env::var("CODEWHALE_CU_SIGN_IDENTITY").unwrap_or_else(|_| "-".into());
let signed = std::process::Command::new("codesign")
.args([
"--force",
if identity == "-" {
"--timestamp=none"
} else {
"--timestamp"
},
"--options",
"runtime",
"--identifier",
"net.codewhale.computer-use.helper",
"--sign",
])
.arg(&identity)
.arg(&output)
.output()
.expect("macOS builds require codesign to package Computer Use");
assert!(
signed.status.success(),
"Computer Use helper signing failed: {}",
String::from_utf8_lossy(&signed.stderr)
);
}
+24 -2
View File
@@ -12,6 +12,16 @@ import { redactMcpOAuthLogs } from './scripts/redact-mcp-oauth-logs.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const outdir = join(here, 'dist')
mkdirSync(outdir, { recursive: true })
const compatibilityBytes = readFileSync(join(here, 'src/dsh/compatibility.json'))
const compatibility = JSON.parse(compatibilityBytes)
writeFileSync(join(outdir, 'dsh-compatibility.json'), compatibilityBytes)
const reusedPure = compatibility.pure_api.reuse
const reusedRoot = join(here, 'node_modules', reusedPure.package)
const reusedPackage = JSON.parse(readFileSync(join(reusedRoot, 'package.json'), 'utf8'))
const reusedSource = readFileSync(join(reusedRoot, reusedPure.file), 'utf8').split('\n').slice(reusedPure.lines[0] - 1, reusedPure.lines[1]).join('\n')
if (reusedPackage.version !== reusedPure.version || createHash('sha256').update(reusedSource).digest('hex') !== reusedPure.sha256) {
throw new Error(`${reusedPure.package} ${reusedPure.symbol} differs from the pinned pure helper receipt`)
}
// Built-in host modules (tier 0): each `src/builtin/<id>.ts` is built to
// `dist/builtin/<id>.mjs`, the file the core activates under the owner id
@@ -235,11 +245,20 @@ function readLicense(root, label) {
const EXCERPTED = [
{
name: '@deepseek-ai/dsh-tools',
version: '0.1.7-alpha.2',
version: compatibility.definition_api.version,
license: 'MIT',
file: 'src/dsh/dsh-tools-compat.js',
licenseOf: '@deepseek-ai/dsh-util-values',
},
...compatibility.pure_api.packages.filter(pkg => pkg.excerpted).map(pkg => ({
name: pkg.package,
version: pkg.version,
license: pkg.license,
file: compatibility.pure_api.file,
sourceSha256: compatibility.pure_api.source_sha256,
licenseTextSha256: pkg.license_text_sha256,
licenseOf: '@deepseek-ai/dsh-util-values',
})),
]
const packageInputs = [hostBuild.metafile, reviewBuild.metafile, ...builtinMetafiles].flatMap(bundledPackages)
@@ -259,11 +278,14 @@ if (packages.length === 0) throw new Error('the host bundle contains no node_mod
const entries = packages.map((pkg) => ({ ...pkg, note: '' }))
for (const excerpt of EXCERPTED) {
const source = readFileSync(join(here, excerpt.file), 'utf8')
if (!source.includes(`${excerpt.name}@${excerpt.version}`)) {
if (excerpt.sourceSha256 ? createHash('sha256').update(source).digest('hex') !== excerpt.sourceSha256 : !source.includes(`${excerpt.name}@${excerpt.version}`)) {
throw new Error(`${excerpt.file} no longer names ${excerpt.name}@${excerpt.version}; update EXCERPTED in build.mjs`)
}
const shared = packages.find((pkg) => pkg.name === excerpt.licenseOf)
if (!shared) throw new Error(`${excerpt.licenseOf} (licence text of ${excerpt.name}) is not bundled`)
if (excerpt.licenseTextSha256 && (shared.license !== excerpt.license || createHash('sha256').update(shared.text).digest('hex') !== excerpt.licenseTextSha256)) {
throw new Error(`${excerpt.name} licence text differs from its pinned pure helper receipt`)
}
entries.push({
name: excerpt.name,
version: excerpt.version,
+151 -6
View File
@@ -8,16 +8,21 @@ 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/dsh-tools@0.1.7-alpha.2 (MIT)
@deepseek-ai/dsh-tools@0.2.1-rc.2 (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@deepseek-ai/[email protected] (MIT)
@modelcontextprotocol/client@2.3.1 (Apache-2.0)
@modelcontextprotocol/core@2.3.1 (Apache-2.0)
@modelcontextprotocol/client@2.4.0 (Apache-2.0)
@modelcontextprotocol/core@2.4.0 (Apache-2.0)
[email protected] (MIT)
[email protected] (MIT)
[email protected] (MIT)
@@ -190,6 +195,34 @@ 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)
========================================================================
Verbatim excerpts of this package are in src/dsh/dsh-message-compat.js, under the same licence text as @deepseek-ai/dsh-util-values above.
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)
========================================================================
@@ -274,6 +307,34 @@ 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)
========================================================================
Verbatim excerpts of this package are in src/dsh/dsh-message-compat.js, under the same licence text as @deepseek-ai/dsh-util-values above.
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)
========================================================================
@@ -302,6 +363,62 @@ 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)
========================================================================
Verbatim excerpts of this package are in src/dsh/dsh-message-compat.js, under the same licence text as @deepseek-ai/dsh-util-values above.
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)
========================================================================
Verbatim excerpts of this package are in src/dsh/dsh-message-compat.js, under the same licence text as @deepseek-ai/dsh-util-values above.
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)
========================================================================
@@ -331,7 +448,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@deepseek-ai/dsh-tools@0.1.7-alpha.2 (MIT)
@deepseek-ai/dsh-tools@0.2.1-rc.2 (MIT)
========================================================================
Verbatim excerpts of this package are in src/dsh/dsh-tools-compat.js, under the same licence text as @deepseek-ai/dsh-util-values above.
@@ -358,6 +475,34 @@ 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)
========================================================================
Verbatim excerpts of this package are in src/dsh/dsh-message-compat.js, under the same licence text as @deepseek-ai/dsh-util-values above.
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)
========================================================================
@@ -411,7 +556,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
@modelcontextprotocol/client@2.3.1 (Apache-2.0)
@modelcontextprotocol/client@2.4.0 (Apache-2.0)
========================================================================
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.
@@ -632,7 +777,7 @@ CC-BY-4.0. See https://creativecommons.org/licenses/by/4.0/legalcode for
the full license text.
========================================================================
@modelcontextprotocol/core@2.3.1 (Apache-2.0)
@modelcontextprotocol/core@2.4.0 (Apache-2.0)
========================================================================
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.
+292 -3
View File
@@ -12060,6 +12060,273 @@ var init_bun_closure = __esm({
}
});
// src/dsh/compatibility.json
var compatibility_default;
var init_compatibility = __esm({
"src/dsh/compatibility.json"() {
compatibility_default = {
version: 2,
target_dsh_version: "0.2.1-rc.2",
provided_services: [
"tools",
"commands",
"prompt",
"storage",
"skills",
"avatars",
"mcp",
"shellHooks",
"logger",
"events",
"reflect",
"registry"
],
refused_services: [
"shellHooks",
"loader",
"approval",
"agents",
"subagents",
"sessions",
"llm",
"sandboxPolicy",
"credentials",
"fs",
"subprocess",
"systemPrompt",
"runtimeLoop",
"tools",
"commands",
"prompt",
"storage",
"skills",
"avatars",
"mcp",
"logger"
],
row_bridges: {
"@deepseek-ai/dsh-mcp-client": "mcp",
"@deepseek-ai/dsh-skill-filesystem": "skills",
"@deepseek-ai/dsh-hooks-claude-code": "hooks-claude-code",
"@deepseek-ai/dsh-hooks-codex": "hooks-codex",
"@deepseek-ai/dsh-persona": "persona"
},
module_singletons: {
"@codewhale/dsh-composition": "dsh-composition",
"@deepseek-ai/cordis-plugin-loader": "dsh-loader",
"@deepseek-ai/cordis-plugin-include": "dsh-include",
"@deepseek-ai/cordis-plugin-group": "dsh-group",
"@deepseek-ai/cordis": "cordis",
"@deepseek-ai/schemastery": "schemastery",
"@deepseek-ai/cosmokit": "cosmokit",
cosmokit: "cosmokit",
"@deepseek-ai/dsh-tools": "dsh-tools",
"@deepseek-ai/dsh-util-values": "dsh-util-values",
"@deepseek-ai/dsh-commands/brand": "dsh-commands-brand",
"@deepseek-ai/dsh-llm": "dsh-llm-values",
"@deepseek-ai/dsh-session": "dsh-session-values",
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values"
},
definition_api: {
package: "@deepseek-ai/dsh-tools",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.2.1-rc.2.tgz",
integrity: "sha512-K3gET3VPbtxM66pkeRsEGrZ+bQKiBqrTbNXiIreBKu0hP+qPvlSwhiSI5MTnFp2tMFK5rpuYMZosRIdbfrbJ/g==",
source_file: "lib/index.js",
source_sha256: "1e53932e7c4213ea24a7d87f06960b7a7702cb21f8a5741c97242b4a470a442a",
source_lines: [
[
8,
888
],
[
2625,
2655
]
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be"
},
command_followup: "one-text-message-submit-on-success",
pure_api: {
file: "src/dsh/dsh-message-compat.js",
source_sha256: "efbafde829c7ecde345ccbc84d94b676466330b7073da05163f93c03b1defc32",
exports: {
"@deepseek-ai/dsh-llm": [
"createUserMessage"
],
"@deepseek-ai/dsh-session": [
"SessionId"
],
"@deepseek-ai/dsh-system-prompt": [
"PERSONA_PREFIX_SECTION"
]
},
packages: [
{
package: "@deepseek-ai/dsh-brand",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.2.1-rc.2.tgz",
integrity: "sha512-T2kcygikRS8rv8cKjgho2x381EgGprv5yQmvHl4/iYbuHE19OW+SLGtHWbV27PGdSHz5U8CJxasYzFosKW4e2A==",
tarball_sha256: "abc43c2daa0c476881cd3441d57e662e386f6e07bf60fbde8c9e41e126f70716",
source_file: "lib/index.js",
source_sha256: "14feb59256b891982b2017d73ed03ee65e2a7365f48e4902a856acb0e52d5eb0",
excerpts: [
{
name: "brandString",
lines: [
20,
22
],
sha256: "2f0b62ba6507db54836a99e24d1d219e598b6140e575d2cf5c63d86a18e5e8ac"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-util-crypto",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-util-crypto/-/dsh-util-crypto-0.2.1-rc.2.tgz",
integrity: "sha512-V4SxvXL4ZbY5RYJckVtmo3qz4YsA52pYKvDF13+8hsDCEmQIv9YfmLRMA61pjK/i05pGMe3p1pPhAB6L+NOIEA==",
tarball_sha256: "333710abf757d423118d9d48f8376d5731eea802bbefeb9f1182db6d58360195",
source_file: "lib/index.js",
source_sha256: "640b26c8e5a1156c6c8e8749a22f805c9c0e34444778eb4ee9529be3e88ec862",
excerpts: [
{
name: "randomUUID",
lines: [
27,
33
],
sha256: "e6c3b47d5cf366a149204c36d1697e658f61c48bfd96e66ec1c96deec3a8f4e2"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-llm",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.2.1-rc.2.tgz",
integrity: "sha512-3xfoMDHEpUJGcH2Lu+lNXUJYRBclYfBkdCG9Au02VzIjYAm9BrFmc/MNuaVTg0vwgvPpTsd6Fx1IeGtfmQFvlg==",
tarball_sha256: "00e45b51cf850817c7a27c00203a1715853f9e476ff44256e895f8aafda2b8ba",
source_file: "lib/index.js",
source_sha256: "d18d8ae4c8d961e628708c1e4698e09716a0ebb80e55a672e0bde1029bec1155",
excerpts: [
{
name: "createMessage",
lines: [
37,
42
],
sha256: "74baa44c9dea1821d97bd4fe592b21baf31f60eaf06962a269fcf2ec88e352ae"
},
{
name: "createUserMessage",
lines: [
59,
64
],
sha256: "2c4929c2c18e511aa0fefe216eb50e703191057bb15ba1412ddcfdcc8bfc020f"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-session",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.2.1-rc.2.tgz",
integrity: "sha512-WDA4lIrXtG5nf1dn2wrA4UzEr7D6e8T3AdPpVSZ7eY4N4ICMDZ0RYuD4GkM+iSb7WkLojdO8ljr0etiIMEbqJg==",
tarball_sha256: "72b538e5e120e1f79c923ad527742640fe56cba50db0a7bacdc12ad73e0225e7",
source_file: "lib/index.js",
source_sha256: "2f5dd07cb5c744a29abbb8003a8a4cefd9e582c6e2538074ca299a0ddd02a3bf",
excerpts: [
{
name: "SessionId",
lines: [
13,
15
],
sha256: "68a5778f0586d4e1c219ce6ae3cec3120a57446bbb91f371b4ba208533f48996"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-system-prompt",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.2.1-rc.2.tgz",
integrity: "sha512-X1WG2FMyfZapAkcCYyTl0UO5vVqDcCsA9wECJY7eeDTLHJDsqrilQJg2DRgHQzM+2/7T01QyzFZhKd7hfALJxg==",
tarball_sha256: "d186153582b801c3f16a84d1a0651910957be7498929381a48ae6fe49666206d",
source_file: "lib/index.js",
source_sha256: "7be9f7aafbf345d01ddcc12fd297b16e9a1d41283a6a04073b867becca0bf020",
excerpts: [
{
name: "PERSONA_PREFIX_SECTION",
lines: [
59,
59
],
sha256: "11338254c278626eea6887b9cb5dc94d16f2ab4dae4bd7e555ea608c4b15648f"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-util-values",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-util-values/-/dsh-util-values-0.2.1-rc.2.tgz",
integrity: "sha512-mgnByPadOgZtoBxbwMI184li494O6sNn66/NvdqN0RIrzAJmFGpFxpjFkQtQN/yetwWhlx7Ic17+6p7eKl8DNg==",
tarball_sha256: "2b273a4eae9cd3a40a57f82a28489b763cd66bddc5c91953e5473e2e45e740cb",
source_file: "lib/index.js",
source_sha256: "4322a2aacdfe38476d2bbc62338120beea1e1660346d7db5b6c1e1f927a26b7c",
excerpts: [
{
name: "deepFreeze",
lines: [
824,
860
],
sha256: "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: false
}
],
reuse: {
package: "@deepseek-ai/dsh-util-values",
version: "0.1.7-alpha.2",
file: "lib/index.js",
symbol: "deepFreeze",
lines: [
194,
230
],
sha256: "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
},
tool_caller_projection: "readonly-id-session-header-cwd"
};
}
});
// src/dsh/resolve-hooks.ts
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "node:url";
import { resolve as resolve2, isAbsolute as isAbsolute2, dirname } from "node:path";
@@ -12114,13 +12381,15 @@ function closureAt(url) {
if (inside !== void 0) return { root, receipt, path: inside };
}
}
var reviewedClosures;
var SINGLETONS, reviewedClosures;
var init_resolve_hooks = __esm({
"src/dsh/resolve-hooks.ts"() {
"use strict";
init_canonical_path();
init_runtime();
init_bun_closure();
init_compatibility();
SINGLETONS = compatibility_default.module_singletons;
reviewedClosures = /* @__PURE__ */ new Map();
}
});
@@ -12240,7 +12509,7 @@ function reviewComposition(spec) {
return { version: 1, entries, warnings, skipped, layers };
}
function hasReviewedRowBridge(name) {
return name === "@deepseek-ai/dsh-mcp-client" || name === "@deepseek-ai/dsh-skill-filesystem" || name === "@deepseek-ai/dsh-hooks-claude-code" || name === "@deepseek-ai/dsh-hooks-codex" || name === PERSONA;
return Object.hasOwn(compatibility_default.row_bridges, name);
}
function compositionRowProblem(name) {
return UNSUPPORTED_ROWS.get(name);
@@ -12361,6 +12630,7 @@ var init_composition = __esm({
init_resolve_hooks();
init_persona();
init_agent_presets();
init_compatibility();
MAX_LAYERS = 64;
MAX_SOURCE_BYTES = 1024 * 1024;
MAX_DATA_BYTES = 4 * 1024 * 1024;
@@ -12504,6 +12774,20 @@ var init_rpc = __esm({
}
});
// node_modules/@deepseek-ai/dsh-util-values/lib/index.js
var init_lib3 = __esm({
"node_modules/@deepseek-ai/dsh-util-values/lib/index.js"() {
}
});
// src/dsh/dsh-tools-compat.js
var init_dsh_tools_compat = __esm({
"src/dsh/dsh-tools-compat.js"() {
"use strict";
init_lib3();
}
});
// src/shims/core.ts
var init_core = __esm({
"src/shims/core.ts"() {
@@ -12560,13 +12844,14 @@ var NO_ATTACHMENTS;
var init_commands = __esm({
"src/shims/commands.ts"() {
"use strict";
init_json();
NO_ATTACHMENTS = Object.freeze([]);
}
});
// src/root.ts
import { AsyncLocalStorage } from "node:async_hooks";
var ownerStorage;
var REFUSED_SERVICES, PROVIDED_SERVICES, ownerStorage;
var init_root = __esm({
"src/root.ts"() {
"use strict";
@@ -12574,6 +12859,8 @@ var init_root = __esm({
init_protocol();
init_rpc();
init_resolve_hooks();
init_compatibility();
init_dsh_tools_compat();
init_json();
init_core();
init_owned();
@@ -12586,6 +12873,8 @@ var init_root = __esm({
init_skills();
init_tier();
init_commands();
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
PROVIDED_SERVICES = new Set(compatibility_default.provided_services);
ownerStorage = new AsyncLocalStorage();
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"modules": {
"harness": "ad24207f93d963dcffe1967f6863f416117d9acd67cdfccbcef59c12ccb39f92",
"mcp": "ce5ccf035b30769cfaca0892ebdb1f5498bca373b618bcca376a12866c42de04"
"mcp": "a176634ff596dc99ad6e3ee388939231d65d6998253839a1e9ebf3a350d2fb76"
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+260
View File
@@ -0,0 +1,260 @@
{
"version": 2,
"target_dsh_version": "0.2.1-rc.2",
"provided_services": [
"tools",
"commands",
"prompt",
"storage",
"skills",
"avatars",
"mcp",
"shellHooks",
"logger",
"events",
"reflect",
"registry"
],
"refused_services": [
"shellHooks",
"loader",
"approval",
"agents",
"subagents",
"sessions",
"llm",
"sandboxPolicy",
"credentials",
"fs",
"subprocess",
"systemPrompt",
"runtimeLoop",
"tools",
"commands",
"prompt",
"storage",
"skills",
"avatars",
"mcp",
"logger"
],
"row_bridges": {
"@deepseek-ai/dsh-mcp-client": "mcp",
"@deepseek-ai/dsh-skill-filesystem": "skills",
"@deepseek-ai/dsh-hooks-claude-code": "hooks-claude-code",
"@deepseek-ai/dsh-hooks-codex": "hooks-codex",
"@deepseek-ai/dsh-persona": "persona"
},
"module_singletons": {
"@codewhale/dsh-composition": "dsh-composition",
"@deepseek-ai/cordis-plugin-loader": "dsh-loader",
"@deepseek-ai/cordis-plugin-include": "dsh-include",
"@deepseek-ai/cordis-plugin-group": "dsh-group",
"@deepseek-ai/cordis": "cordis",
"@deepseek-ai/schemastery": "schemastery",
"@deepseek-ai/cosmokit": "cosmokit",
"cosmokit": "cosmokit",
"@deepseek-ai/dsh-tools": "dsh-tools",
"@deepseek-ai/dsh-util-values": "dsh-util-values",
"@deepseek-ai/dsh-commands/brand": "dsh-commands-brand",
"@deepseek-ai/dsh-llm": "dsh-llm-values",
"@deepseek-ai/dsh-session": "dsh-session-values",
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values"
},
"definition_api": {
"package": "@deepseek-ai/dsh-tools",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.2.1-rc.2.tgz",
"integrity": "sha512-K3gET3VPbtxM66pkeRsEGrZ+bQKiBqrTbNXiIreBKu0hP+qPvlSwhiSI5MTnFp2tMFK5rpuYMZosRIdbfrbJ/g==",
"source_file": "lib/index.js",
"source_sha256": "1e53932e7c4213ea24a7d87f06960b7a7702cb21f8a5741c97242b4a470a442a",
"source_lines": [
[
8,
888
],
[
2625,
2655
]
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be"
},
"command_followup": "one-text-message-submit-on-success",
"pure_api": {
"file": "src/dsh/dsh-message-compat.js",
"source_sha256": "efbafde829c7ecde345ccbc84d94b676466330b7073da05163f93c03b1defc32",
"exports": {
"@deepseek-ai/dsh-llm": [
"createUserMessage"
],
"@deepseek-ai/dsh-session": [
"SessionId"
],
"@deepseek-ai/dsh-system-prompt": [
"PERSONA_PREFIX_SECTION"
]
},
"packages": [
{
"package": "@deepseek-ai/dsh-brand",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.2.1-rc.2.tgz",
"integrity": "sha512-T2kcygikRS8rv8cKjgho2x381EgGprv5yQmvHl4/iYbuHE19OW+SLGtHWbV27PGdSHz5U8CJxasYzFosKW4e2A==",
"tarball_sha256": "abc43c2daa0c476881cd3441d57e662e386f6e07bf60fbde8c9e41e126f70716",
"source_file": "lib/index.js",
"source_sha256": "14feb59256b891982b2017d73ed03ee65e2a7365f48e4902a856acb0e52d5eb0",
"excerpts": [
{
"name": "brandString",
"lines": [
20,
22
],
"sha256": "2f0b62ba6507db54836a99e24d1d219e598b6140e575d2cf5c63d86a18e5e8ac"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": true
},
{
"package": "@deepseek-ai/dsh-util-crypto",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-util-crypto/-/dsh-util-crypto-0.2.1-rc.2.tgz",
"integrity": "sha512-V4SxvXL4ZbY5RYJckVtmo3qz4YsA52pYKvDF13+8hsDCEmQIv9YfmLRMA61pjK/i05pGMe3p1pPhAB6L+NOIEA==",
"tarball_sha256": "333710abf757d423118d9d48f8376d5731eea802bbefeb9f1182db6d58360195",
"source_file": "lib/index.js",
"source_sha256": "640b26c8e5a1156c6c8e8749a22f805c9c0e34444778eb4ee9529be3e88ec862",
"excerpts": [
{
"name": "randomUUID",
"lines": [
27,
33
],
"sha256": "e6c3b47d5cf366a149204c36d1697e658f61c48bfd96e66ec1c96deec3a8f4e2"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": true
},
{
"package": "@deepseek-ai/dsh-llm",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.2.1-rc.2.tgz",
"integrity": "sha512-3xfoMDHEpUJGcH2Lu+lNXUJYRBclYfBkdCG9Au02VzIjYAm9BrFmc/MNuaVTg0vwgvPpTsd6Fx1IeGtfmQFvlg==",
"tarball_sha256": "00e45b51cf850817c7a27c00203a1715853f9e476ff44256e895f8aafda2b8ba",
"source_file": "lib/index.js",
"source_sha256": "d18d8ae4c8d961e628708c1e4698e09716a0ebb80e55a672e0bde1029bec1155",
"excerpts": [
{
"name": "createMessage",
"lines": [
37,
42
],
"sha256": "74baa44c9dea1821d97bd4fe592b21baf31f60eaf06962a269fcf2ec88e352ae"
},
{
"name": "createUserMessage",
"lines": [
59,
64
],
"sha256": "2c4929c2c18e511aa0fefe216eb50e703191057bb15ba1412ddcfdcc8bfc020f"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": true
},
{
"package": "@deepseek-ai/dsh-session",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.2.1-rc.2.tgz",
"integrity": "sha512-WDA4lIrXtG5nf1dn2wrA4UzEr7D6e8T3AdPpVSZ7eY4N4ICMDZ0RYuD4GkM+iSb7WkLojdO8ljr0etiIMEbqJg==",
"tarball_sha256": "72b538e5e120e1f79c923ad527742640fe56cba50db0a7bacdc12ad73e0225e7",
"source_file": "lib/index.js",
"source_sha256": "2f5dd07cb5c744a29abbb8003a8a4cefd9e582c6e2538074ca299a0ddd02a3bf",
"excerpts": [
{
"name": "SessionId",
"lines": [
13,
15
],
"sha256": "68a5778f0586d4e1c219ce6ae3cec3120a57446bbb91f371b4ba208533f48996"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": true
},
{
"package": "@deepseek-ai/dsh-system-prompt",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.2.1-rc.2.tgz",
"integrity": "sha512-X1WG2FMyfZapAkcCYyTl0UO5vVqDcCsA9wECJY7eeDTLHJDsqrilQJg2DRgHQzM+2/7T01QyzFZhKd7hfALJxg==",
"tarball_sha256": "d186153582b801c3f16a84d1a0651910957be7498929381a48ae6fe49666206d",
"source_file": "lib/index.js",
"source_sha256": "7be9f7aafbf345d01ddcc12fd297b16e9a1d41283a6a04073b867becca0bf020",
"excerpts": [
{
"name": "PERSONA_PREFIX_SECTION",
"lines": [
59,
59
],
"sha256": "11338254c278626eea6887b9cb5dc94d16f2ab4dae4bd7e555ea608c4b15648f"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": true
},
{
"package": "@deepseek-ai/dsh-util-values",
"version": "0.2.1-rc.2",
"license": "MIT",
"tarball": "https://registry.npmjs.org/@deepseek-ai/dsh-util-values/-/dsh-util-values-0.2.1-rc.2.tgz",
"integrity": "sha512-mgnByPadOgZtoBxbwMI184li494O6sNn66/NvdqN0RIrzAJmFGpFxpjFkQtQN/yetwWhlx7Ic17+6p7eKl8DNg==",
"tarball_sha256": "2b273a4eae9cd3a40a57f82a28489b763cd66bddc5c91953e5473e2e45e740cb",
"source_file": "lib/index.js",
"source_sha256": "4322a2aacdfe38476d2bbc62338120beea1e1660346d7db5b6c1e1f927a26b7c",
"excerpts": [
{
"name": "deepFreeze",
"lines": [
824,
860
],
"sha256": "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
],
"license_sha256": "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
"license_text_sha256": "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
"excerpted": false
}
],
"reuse": {
"package": "@deepseek-ai/dsh-util-values",
"version": "0.1.7-alpha.2",
"file": "lib/index.js",
"symbol": "deepFreeze",
"lines": [
194,
230
],
"sha256": "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
},
"tool_caller_projection": "readonly-id-session-header-cwd"
}
+292 -3
View File
@@ -12011,6 +12011,273 @@ var init_bun_closure = __esm({
}
});
// src/dsh/compatibility.json
var compatibility_default;
var init_compatibility = __esm({
"src/dsh/compatibility.json"() {
compatibility_default = {
version: 2,
target_dsh_version: "0.2.1-rc.2",
provided_services: [
"tools",
"commands",
"prompt",
"storage",
"skills",
"avatars",
"mcp",
"shellHooks",
"logger",
"events",
"reflect",
"registry"
],
refused_services: [
"shellHooks",
"loader",
"approval",
"agents",
"subagents",
"sessions",
"llm",
"sandboxPolicy",
"credentials",
"fs",
"subprocess",
"systemPrompt",
"runtimeLoop",
"tools",
"commands",
"prompt",
"storage",
"skills",
"avatars",
"mcp",
"logger"
],
row_bridges: {
"@deepseek-ai/dsh-mcp-client": "mcp",
"@deepseek-ai/dsh-skill-filesystem": "skills",
"@deepseek-ai/dsh-hooks-claude-code": "hooks-claude-code",
"@deepseek-ai/dsh-hooks-codex": "hooks-codex",
"@deepseek-ai/dsh-persona": "persona"
},
module_singletons: {
"@codewhale/dsh-composition": "dsh-composition",
"@deepseek-ai/cordis-plugin-loader": "dsh-loader",
"@deepseek-ai/cordis-plugin-include": "dsh-include",
"@deepseek-ai/cordis-plugin-group": "dsh-group",
"@deepseek-ai/cordis": "cordis",
"@deepseek-ai/schemastery": "schemastery",
"@deepseek-ai/cosmokit": "cosmokit",
cosmokit: "cosmokit",
"@deepseek-ai/dsh-tools": "dsh-tools",
"@deepseek-ai/dsh-util-values": "dsh-util-values",
"@deepseek-ai/dsh-commands/brand": "dsh-commands-brand",
"@deepseek-ai/dsh-llm": "dsh-llm-values",
"@deepseek-ai/dsh-session": "dsh-session-values",
"@deepseek-ai/dsh-system-prompt": "dsh-system-prompt-values"
},
definition_api: {
package: "@deepseek-ai/dsh-tools",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.2.1-rc.2.tgz",
integrity: "sha512-K3gET3VPbtxM66pkeRsEGrZ+bQKiBqrTbNXiIreBKu0hP+qPvlSwhiSI5MTnFp2tMFK5rpuYMZosRIdbfrbJ/g==",
source_file: "lib/index.js",
source_sha256: "1e53932e7c4213ea24a7d87f06960b7a7702cb21f8a5741c97242b4a470a442a",
source_lines: [
[
8,
888
],
[
2625,
2655
]
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be"
},
command_followup: "one-text-message-submit-on-success",
pure_api: {
file: "src/dsh/dsh-message-compat.js",
source_sha256: "efbafde829c7ecde345ccbc84d94b676466330b7073da05163f93c03b1defc32",
exports: {
"@deepseek-ai/dsh-llm": [
"createUserMessage"
],
"@deepseek-ai/dsh-session": [
"SessionId"
],
"@deepseek-ai/dsh-system-prompt": [
"PERSONA_PREFIX_SECTION"
]
},
packages: [
{
package: "@deepseek-ai/dsh-brand",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.2.1-rc.2.tgz",
integrity: "sha512-T2kcygikRS8rv8cKjgho2x381EgGprv5yQmvHl4/iYbuHE19OW+SLGtHWbV27PGdSHz5U8CJxasYzFosKW4e2A==",
tarball_sha256: "abc43c2daa0c476881cd3441d57e662e386f6e07bf60fbde8c9e41e126f70716",
source_file: "lib/index.js",
source_sha256: "14feb59256b891982b2017d73ed03ee65e2a7365f48e4902a856acb0e52d5eb0",
excerpts: [
{
name: "brandString",
lines: [
20,
22
],
sha256: "2f0b62ba6507db54836a99e24d1d219e598b6140e575d2cf5c63d86a18e5e8ac"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-util-crypto",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-util-crypto/-/dsh-util-crypto-0.2.1-rc.2.tgz",
integrity: "sha512-V4SxvXL4ZbY5RYJckVtmo3qz4YsA52pYKvDF13+8hsDCEmQIv9YfmLRMA61pjK/i05pGMe3p1pPhAB6L+NOIEA==",
tarball_sha256: "333710abf757d423118d9d48f8376d5731eea802bbefeb9f1182db6d58360195",
source_file: "lib/index.js",
source_sha256: "640b26c8e5a1156c6c8e8749a22f805c9c0e34444778eb4ee9529be3e88ec862",
excerpts: [
{
name: "randomUUID",
lines: [
27,
33
],
sha256: "e6c3b47d5cf366a149204c36d1697e658f61c48bfd96e66ec1c96deec3a8f4e2"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-llm",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.2.1-rc.2.tgz",
integrity: "sha512-3xfoMDHEpUJGcH2Lu+lNXUJYRBclYfBkdCG9Au02VzIjYAm9BrFmc/MNuaVTg0vwgvPpTsd6Fx1IeGtfmQFvlg==",
tarball_sha256: "00e45b51cf850817c7a27c00203a1715853f9e476ff44256e895f8aafda2b8ba",
source_file: "lib/index.js",
source_sha256: "d18d8ae4c8d961e628708c1e4698e09716a0ebb80e55a672e0bde1029bec1155",
excerpts: [
{
name: "createMessage",
lines: [
37,
42
],
sha256: "74baa44c9dea1821d97bd4fe592b21baf31f60eaf06962a269fcf2ec88e352ae"
},
{
name: "createUserMessage",
lines: [
59,
64
],
sha256: "2c4929c2c18e511aa0fefe216eb50e703191057bb15ba1412ddcfdcc8bfc020f"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-session",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.2.1-rc.2.tgz",
integrity: "sha512-WDA4lIrXtG5nf1dn2wrA4UzEr7D6e8T3AdPpVSZ7eY4N4ICMDZ0RYuD4GkM+iSb7WkLojdO8ljr0etiIMEbqJg==",
tarball_sha256: "72b538e5e120e1f79c923ad527742640fe56cba50db0a7bacdc12ad73e0225e7",
source_file: "lib/index.js",
source_sha256: "2f5dd07cb5c744a29abbb8003a8a4cefd9e582c6e2538074ca299a0ddd02a3bf",
excerpts: [
{
name: "SessionId",
lines: [
13,
15
],
sha256: "68a5778f0586d4e1c219ce6ae3cec3120a57446bbb91f371b4ba208533f48996"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-system-prompt",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.2.1-rc.2.tgz",
integrity: "sha512-X1WG2FMyfZapAkcCYyTl0UO5vVqDcCsA9wECJY7eeDTLHJDsqrilQJg2DRgHQzM+2/7T01QyzFZhKd7hfALJxg==",
tarball_sha256: "d186153582b801c3f16a84d1a0651910957be7498929381a48ae6fe49666206d",
source_file: "lib/index.js",
source_sha256: "7be9f7aafbf345d01ddcc12fd297b16e9a1d41283a6a04073b867becca0bf020",
excerpts: [
{
name: "PERSONA_PREFIX_SECTION",
lines: [
59,
59
],
sha256: "11338254c278626eea6887b9cb5dc94d16f2ab4dae4bd7e555ea608c4b15648f"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: true
},
{
package: "@deepseek-ai/dsh-util-values",
version: "0.2.1-rc.2",
license: "MIT",
tarball: "https://registry.npmjs.org/@deepseek-ai/dsh-util-values/-/dsh-util-values-0.2.1-rc.2.tgz",
integrity: "sha512-mgnByPadOgZtoBxbwMI184li494O6sNn66/NvdqN0RIrzAJmFGpFxpjFkQtQN/yetwWhlx7Ic17+6p7eKl8DNg==",
tarball_sha256: "2b273a4eae9cd3a40a57f82a28489b763cd66bddc5c91953e5473e2e45e740cb",
source_file: "lib/index.js",
source_sha256: "4322a2aacdfe38476d2bbc62338120beea1e1660346d7db5b6c1e1f927a26b7c",
excerpts: [
{
name: "deepFreeze",
lines: [
824,
860
],
sha256: "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
],
license_sha256: "ebb4f09972aee8608be255debaf78451a68e95c290f55c240dec2ecfa16ea6be",
license_text_sha256: "fe195482bb2bddf34ebced7531d6afb11c33fa42f248472736ad9fa9b9eb503b",
excerpted: false
}
],
reuse: {
package: "@deepseek-ai/dsh-util-values",
version: "0.1.7-alpha.2",
file: "lib/index.js",
symbol: "deepFreeze",
lines: [
194,
230
],
sha256: "66ee7f28877afa912496ba944a581ac74e7b87390e6a47c4c640d8f477bdc6e2"
}
},
tool_caller_projection: "readonly-id-session-header-cwd"
};
}
});
// src/dsh/resolve-hooks.ts
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "node:url";
import { resolve as resolve2, isAbsolute as isAbsolute2, dirname } from "node:path";
@@ -12065,13 +12332,15 @@ function closureAt(url) {
if (inside !== void 0) return { root, receipt, path: inside };
}
}
var reviewedClosures;
var SINGLETONS, reviewedClosures;
var init_resolve_hooks = __esm({
"src/dsh/resolve-hooks.ts"() {
"use strict";
init_canonical_path();
init_runtime();
init_bun_closure();
init_compatibility();
SINGLETONS = compatibility_default.module_singletons;
reviewedClosures = /* @__PURE__ */ new Map();
}
});
@@ -12154,6 +12423,20 @@ var init_rpc = __esm({
}
});
// node_modules/@deepseek-ai/dsh-util-values/lib/index.js
var init_lib3 = __esm({
"node_modules/@deepseek-ai/dsh-util-values/lib/index.js"() {
}
});
// src/dsh/dsh-tools-compat.js
var init_dsh_tools_compat = __esm({
"src/dsh/dsh-tools-compat.js"() {
"use strict";
init_lib3();
}
});
// src/shims/core.ts
var init_core = __esm({
"src/shims/core.ts"() {
@@ -12210,13 +12493,14 @@ var NO_ATTACHMENTS;
var init_commands = __esm({
"src/shims/commands.ts"() {
"use strict";
init_json();
NO_ATTACHMENTS = Object.freeze([]);
}
});
// src/root.ts
import { AsyncLocalStorage } from "node:async_hooks";
var ownerStorage;
var REFUSED_SERVICES, PROVIDED_SERVICES, ownerStorage;
var init_root = __esm({
"src/root.ts"() {
"use strict";
@@ -12224,6 +12508,8 @@ var init_root = __esm({
init_protocol();
init_rpc();
init_resolve_hooks();
init_compatibility();
init_dsh_tools_compat();
init_json();
init_core();
init_owned();
@@ -12236,6 +12522,8 @@ var init_root = __esm({
init_skills();
init_tier();
init_commands();
REFUSED_SERVICES = new Set(compatibility_default.refused_services);
PROVIDED_SERVICES = new Set(compatibility_default.provided_services);
ownerStorage = new AsyncLocalStorage();
}
});
@@ -12964,7 +13252,7 @@ function reviewComposition(spec) {
return { version: 1, entries, warnings, skipped, layers };
}
function hasReviewedRowBridge(name) {
return name === "@deepseek-ai/dsh-mcp-client" || name === "@deepseek-ai/dsh-skill-filesystem" || name === "@deepseek-ai/dsh-hooks-claude-code" || name === "@deepseek-ai/dsh-hooks-codex" || name === PERSONA;
return Object.hasOwn(compatibility_default.row_bridges, name);
}
function compositionRowProblem(name) {
return UNSUPPORTED_ROWS.get(name);
@@ -13085,6 +13373,7 @@ var init_composition = __esm({
init_resolve_hooks();
init_persona();
init_agent_presets();
init_compatibility();
MAX_LAYERS = 64;
MAX_SOURCE_BYTES = 1024 * 1024;
MAX_DATA_BYTES = 4 * 1024 * 1024;
+2 -2
View File
@@ -150,12 +150,12 @@ var SHAPES = {
McpHttpHeaders: {
strict: true,
required: {},
optional: { accept: "string", content_type: "string", mcp_session_id: "string", mcp_protocol_version: "string" }
optional: { accept: "string", content_type: "string", mcp_session_id: "string", mcp_protocol_version: "string", mcp_method: "string", mcp_name: "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: {}
optional: { discover_grant: { ref: "McpOperationGrant" } }
},
McpOperationGrant: {
strict: true,
+2 -2
View File
@@ -147,12 +147,12 @@ var SHAPES = {
McpHttpHeaders: {
strict: true,
required: {},
optional: { accept: "string", content_type: "string", mcp_session_id: "string", mcp_protocol_version: "string" }
optional: { accept: "string", content_type: "string", mcp_session_id: "string", mcp_protocol_version: "string", mcp_method: "string", mcp_name: "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: {}
optional: { discover_grant: { ref: "McpOperationGrant" } }
},
McpOperationGrant: {
strict: true,
Loaded 100 of 748 files, more files were not shown because too many files have changed in this diff. Show more