mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 02:26:02 -10:00
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:
1 parent
5c3d461d8a
commit
4cf2d2cfff
748 files changed
+34370
-47487
No files matched your search
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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"));
|
||||
}
|
||||
@@ -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
@@ -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:#?}");
|
||||
|
||||
@@ -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(¤t_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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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} से अधिक है"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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} を超えています"
|
||||
}
|
||||
@@ -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}를 초과합니다"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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>,
|
||||
@@ -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 {
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
File renamed without changes.
@@ -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),
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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'~')
|
||||
})
|
||||
}
|
||||
@@ -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(
|
||||
®istration.issuer,
|
||||
®istration.client_id,
|
||||
®istration.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
+9
-154
@@ -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.
File renamed without changes.
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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}")
|
||||
}
|
||||
@@ -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.
|
||||
+9
-6
@@ -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
@@ -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'
|
||||
|
||||
@@ -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"] }
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"modules": {
|
||||
"harness": "ad24207f93d963dcffe1967f6863f416117d9acd67cdfccbcef59c12ccb39f92",
|
||||
"mcp": "ce5ccf035b30769cfaca0892ebdb1f5498bca373b618bcca376a12866c42de04"
|
||||
"mcp": "a176634ff596dc99ad6e3ee388939231d65d6998253839a1e9ebf3a350d2fb76"
|
||||
}
|
||||
}
|
||||
+1083
-679
File diff suppressed because it is too large.
Load diff
+1499
-1138
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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
@@ -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
@@ -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
Reference in new issue
Block a user