diff --git a/.cnb.yml b/.cnb.yml index 5db62fbb7a..9ef320e273 100644 --- a/.cnb.yml +++ b/.cnb.yml @@ -162,7 +162,7 @@ $: echo "ERROR: CNB_COMMIT ${commit_sha} does not match checkout ${checkout_sha}" >&2 exit 1 fi - export DEEPSEEK_BUILD_SHA="$commit_sha" + export CODEWHALE_BUILD_SHA="$commit_sha" cargo build --jobs 2 --release --locked \ --target x86_64-unknown-linux-musl \ -p codewhale-cli # single binary diff --git a/.github/AUTHOR_MAP b/.github/AUTHOR_MAP index a85da66696..d898c14acf 100644 --- a/.github/AUTHOR_MAP +++ b/.github/AUTHOR_MAP @@ -52,6 +52,7 @@ angziii = angziii <177907677+angziii@users.noreply.github.com> aboimpinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com> Paulo Aboim Pinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com> aboimpinto@gmail.com = aboimpinto <1231687+aboimpinto@users.noreply.github.com> +paulo.aboim.pinto@gmail.com = aboimpinto <1231687+aboimpinto@users.noreply.github.com> encyc = encyc <62669951+encyc@users.noreply.github.com> Duducoco = Duducoco <69681789+Duducoco@users.noreply.github.com> cyq1017 = cyq1017 <61975706+cyq1017@users.noreply.github.com> @@ -66,6 +67,7 @@ THINKER-ONLY = THINKER-ONLY <181556007+THINKER-ONLY@users.noreply.github.com> nightt5879 = nightt5879 <87569709+nightt5879@users.noreply.github.com> LmeSzinc = LmeSzinc <37934724+LmeSzinc@users.noreply.github.com> Lstarsky0 = Lstarsky0 <59827030+Lstarsky0@users.noreply.github.com> +RepentStar = RepentStar <87593085+RepentStar@users.noreply.github.com> CCChisato = Fushimi Rio <158128433+CCChisato@users.noreply.github.com> aznikline = aznikline <27564626+aznikline@users.noreply.github.com> Aznable = aznikline <27564626+aznikline@users.noreply.github.com> diff --git a/.github/scripts/release-workflows.test.js b/.github/scripts/release-workflows.test.js index 97edfef21b..29913191a2 100755 --- a/.github/scripts/release-workflows.test.js +++ b/.github/scripts/release-workflows.test.js @@ -230,7 +230,7 @@ assert.match(releaseStaticSmoke, /"\$\{bin_path\}" --version/); const builtAssetNames = [ ...valuesForKey(artifacts, "cli_artifact"), ...valuesForKey(artifacts, "shim_artifact"), - ...valuesForKey(artifacts, "tui_artifact"), + ...valuesForKey(artifacts, "compat_tui_artifact"), ]; assert.equal(builtAssetNames.length, 21); assert.deepEqual( @@ -242,7 +242,7 @@ assert.deepEqual( ); assert.match( artifacts, - /stage_binary "\$\{\{ matrix\.cli_binary \}\}" "\$\{\{ matrix\.tui_artifact \}\}"/, + /stage_binary "\$\{\{ matrix\.cli_binary \}\}" "\$\{\{ matrix\.compat_tui_artifact \}\}"/, "legacy TUI bridge assets must be staged from the one compiled codewhale binary", ); const bundleInvocations = [...bundles.matchAll( @@ -463,7 +463,7 @@ assert.ok(cnbSmoke > cnbAlias, "CNB release preflight must materialize codew bef const cnbTagRelease = cnb.match(/\$:\n tag_push:\n([\s\S]*)$/); assert.ok(cnbTagRelease, "CNB must retain a tag release pipeline"); const cnbTagStamp = cnbTagRelease[1].indexOf( - 'export DEEPSEEK_BUILD_SHA="$commit_sha"', + 'export CODEWHALE_BUILD_SHA="$commit_sha"', ); const cnbTagBuild = cnbTagRelease[1].indexOf( "cargo build --jobs 2 --release --locked \\", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1230877360..e20a0c374f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,7 +135,7 @@ jobs: workflow=true ;; esac - # Mobile runtime surface: the `codewhale-tui serve --mobile` + # Mobile runtime surface: the `codewhale serve --mobile` # HTTP/SSE stack that scripts/mobile-smoke.sh exercises. Pull # requests run the smoke only when one of these changes; every # push to main still runs it unconditionally as the pre-release diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 4d5e9b0900..0d4f2db47c 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -21,7 +21,7 @@ env: CARGO_TERM_COLOR: always CARGO_INCREMENTAL: 0 RUSTFLAGS: -Dwarnings - DEEPSEEK_BUILD_SHA: ${{ github.sha }} + CODEWHALE_BUILD_SHA: ${{ github.sha }} jobs: build: diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index c3227fb78c..7e1f512378 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -26,7 +26,7 @@ env: RUSTFLAGS: -Dwarnings # Build identity is the trusted workflow SHA. Callers pass source_sha only # so `pin` can refuse a mismatch; it must not retarget checkout or caches. - DEEPSEEK_BUILD_SHA: ${{ github.sha }} + CODEWHALE_BUILD_SHA: ${{ github.sha }} jobs: pin: @@ -67,7 +67,7 @@ jobs: shim_binary: codew cli_artifact: codewhale-linux-x64 shim_artifact: codew-linux-x64 - tui_artifact: codewhale-tui-linux-x64 + compat_tui_artifact: codewhale-tui-linux-x64 - os: ubuntu-24.04-arm target: aarch64-unknown-linux-musl platform: linux-arm64 @@ -75,7 +75,7 @@ jobs: shim_binary: codew cli_artifact: codewhale-linux-arm64 shim_artifact: codew-linux-arm64 - tui_artifact: codewhale-tui-linux-arm64 + compat_tui_artifact: codewhale-tui-linux-arm64 - os: ubuntu-latest target: aarch64-linux-android platform: android-arm64 @@ -83,7 +83,7 @@ jobs: shim_binary: codew cli_artifact: codewhale-android-arm64 shim_artifact: codew-android-arm64 - tui_artifact: codewhale-tui-android-arm64 + compat_tui_artifact: codewhale-tui-android-arm64 - os: macos-latest target: x86_64-apple-darwin platform: macos-x64 @@ -91,7 +91,7 @@ jobs: shim_binary: codew cli_artifact: codewhale-macos-x64 shim_artifact: codew-macos-x64 - tui_artifact: codewhale-tui-macos-x64 + compat_tui_artifact: codewhale-tui-macos-x64 - os: macos-latest target: aarch64-apple-darwin platform: macos-arm64 @@ -99,7 +99,7 @@ jobs: shim_binary: codew cli_artifact: codewhale-macos-arm64 shim_artifact: codew-macos-arm64 - tui_artifact: codewhale-tui-macos-arm64 + compat_tui_artifact: codewhale-tui-macos-arm64 - os: windows-latest target: x86_64-pc-windows-msvc platform: windows-x64 @@ -107,7 +107,7 @@ jobs: shim_binary: codew.exe cli_artifact: codewhale-windows-x64.exe shim_artifact: codew-windows-x64.exe - tui_artifact: codewhale-tui-windows-x64.exe + compat_tui_artifact: codewhale-tui-windows-x64.exe - os: windows-11-arm target: aarch64-pc-windows-msvc platform: windows-arm64 @@ -115,7 +115,7 @@ jobs: shim_binary: codew.exe cli_artifact: codewhale-windows-arm64.exe shim_artifact: codew-windows-arm64.exe - tui_artifact: codewhale-tui-windows-arm64.exe + compat_tui_artifact: codewhale-tui-windows-arm64.exe runs-on: ${{ matrix.os }} needs: pin steps: @@ -253,10 +253,10 @@ jobs: stage_binary "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}" stage_binary "${{ matrix.shim_binary }}" "${{ matrix.shim_artifact }}" - # One-release compatibility bridge for v0.9.4's hard-coded release + # Compatibility bridge for v0.9.4's hard-coded release # completeness/updater contract. This is the same runtime, not a - # separately compiled TUI binary. - stage_binary "${{ matrix.cli_binary }}" "${{ matrix.tui_artifact }}" + # separately compiled or installed TUI command. + stage_binary "${{ matrix.cli_binary }}" "${{ matrix.compat_tui_artifact }}" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ matrix.cli_artifact }} @@ -273,8 +273,8 @@ jobs: overwrite: true - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ${{ matrix.tui_artifact }} - path: ${{ matrix.tui_artifact }} + name: ${{ matrix.compat_tui_artifact }} + path: ${{ matrix.compat_tui_artifact }} if-no-files-found: error retention-days: ${{ inputs.retention_days }} overwrite: true diff --git a/.github/workflows/release-republish.yml b/.github/workflows/release-republish.yml index dae30563b3..ae7f61be35 100644 --- a/.github/workflows/release-republish.yml +++ b/.github/workflows/release-republish.yml @@ -153,7 +153,7 @@ jobs: platforms: linux/amd64,linux/arm64 push: true build-args: | - DEEPSEEK_BUILD_SHA=${{ needs.resolve.outputs.sha }} + CODEWHALE_BUILD_SHA=${{ needs.resolve.outputs.sha }} tags: | ${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.tag }} ${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.version }} diff --git a/.winget/Hmbown.CodeWhale.yaml b/.winget/Hmbown.CodeWhale.yaml index 825e75727e..1d23fa04f8 100644 --- a/.winget/Hmbown.CodeWhale.yaml +++ b/.winget/Hmbown.CodeWhale.yaml @@ -1,7 +1,7 @@ # Hmbown.CodeWhale — winget singleton manifest for CodeWhale (single binary) -# This is a mirror of packaging/winget/Hmbown.CodeWhale.yaml for tooling that expects .winget/. -# Keep both in sync; the canonical source is packaging/winget/Hmbown.CodeWhale.yaml. -# See packaging/winget/README.md for update instructions. +# PackageIdentifier follows the publisher convention used by the Homebrew formula (codewhale). +# Canonical maintenance instructions live in packaging/winget/README.md; the generator mirrors these exact bytes to .winget/. +# Winget installs only `codewhale` + `codew`; compatibility release filenames are not installed commands. PackageIdentifier: Hmbown.CodeWhale PackageVersion: 0.9.6 @@ -19,10 +19,10 @@ LicenseUrl: https://github.com/Hmbown/CodeWhale/blob/main/LICENSE Copyright: Copyright (c) Hmbown ShortDescription: Terminal coding agent for supported hosted and local models Description: | - CodeWhale is a terminal coding agent that runs on your machine. The v0.9.5 single-binary + CodeWhale is a terminal coding agent that runs on your machine. The v0.9.5+ single-binary release ships one `codewhale` binary per target (plus the `codew` shim) across Linux x64 (musl), Linux arm64, Android arm64, macOS x64/arm64, and Windows x64/arm64. See https://github.com/Hmbown/CodeWhale - for provider setup, Fleet workflows, and the full install guide. + for provider setup, Fleet workflows, and the full install guide (docs/INSTALL.md). Author: Hmbown Moniker: codewhale Tags: @@ -37,8 +37,19 @@ Tags: MinimumOSVersion: 10.0.0.0 ReleaseNotes: https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.6 ReleaseNotesUrl: https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.6 +InstallationNotes: | + The winget package installs the Windows binaries. For the NSIS installer (CodeWhaleSetup.exe) + the installer adds %LOCALAPPDATA%\Programs\CodeWhale\bin to the user PATH. For the portable + ZIP, winget extracts codewhale.exe and codew.exe side-by-side and adds the install location to PATH. + Verify checksums with codewhale-artifacts-sha256.txt from the same GitHub Release. +Documentations: + - DocumentLabel: Install guide + DocumentUrl: https://github.com/Hmbown/CodeWhale/blob/main/docs/INSTALL.md + - DocumentLabel: Releases + DocumentUrl: https://github.com/Hmbown/CodeWhale/releases Installers: + # Preferred: NSIS installer for Windows x64 (per-user, no elevation, adds user PATH). - Architecture: x64 InstallerType: nullsoft Scope: user @@ -46,7 +57,11 @@ Installers: InstallerSha256: 040afa01a70ddae4ea7783e563bacd962a512b4d288ee5c5c5111ef2760755a7 ProductCode: CodeWhale UpgradeBehavior: install + FileExtensions: + - toml ReleaseDate: 2026-08-12 + # Portable ZIP fallback — same single binary (codewhale.exe + codew.exe). Used when NSIS is blocked + # by policy or for winget's portable install flow. NestedInstallerType is portable (no installer). - Architecture: x64 InstallerType: zip Scope: user diff --git a/AGENTS.md b/AGENTS.md index 5a720a08ba..a39e9e2833 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,12 @@ instructions or memory. The nearest scoped `AGENTS.md` adds path-specific rules. `agent_open`/`agent_eval`/`agent_close`/`delegate_to_agent` surfaces or parallel lifecycle/tag systems. - `BASE_PROMPT` in `crates/tui/src/prompts/text.rs` is the sole base prompt. +- There is exactly one turn loop: `Engine::run_turn` in + `crates/tui/src/core/engine/turn_loop.rs`. Note that `crates/tui/src/core/` + is a module inside the TUI crate — it is not `crates/core`, which owns + request construction, bounded fragments, and thread/session types and + runs no turns. Do not add a second loop beside the one that exists; a + guard test (`crates/core/tests/single_turn_loop.rs`) fails if you do. - The system prompt + tool catalog are a session-pinned KV-cache prefix (`docs/CACHE.md`). Any new session-context contributor must state its KV-cache effect: frozen prefix vs. append-only history. Never splice a diff --git a/CHANGELOG.md b/CHANGELOG.md index 14df1be898..b992d47ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,737 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.9.11] - 2026-08-22 + +Codewhale v0.9.11 tightens the long-running agent loop, makes workflow +failures visible instead of successful-looking, adds an experimental +vision-capable DeepSeek route, and prepares reproducible Codewhale-versus-Pi +evaluation without publishing a result before a real run. The complete +item-level change record is retained below the categorized release highlights. + +### Added + +- Added first-party `deepseek-v4-flash-vision-exp` discovery and selection for + DeepSeek, including the `flash-vision` alias, bundled offline metadata, + registry and picker entries, and image-input capability on the chat route. + Context and output limits inherit from V4 Flash until DeepSeek publishes + distinct values; pricing remains unknown rather than guessed. +- Added a provider-controlled Codewhale-versus-Pi parity harness with three + hermetic coding tasks, route and reasoning-effort receipts, doctor/dry-run + modes, and bounded result artifacts. The repository ships the harness, not a + benchmark verdict; comparable real runs remain an acceptance gate. +- Added portable, secret-free config export/import with a reviewable plan, + explicit headless consent, backup and rollback, and idempotent re-import. +- Added bounded multi-file diagnostics through the existing model-facing `lsp` + tool without increasing the tool-catalog count. Thanks to **Isabel Wu + ([@wuisabel-gif](https://github.com/wuisabel-gif))** for PR #5524. +- Added portable presentation, media-attachment, and operation-digest facets to + the command contract, then moved all seven utility handlers onto the + contract-backed dispatch path. Thanks to **Paulo Aboim Pinto + ([@aboimpinto](https://github.com/aboimpinto))** for PR #5525. + +### Changed + +- Sub-agent, Fleet-worker, workflow-task, and thread-runtime model turns no + longer inherit a hidden role-based step ceiling. An omitted or zero + `max_steps` is unbounded; a positive user/config value remains an explicit + cap and is still clamped to the runtime safety ceiling. Wall-clock, provider, + heartbeat, cancellation, and admission safeguards are unchanged. +- `/rc` now mirrors one shared session rather than transferring terminal + ownership: local and web prompts remain available while idle, approvals use + first-decision-wins semantics, and transport/integrity failures remain + fail-closed. +- The terminal status rows around the composer are now two stable bands: + `provider · model · thinking level` is the persistent identity row below + the composer in every phase, and a separate activity row above the + composer carries the live phase, notices, and cost/metrics. Sending a + prompt no longer relocates the route identity above the composer, and + neither row ever duplicates it. +- The embedded local Web client now uses the current CWC Ocean hierarchy and + readable control sizing, follows the shared Enter/Shift+Enter composer + grammar, and chooses a provider plus model per new thread without mutating + Runtime defaults. Exact image-input capability is labelled honestly; a + vision-capable route does not imply that browser attachments exist. +- The runtime now has one authoritative model-turn loop. The placeholder + `crates/core` engine tree is gone, while the active TUI loop and its extracted + tool-call stages retain existing policy, hook, cancellation, and budget + behavior. Thanks to **Sun Zhenyuan + ([@bistack](https://github.com/bistack))** for PR #5523. + +### Fixed + +- Chat Completions streams now require terminal proof from `[DONE]` or a + non-empty `finish_reason`. Protocol-only frames no longer count as answer + content or time-to-first-token, and a provider continuation that ends after + tool results with no answer or tool call fails durably instead of producing + a false `Completed` receipt. +- A selected v2 Fleet now drives one bounded, deterministic Agent roster across + terminal and runtime surfaces. Fleet operator/member/explicit-route + precedence, resolved member identity, and exact `vision` requirement + admission now fail visibly instead of silently falling back, first-matching, + or rerouting. +- A workflow whose `task()` dispatch was rejected no longer loses that failure + inside a `parallel()` null slot or presents a successful-looking run. Rejected + dispatches now fail the run, persist as typed bounded receipts with an exact + count, and appear in transcript, activity detail, and workflow-panel views. +- Provider readiness, credential-source explanations, focused-agent scrolling, + compact `/status` and `/help` rendering, shell/web output bounds, MCP + lifecycle reporting, and narrow-terminal onboarding received the detailed + fixes recorded below. +- Portable config import/export now preserves typed tables, arrays, numbers, + booleans, and datetimes without stringifying them, while refusing + machine-bound trust overlays, credential readers, automatically executable + hooks/LSP definitions, local-path authority, machine-local network proxy + routes, cookies, redaction placeholders, and nested or camel/dotted + credential keys. Project and global bundle operations now load and validate + the document for their actual scope in both directions, including a + workspace whose document still lives under the legacy app directory. +- Terminal input shutdown no longer waits forever on a wedged TTY read, + Windows launch receipts can atomically replace an existing record, and the + complete `/status` report now follows the active locale without rewriting + exact custom-provider identities that contain brace-like text. +- Localized READMEs again match the English install and third-party-notice + surface, including shell-completion guidance in all 18 translations. + +### Security + +- Unified OAuth device-code polling now validates verification URLs before + opening them, redacts token-bearing types, honors server slowdown intervals, + and keeps credential save/logout mutations serialized. +- Project instructions, rules-directory traversal, secret-shaped config data, + URL fingerprints, and shell network authority now retain the explicit bounds + and fail-closed behavior described in the detailed record. + +### Contributors + +- **Sun Zhenyuan ([@bistack](https://github.com/bistack))** — tool-call stage + extraction with the existing execution and policy contracts preserved + (#5523). +- **Isabel Wu ([@wuisabel-gif](https://github.com/wuisabel-gif))** — bounded + multi-file `read_lints` support (#5524), plus independently reviewed + completion-routing overlap in #5530. +- **Lstarsky0 ([@Lstarsky0](https://github.com/Lstarsky0))** — maintainer review + hardening for truthful per-file states and truncation metadata in #5524. +- **Paulo Aboim Pinto ([@aboimpinto](https://github.com/aboimpinto))** — portable + presentation/media/digest facets and the seven utility-handler migrations + (#5525). +- **RepentStar ([@RepentStar](https://github.com/RepentStar))** — reported and + reproduced the stale completion-generator path and missing `codew` + registration fixed for #5526. + +### Detailed change record + +The notes below are preserved in full so the categorized highlights do not +erase behavior, migration, security, compatibility, or verification details. + +- Provider completion is now evidence-based. A Chat Completions stream reaches + `MessageStop` only after `[DONE]` or a non-empty `finish_reason`; raw EOF + without either is a typed failure. Message-start, ping, usage/terminal + deltas, block-stop, and message-stop frames do not count as productive + content or mint time-to-first-token. After tool results, a terminal provider + step with no answer or tool call now emits a durable failed turn and never + fabricates an empty assistant message. + +- A selected v2 Fleet is the single effective Agent roster across terminal, + Runtime threads, direct Workflow, Fleet execution, doctor, and + setup/readiness; legacy profile layers are consulted only when no Fleet is + selected, and invalid selections fail visibly with bounded, redacted errors. + Member references resolve exact id first and otherwise require a unique + display name, role, pinned model, offline model name, or provider/model route; + `agent action=roster` exposes that same bounded roster. The Fleet operator + supplies fresh-root and inherited-member routing unless an explicit launch + route or member pin wins, the resolved member is shown separately from the + requested alias, and `requires = ["vision"]` is admitted only on an exact + route with verified offline `image_input` support—never by silent rerouting + or custom-proxy inference. Fleet selection remains an explicit user/folder + contract independent of legacy project-profile loading. + +- **Breaking (app-server):** `/prompt`, `prompt/request` and `prompt/run` now + execute a real model turn instead of reporting success for work they never + did. `Runtime::handle_prompt` called no model: it resolved config, ran a + local `ModelRegistry` lookup, emitted three canned hook events + (`ResponseDelta` was literally the string `model-selected`), and returned + HTTP 200 with `output` set to a stringified JSON echo of the caller's own + routing metadata — the prompt included. Worse, when a `thread_id` was + supplied it appended a real user row, flipped the thread to `Running`, and + then wrote that echo into durable history as an **assistant message** plus a + `prompt_response` checkpoint. Nothing marked the row synthetic and nothing + ever moved the thread out of `Running`. All three endpoints now route + through the same `RuntimeBridge` that stdio `thread/message` has always + used, so `output` is the model's streamed text, `model` is what the runtime + reports for the thread that ran the turn, and `events` are the real + streaming frames. `Runtime::handle_prompt` and its synthetic history write + are gone. + +- **Breaking (app-server):** a failed prompt is now a typed failure rather + than a success-shaped body. `POST /prompt` returns + `{"error":{"code":...,"message":...}}` with `400` (invalid request), `404` + (thread not found), `503` (`runtime_unavailable`) or `500`, instead of HTTP + 500 carrying a `PromptResponse` with the error text stuffed into `output` + where model text belongs. The stdio surface gained JSON-RPC `-32005` + `runtime_unavailable` for "the turn engine could not be reached, so nothing + ran" — distinct from `-32603`, and retryable. There is no configuration in + which a prompt silently echoes instead of running. + +- **Breaking (app-server):** `POST /thread` with a `Message` body runs the + turn. It previously replied `status: "accepted"` with a + `ResponseDelta("queued")` frame while starting no worker and calling no + bridge — the stdio path for the same request has always done real work, so + the two transports disagreed about what `accepted` meant. HTTP now replies + `status: "completed"` once the turn reaches a terminal state, with the + streamed frames in `events` and the turn id in `data`. `Runtime::handle_thread` + no longer accepts `ThreadRequest::Message` at all: it owns thread + bookkeeping, not the turn engine, and returns an error naming + `POST /v1/threads/{id}/turns` rather than a canned acceptance. + +- **Breaking (app-server):** `AppRequest::SubmitUserInput` now refuses + explicitly (`ok: false`, `error: "user_input_reply_unsupported"`) instead of + returning `resolved: true` and filing the answers in a map that had no + reader anywhere in the crate — every answer submitted was silently + discarded. It cannot be made to work on this transport: while a turn + streams, the stdio loop executes only `thread/interrupt` and queues + everything else, so an answer sent there would wait on the very turn + waiting for it. The refusal names the surface that does accept it, + `POST /v1/user-input/{thread_id}/{request_id}` on the runtime API. The + client-visible refusal is whitespace-clean, and the `/tool` path that mints + the `UserInputRequest` is unchanged and still genuine. +- Split the coordination ledger out of `tools/subagent/coord.rs` into + `tools/subagent/coord/ledger.rs`. The file held two unrelated things: the + model-facing `agents/*` tool wrappers, and the durable decision/claim/ + contention records those wrappers happen to write — records whose consumers + are mostly *not* in the tool layer (`tui::coordination_detail`, + `tui::work_surface`, `tui::ui::tests`, `core::engine::tests` all name these + types). At 3.8k lines, reading either one started by scrolling past the + other. A pure move with a glob re-export from `coord`, so every + `crate::tools::subagent::coord::{…}` path still resolves and no consumer file + was edited; the only content change the move required is one constant going + from private to `pub(super)` because its caller stayed behind. `coord.rs` is + now 2.3k lines and `ledger.rs` 1.6k. + +- `agent` is now the only sub-agent tool the model can see. `AGENTS.md` has + said "the model-facing sub-agent surface is `agent` only" since the lifecycle + tools were removed, but six more were reachable: `agents/list`, + `agents/message`, `agents/followup`, `agents/interrupt`, `agents/coordinate`, + and `agents/wait` all defaulted to model-visible, so they shipped in the + catalog and `tool_search` could load any of them — and the `agent` + description told the model they existed. They now declare + `model_visible() -> false`, the same shape `rlm` and `exec_shell` use: still + registered, still executable by name so a persisted transcript replays + against the same implementation, never advertised and never returned by + either `tool_search` matcher. + + Five of the six were already duplicates of an `agent` action. The sixth was + not: `agents/coordinate action=claim` was the *only* way to widen a write + claim, and write enforcement fails closed, so hiding it would have left a + refusal ("expand it first with…") pointing at a tool the model could no + longer call. `agent` gains one action, `claim`, taking the write scope + vocabulary `action=start` already uses (`write_roots`, plus parse-accepted + `exact_files` and `coordination_contracts`). It keeps `agents/coordinate`'s + `Auto` approval — gating it deadlocks autonomous fan-in — and it can only + widen the caller's own scope; peer contention still fails. A scopeless claim + is refused rather than reported as granted, because `expand_write_claim` + returns the unchanged claim with `Ok` when every list is empty. + + Collapsing six tools into one action set also collapses the gating: `agent` + is deliberately exempt from both name-keyed gates (`posture_permits_tool` + short-circuits it so delegation depth governs spawning, and + `execution_envelope` classifies it `Bounded` so a read-only member can fan + out read-only work), so a capability folded into it inherits no gate. `claim` + is therefore gated per action, reproducing the envelope check that kept + `agents/coordinate` off a read-only role's catalog — in the catalog and again + at dispatch, since catalog shaping is not an authority boundary. The other + actions keep exactly the visibility they had. +- One placement table now decides which wire channel a message role belongs + in, and unrepresentable role/dialect pairs are refused at the outbound seam + (`DeepSeekClient::prepare_outbound_request`) instead of at the provider. + Chat Completions and OpenAI Responses used to drop an unfamiliar role + silently, Anthropic Messages forwarded `message.role` verbatim and took an + opaque provider 400 for it, and Google cloud-code was alone in failing + closed. Positioned `system` and `developer` history — including compaction + and branch summaries — is carried natively by Chat Completions and Responses + and projected, in place, onto Anthropic's user channel. It is neither + hoisted nor dropped. Genuinely unknown roles keep the previous + dialect-specific fail-closed/omit behavior, now decided in one table. The + dead `"tool"` arm in the Responses adapter is gone — nothing constructs that + role. + +- Message roles are a closed `Role` enum (`crates/core/src/role.rs`) instead of + a free-form `String` on `Message`. Four wire adapters each decided + independently what an unfamiliar role meant, and a typo in a role string was + a silent transcript edit rather than a compile error. `Role` keeps an + `Unrecognized(String)` variant and serializes via `as_str()`, so a saved + session's bytes are unchanged, a transcript written by a newer build still + loads here, and `assistant_interrupted` stays a distinct session item — no + session schema bump and no migration ladder. + +- Portable config bundles: `codewhale config export --portable` writes a + deterministic, secret-free bundle (credential and machine-specific keys + dropped), and `codewhale config import ` applies one with a + strict versioned envelope, a printed added/changed/skipped/conflicting/ + rejected plan, consent gating (`--yes` required headless), a timestamped + backup with rollback, and idempotent re-import. Credential-shaped entries + are rejected by key name and value shape — rejections name the field, + never the value. Remote imports revalidate the HTTPS-or-loopback-HTTP policy + on every same-scheme redirect hop, and duplicate keys across applicable + section labels fail before any backup or write instead of silently resolving + by section order. Structured TOML values round-trip with their original + types and exact named-provider identity; recursive sanitization covers + arrays/tables and camel-case, dotted, cookie, and access-key spellings while + retaining ordinary token-count metrics. Machine-bound project trust, + credential-source consent, automatic hook/LSP execution, and local-path + authority are non-portable and fail before mutation. A missing target is + created transactionally and removed again on rollback, and project/global + scope validation rejects the wrong document before import or export. + Imported tables deep-merge portable fields into the target, so omitted + machine-local provider credentials, endpoints, and executable definitions + remain intact instead of being erased by a sanitized bundle. + +- `/rc` is now a shared-session mirror instead of a terminal takeover. + Attaching the web app no longer locks the local composer or hides + approvals: both surfaces can prompt while idle (one turn runs at a + time), approval cards stay visible in the terminal and are shared with + the web with first-decision-wins semantics (the losing side is told, a + web decision dismisses the local card), and structured questions are + answered locally instead of cancelled. Fail-closed behavior survives — + the post-failure reconnect lockout, integrity-gated `/rc stop`, and the + fail-closed shared-approval channel on transport loss are unchanged. + The takeover vocabulary ("web owns prompts and approvals") is gone from + every surface. + +- Auto-mode provider readiness no longer reports "key saved · not checked" + forever. Readiness checks are recorded against the concrete model the + router ran, but auto-mode reads resolved against the literal `auto` + identity, which never matched any recorded check — so the setup receipt, + model picker, and fleet setup view showed an eternal unchecked badge + even after hundreds of successful turns. The read now falls back to the + most recent check on the same route (provider + endpoint + auth class); + concrete-model reads keep exact per-model scoping. + +- The focused sub-agent transcript now scrolls like the main transcript. + The frame renderer sampled the ocean column through a `ChatWidget` whose + constructor consumed `pending_scroll_delta` — every PageUp/PageDown and + wheel event was swallowed by an invisible widget before the focused pane + could read it. The delta is now parked across the sample; the pane pins + on user scroll-up, follows new child activity at tail, and + jump-to-bottom releases the pin. + +- Every Codex OAuth Responses request carried `max_output_tokens`, a parameter + that endpoint rejects outright ("Unsupported parameter: max_output_tokens"), + so every gpt-5.6-sol turn — including every sub-agent on that route — failed + at the first request. Codex Responses bodies now ship without a client-side + output cap; the backend applies its own. Every other Responses route keeps + the central cap on the wire, exactly as before. + +- The model-facing `lsp` tool now supports a bounded `read_lints` operation + for multi-file, workspace-relative LSP diagnostics without adding another + tool catalog entry (#4070). + +- HTTP 400 classification no longer calls an unsupported-parameter error a + context-window overflow. Responses shape errors such as "Unsupported + parameter: max_output_tokens" name a token-shaped field, which the generic + keyword rules read as prompt-size exhaustion and pointed users at compaction + that could never help. Such responses now classify as invalid requests. + +- xAI device login validated nothing about the URL it opened. The + `verification_uri` from the device-code response went straight to + `webbrowser::open` with no parse, no scheme check and no credential check, so a + spoofed or compromised issuer could hand the platform's "open this" call a + `file:` path, a custom application scheme (`vscode://`, `slack://`), or a + credential-bearing URL. The shared primitive now refuses anything that is not a + web page before the URI is printed or opened. **Behaviour change:** a + non-loopback plain-`http:` verification URI now aborts login where it + previously opened; `http:` on a loopback host is still allowed, because local + runtimes legitimately use it. +- The xAI OAuth types no longer print bearer material through `Debug`. Five types + holding tokens (`GrokAuthEntry`, `TokenResponse`, `DeviceCodeResponse`, + `DeviceCodeGrant` and the poll outcome) either redact or no longer derive + `Debug` at all, so a token has no printable path through a `{:?}` on any + surrounding struct. The shared `DevicePollOutcome` derives nothing, which the + compiler enforces. +- **Behaviour change:** an `interval` of `0` from the authorization server now + falls back to RFC 8628's five-second default rather than a one-second floor, + in both the xAI and account device flows. + +- OAuth device-code login is now one implementation. xAI/Grok device login and + Codewhale account login each carried their own hand-rolled RFC 8628 polling + loop with nothing shared between them; both now call a single primitive + (`codewhale-config`'s `device_code`), ported from pi. Three fixes come with + it. `slow_down` now honours a server-supplied `interval` instead of always + adding five seconds, which is what stops polling from running early forever + under WSL and VM clock drift. Timing out after a `slow_down` now says so and + names clock drift, rather than reading as a plain timeout. And the xAI + verification URI is validated before it is handed to the browser opener — + Codewhale previously opened whatever the device-code response said, so a + spoofed or compromised issuer could point the platform "open this" call at a + `file:` path or a custom application scheme. It must now be `https:`, or + `http:` on a loopback host for self-hosted issuers. Stored credential files + are unchanged and existing logins keep working. MCP OAuth is untouched: it + delegates to `rmcp`/`oauth2` and was never hand-rolled. +- Shell output truncation now stays inside its own budget. A truncated shell + result keeps a 6 KB head, a 24 KB tail, and any high-signal lines rescued + from the omitted middle — but that rescued block was bounded only by a line + count. One rustc `error:` line carrying a long inferred type or a minified + bundler frame is routinely hundreds of kilobytes, so a "30 KB" result could + arrive at 430 KB with the omitted line pasted back in whole. Each rescued + line is now clipped and the block has a 4 KiB ceiling; the signal survives, + the payload does not. + +- Fetched web pages in non-Latin scripts no longer arrive half-read. Page text + was reflowed against a column budget measured in bytes, so Cyrillic and Greek + wrapped at roughly half the intended width and CJK at two thirds — and since + the page view is delivered by line count, the surplus lines pushed real + content off the end of the window. A Russian or Japanese URL returned a + fraction of the text an English one did, for the same call. Wrapping now + measures display width. + +- The `bash` tool no longer tells the model it has no default timeout when it + does. An omitted timeout has always been bounded at 120 seconds and the + command killed there, but the tool description and its `timeout` field both + claimed otherwise — steering the model away from the one parameter that + would have saved a longer build. Both now name the real bound. +- MCP servers no longer restart because an unrelated setting was saved. The + lazy config reload re-reads every watched source whenever one of their + mtimes moves and keeps the live connections only when the content hash + matches — but the hash was taken over `serde_json` bytes produced straight + from the config's `HashMap`s, and two `HashMap`s with identical contents do + not iterate in the same order. Any touch of any watched file therefore hashed + differently, tore down every connection, and SIGTERMed and respawned every + stdio child. Keys are now sorted before hashing. + +- An MCP server marked `required` now still tells you *why* it failed to start. + `connect_all` appended a generic "required MCP server failed to initialize" + entry after the real per-server error, and the snapshot folds those pairs into + a map keyed by server name — so the contentless entry replaced the diagnosis + and /mcp showed the marker instead of "No such file or directory". The marker + is now only synthesized when nothing else reported a cause. + +- A crashed stdio MCP server is now rebuilt instead of being handed back dead. + A failed transport *read* disconnected the connection; a failed *write* did + not, so after the child exited the connection stayed `Ready`, the pool reused + it on every later tool call, and /mcp kept listing the server as connected. + +- An MCP response carrying neither `result` nor `error` is now an error rather + than an empty success. It previously reached the model as a successful tool + call with a `null` payload, indistinguishable from a tool that did nothing. + An explicit `"result": null` is still a valid empty success. +- Stdio MCP server requests are answered while the client is idle instead of + blocking behind the zero-capacity response rendezvous until an unrelated + client call. `ping` receives its prompt empty result, unsupported methods + receive JSON-RPC `-32601`, and the reader keeps only a weak stdin handle so + dropping the client still delivers graceful EOF. +- `base_url_fingerprint` is a persisted-key change for two input shapes. + The digest is serde-serialized into `ProviderCatalogCache` and + `LiveOffering`, pricing defect receipts, and + `TurnRecord.routed_usage_source_ids` — it is not an in-memory-only cache + label. Empty or whitespace-only values (and scheme-less query-only strings + that strip to an empty authority) now hash the invalid-or-secret-bearing + sentinel instead of SHA-256 of the empty string. Scheme-less URLs that + contain `@` now strip `userinfo` before hashing, matching the + scheme-bearing branch, so a typed `user:pass@host/v1` no longer embeds the + password in a stored digest. `routed_usage_source_fingerprint` feeds + arbitrary scheme-less source ids into the same function, so a turn + rehydrated from an older build can fail to dedupe one routed-usage row. + Recovery is a cache miss and a re-fetch, not corruption. Empty input was + not restored to the old digest: an empty authority is not a usable + endpoint, and mapping it to the same sentinel the scheme branch already + uses for an empty host keeps invalid inputs from minting a unique cache + scope. + +- Diagnostic lines that mention token *counts* are no longer swallowed by + secret redaction. A stream error such as `max tokens = 8192 but budget = + 4096` was matching the `token` hint as a substring of the English word + `tokens`, and the spaced-assignment pass then dropped the rest of the + line, leaving `max tokens = [redacted]`. Token counts are not credentials; + the hint now matches a credential identifier (`token`, `api_token`) rather + than an English word, so the numbers survive while `token = Bearer …` is + still redacted. +- Dashboard thread search no longer loads every thread's transcript to decide + whether the row matches. `GET /v1/threads/summary?search=` walked the full + thread list and called `get_thread_detail` on each row before matching, and + that detail read is itself a whole-store walk of every turn JSON and every + item JSON. A non-matching keystroke was therefore + O(threads × (all_turns + all_items)) file reads — on the order of 10^8 JSON + parses at a few thousand threads. Search now matches `id`, title, and model + from the thread record (and, when the title is unset, the single latest-turn + file that supplies the displayed title) and loads detail only for matches, so + preview stays a display field rather than a search key. Session summary + already refused to search last-message text for the same reason. + +- Silent `#[allow(dead_code)]` suppressions on the modules AGENTS.md warns + auditors not to delete — prompt zones, context budget, the route seam — + and on the next-largest holders (palette tokens, hotbar actions, core + events) are now `#[expect(dead_code)]`, or gone where the lint was already + stale. A suppression that stops matching the lint fails the `-Dwarnings` + gate instead of sitting quiet. The same gate is recorded in + `[workspace.lints]` so member crates inherit it from the manifest rather + than only from CI `RUSTFLAGS`. +- "missing key" now says where it looked. The provider picker reported + credential readiness as the bare strings `missing key` / `key:not-set`, + which named no source at all — so a home whose secret store held a working + DeepSeek key could show `DeepSeek missing key` in the picker while a real + turn from that same home completed, and nothing on screen said which layer + disagreed. Every row now resolves through one sourced resolver and states + the place its credential came from ("OPENROUTER_API_KEY", `secret store + "deepseek"`, `[providers.x] api_key`, "xAI OAuth", a consented external CLI + file); a row without a credential lists the places that were probed, in + precedence order, and the command that fixes the first of them. Where a + durable slot is deliberately *not* read — an inactive provider whose config + table carries no api-key marker — the row says so rather than implying an + empty slot. + +- Provider credential precedence is now stated once, in a doc comment beside + the single resolver that enforces it, instead of being implied by a + 150-line cascade of provider special cases. No precedence decision changed: + `has_api_key_for` is now a wrapper over that resolver, and a test asserts + the two agree for every provider. + +- Credential saves and logouts no longer interleave. Both took a snapshot of + the durable slot, wrote it, mutated the config document, and rolled back on + failure, with no lock held across the sequence — so a save racing a logout + on the same slot could leave the secret store and the config file + disagreeing. Both now hold that provider's credential write lock for the + whole read-modify-write. + + Design ported from pi-mono (MIT, Copyright (c) 2025 Mario Zechner); see + `docs/THIRD_PARTY_NOTICES.md`. + +- Enumerating stored credentials no longer fails closed on one bad slot. + Listing used to propagate a backend read error, so a single unreadable + secret-store entry made `/provider` and logout treat every other stored + credential as missing. Enumeration now skips the unreadable slot and + continues, matching the probe loop it replaced. + +- The first screen of first run no longer cuts its own headline. The welcome + and ready titles, and the provider-step heading, were emitted as single + unwrapped lines while the sentence beneath them wrapped, so at 40 columns + German read "Codewhale arbeitet mit dir in diesem O", Russian lost its final + stop, and Japanese lost "します。". Headings are prose and now wrap like it, + in every shipped locale. + +- The workspace-trust screen no longer cuts its own question in half on a small + terminal. The question, the prompt-injection risk hint, and the trust-effect + hint were each pushed as one unwrapped line, so at 40 columns the screen read + "Should Codewhale work with the instruc" — severed mid-word with nothing + marking the cut, while the workspace path directly beneath it wrapped + correctly. Asking someone to grant filesystem trust while the question itself + is truncated is the worst place in the product for that to happen. All three + now wrap through the same helper the rest of onboarding uses, which also + means they wrap correctly in Japanese and Chinese. Verified across all + fifteen shipped locales at 40, 60, 80 and 120 columns. +- `codewhale completions ` generated a script for the wrong program. + The subcommand forwarded to the in-tree `codewhale-tui` binary, which + rendered completions from *its own* clap tree under *its own* name, so the + output ended in `complete -F _codewhale__tui ... codewhale-tui` (bash), + `#compdef codewhale-tui` (zsh), and + `Register-ArgumentCompleter -Native -CommandName 'codewhale-tui'` + (PowerShell). Sourcing it registered nothing for `codewhale` or `codew` — + the two commands current installers expose — so tab completion appeared to + do nothing. The forwarded tree was also stale against the real CLI: it offered + `pr`, `scorecard`, and `session-diagnostics`, which `codewhale` does not + have, and omitted `run`, `rc`, `config`, `model`, `thread`, `lane`, + `workflow`, `web`, `account`, `app-server`, `mcp-server`, `metrics`, + `update`, `cloud`, `completion`, and `lane-log-proxy`, which it does. + Completions are now rendered in-process from the CLI's own command tree, + and `completions` is an alias of the existing + `completion` subcommand rather than a second, divergent path. Regenerate any + script you installed from an earlier release. Reported by **RepentStar** + (#5526); part of the `deepseek-tui`-era identifier retirement in #5443. + +- Completion scripts now fire for the `codew` shorthand as well as + `codewhale`. Releases publish `codew` as a byte-identical copy of the + `codewhale` binary, so a script bound to only one of the two names was half + installed for anyone who types the short one. Each shell gets its own + idiomatic hook rather than a second copy of the script: bash re-binds the + generated function, zsh widens the `#compdef` tag line to + `#compdef codewhale codew`, fish adds `complete -c codew -w codewhale`, + PowerShell registers `-CommandName 'codewhale','codew'`, and Elvish aliases + the completer with + `set edit:completion:arg-completer[codew] = $edit:completion:arg-completer[codewhale]`. + +- Documented shell completions. `docs/INSTALL.md` § 8 now gives the generate + and install commands for bash, zsh, fish, PowerShell, and Elvish, with a + note to regenerate after upgrading and to delete scripts produced by + v0.9.10 or earlier. There was previously no completion documentation + anywhere in the repository, which is how #5526 was reported as three + problems instead of one. +- `/status` was 31 rows. On an 80x24 terminal the transcript viewport is 18, so + typing `/status` landed you on the *tail* of the report: the version, route, + directory, mode and sandbox rows had already scrolled past, and what stayed on + screen was five `not reported` rows and a `$0.0000`. The report is 18 rows on a + fresh session — `Window override:` is present unless the value is already + configured. That is the viewport's height, so once `/status` itself occupies a + history cell the title row still scrolls off; it does not fit that terminal + whole. Provider, model and reasoning effort are one `Route:` lockup, the way + the header rail already writes them. Mode and its permissions are one statement + of posture. `Rate limits:` is gone — it was a `push_row` of a string literal + and could never say anything but "not available from provider telemetry". The + per-turn token ledger is gone too, because `/tokens` is that ledger's whole + subject and `/status` was printing six rows of it at the same weight as the + sandbox policy; the two facts that lived nowhere else, the cumulative in/out + split and the cumulative cache totals, survive on one `Session tokens:` row. + `Footer items:` no longer prints ten internal config keys across the full + width — `/statusline` owns them, and the report now points there in the same + row that points at `/tokens`. Two blank gutters do the grouping; the + `===================` rule under the title is gone. + +- The `/status` window-override key has its own labelled row. It used to be + parenthesised onto the end of the provenance row, which pushed + `context_window in config.toml` past the right edge at 80 columns and wrapped + the sentence. `Window source:` states the provenance and `Window override:` + names the exact key — and the override row is omitted entirely when the value + is already configured, rather than advising you to set what you have set. + +- `/help` no longer truncates anything. Every label and description used to run + through a `truncate_to_width` that appended `…`, which in a two-hundred-row + list promises text no keystroke can reveal and lands mid-token: + `(aliases: /qin…` left the parenthesis hanging open. Descriptions now shed + whole fields — the alias parenthetical first, then trailing clauses at their + own joints, and only where there is no joint at all, the sentence's short form + on a whole word with no mark, keeping the head noun of a simple verb + + modifier + noun phrase rather than the adjectives that qualified it. The + focused row's description is restated under the filter at the panel's full + width, *only when the row itself could not hold it*, so a wide terminal does + not say the same sentence twice. At 60 columns that restatement is itself + shed — the `/advisor` detail stops before `session` — so the detail is longer + than the row, not a copy of the original sentence. + +- The `/help` label column is measured instead of assumed. It was a flat 28 + columns at every terminal size, so at 60 columns twenty blank cells sat + between `/advisor` and a description cut down to 21. Each group now sizes its + column to the labels it actually holds, which nearly doubles the description + column on a narrow terminal, and the label — the string you have to type — + reads one step brighter than the description that qualifies it. + +- `/help` stopped spending rows on itself. The match count moved onto the filter + row it describes, the blank spacer under it is gone, and the footer no longer + repeats `type to filter` while the filter box says `Type to filter` two lines + above — at 60 columns that duplicate was what pushed the footer onto a second + row. A group header also stopped printing `▸ ▾`: the selection cursor and the + collapsed chevron are the same glyph, and a focused collapsed group was + showing it twice for two different facts. Help now opens focused on the first + entry rather than the header above it. + +- The bottom status rail is no longer one run-on sentence. At 120 columns it + read `▌· idle · Ollama · deepseek-v4-flash · max · Anonymous usage counts are + on. … ⌥V:output · /context:context · fn+F1:keys` — live state, route + identity, a telemetry consent notice, and keyboard hints all strung together + by the same middle dot in the same ink, so nothing was grouped and the eye + had nothing to skim by. At 80 columns it simply stopped mid-notice, and at 60 + the row overflowed and was clipped by the terminal mid-word. The rail now + divides its groups with a blank gutter instead of another dot (the dot is + kept for peers *inside* a group), the model name reads one step brighter than + the qualifiers that narrow it, and `Esc to interrupt` reads in the same hint + weight as the right-hand chords rather than in the separator weight. + +- Nothing on the status rail is ever truncated now. A notice sheds whole + sentences to fit, and if one sentence is still too long it sheds at the inner + joints — a colon, a semicolon — with the trailing mark cut so the phrase that + survives does not itself advertise that more was coming. Route identity sheds + the provider, then the reasoning effort, rather than rendering + `deepseek-v4-flash-prev…`; a clipped model name is worse than no model name + because routes share prefixes. Clauses rejoin without a Latin space after a + full-width stop, so the Japanese receipt reads as Japanese. + +- A notice now stands the standing facts down instead of queueing behind them. + Route identity and the ledger chips are still there in ten seconds; the + notice is not, so it takes the row and the key hints yield last. This is what + makes the telemetry receipt readable at 80 columns, where it used to be + simultaneously always present and never legible. + +- The status rail no longer advertises `/context:context`. It was spending + eighteen columns of a 24-row screen to name a slash command that announces + itself the moment you type `/`; the rail advertises chords you cannot + discover any other way. The rail now reads the same at 80 columns as at 200. + +- The idle screen no longer has an absolute path stretched across it. The + workspace caption between the wordmark and "What do you want to accomplish?" + was composed at full length and then truncated to the lane width, which made + the centering inset `(width - caption.width()) / 2` evaluate to zero — so a + line that was written to be centered rendered flush-left and full-bleed, + cutting the centered whale/wordmark/prompt composition in half. The clipping + also destroyed the information it was supposed to carry: at 80 columns the + line read `/private/tmp/claude-501/-Volumes-.../34267917-11f4-4d15-911a-…`, + which tells the reader nothing about where they are. The caption now sheds + detail instead of being cut — MCP count first, then branch, then leading path + components — so it always fits with room to center, and the folder you are + standing in is the last thing to go. Elisions land on a path separator rather + than mid-directory. + +- Removed the placeholder engine tree in `crates/core/src/engine/`. Its + `Engine::run` accepted `Op::SendMessage`, appended to a journal, and emitted + `TurnComplete { status: "completed" }` without ever contacting a model, and + `TurnExecutor` was a struct with a field-copy constructor and a + `step < max_steps` comparison. Nothing in the workspace referenced any of it — + the only mention of `codewhale_core::engine` anywhere was a doc comment inside + the tree itself — but its comments ("the real turn loop is wired here in the + next slice") were what `docs/ARCHITECTURE.md` leaned on to claim that + `crates/core` owns the agent loop. There is now exactly one turn loop in the + workspace, `Engine::run_turn`, and a guard test fails if a second one appears. + `docs/ARCHITECTURE.md` and `AGENTS.md` now say where it actually lives. + +- First-run onboarding no longer silently truncates its explanation in + languages that do not put spaces between words. `wrap_words` split on + whitespace, so a Japanese sentence arrived as a single token, the + line-break check (which only fires once a line is non-empty) never + triggered, and the over-wide line was clipped by the terminal. At 80 + columns the provider screen read + "Hosted providers need a key, but loca" and stopped — losing exactly the + half that tells the reader local runtimes need no key, on the screen where + they choose a provider. Space-less scripts now break by display width on + grapheme clusters, and a line may not begin with closing punctuation + (`。`, `、`, `」`, `)` and friends). Wrapping for languages that do use + spaces is unchanged. + +- Project instructions are bounded by one budget and no longer treat other + agents' files as law by default. Previously `.claude/instructions.md` and + `CLAUDE.md` sat at ranks 2 and 3 of the canonical instruction list — *above* + Codewhale's own `.codewhale/instructions.md` — `.claude/rules/` was an + auto-discovered rules directory, and `.cursorrules`, `.cursor/rules`, + `.clinerules`, `.windsurf/rules`, `.gemini`, `.github/copilot-instructions.md` + and `.github/muse-instructions.md` were all imported into the system prompt + with no opt-in. Dropping a `CLAUDE.md` written for a different tool into a + repository silently made it standing authority here, which is an injection + surface rather than a convenience. Codewhale now reads `AGENTS.md`, the + cross-agent `.agents/AGENTS.md`, and its own instruction files by default; + every other agent's format is opt-in by name through + `project_instruction_imports` (env `CODEWHALE_PROJECT_INSTRUCTION_IMPORTS`), + imported files rank *below* Codewhale's own, and a workspace that contains an + un-imported format says so in a warning naming the exact setting. +- Separately, a symlinked candidate rules directory — `.cursor/rules`, + `.windsurf/rules`, or `.gemini` pointing outside the workspace — was + traversed and its contents imported as instruction authority, because the + directory check followed the link while only the files inside it were + checked. The two instruction loaders now apply the same no-follow rule that + `.codewhale/rules/` already had. +- The three separate ceilings on standing instructions (200 KiB for the + root->workspace chain, 500 KiB for the rules block, 40 KiB for imported + fragments, and a global layer that was merged in after the chain budget had + already closed and so counted against nothing) are replaced by a single + 48 KiB aggregate budget covering all of them together. Instructions claim it + before rules, and are trimmed from the broadest scope inward so the + nearest-scope file is the last thing dropped rather than the first thing + stranded. Truncation still leaves an explicit marker. + +- Editing the workspace no longer grants the shell outbound network access. + `workspace-write` sandboxes are created network-restricted; `curl`, package + installs, and `git fetch` inside a sandboxed shell are denied by the OS + sandbox unless network is granted explicitly. This closes a real gap rather + than tightening a working boundary: the elevation added in #273 was justified + by the application-level `NetworkPolicy` remaining "the only outbound + boundary", but that policy governs `fetch_url`, `web_search`, and MCP HTTP + and never constrained shell subprocesses, so workspace-write turns had + unrestricted egress with nothing enforcing anything. Network now comes from + one of three explicit places: the new `sandbox_network_access` config key + (also `CODEWHALE_SANDBOX_NETWORK_ACCESS`), a `danger-full-access` posture, or + the existing post-denial elevation prompt that grants network for a single + call. Yolo and `--yolo`/Bypass are unchanged — they resolve to + `danger-full-access`, which applies no sandbox at all. `external-sandbox` + reports the network it was actually granted instead of hardcoding `true`, and + `/status` reads the flag instead of printing "network on" for every + workspace-write policy. Platforms with no sandbox backend (default Linux + without bubblewrap, and Windows) still enforce nothing, and both `/status` + and `doctor` continue to say so. + - The nightly Windows ARM64 artifact build works again. Every nightly from 2026-08-16 failed while compiling `codewhale-tui`, deterministically on the same codegen unit across all three build attempts, with @@ -23,6 +754,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `release-artifacts.yml` builds `--profile dist` with fat LTO and `codegen-units = 1`. +- Test debt: the transcript history-cell suite has been rebuilt. It was 123 + tests across 3,964 lines, and about a third of it pinned the current skin + rather than any behavior -- `assert_eq!(spans[1], "⣤")` for the + reduced-motion marker, `title_span.style.fg == theme.tool_title_color`, + `visible[1] == "▏ done: scan repo"`, four separate tests each asserting one + shape of fenced code never takes the transcript rail, and one test whose + only assertion was `!text.is_empty()` under a name promising it checked the + rendered tool id. Assertions like those break on every legitimate visual + change and catch nothing a reader of the transcript would notice, which is + the liability `d64b9429b` named. The replacement is 40 tests, each named + for the property it protects and asserting the property instead of the + token: reduced motion is checked by rendering the same running card at two + different elapsed times and requiring the frames to match -- which also + catches an animation leak the glyph constant missed -- and a frozen marker + must stay visible rather than landing on the spinner's invisible blank + (U+2800). Severity colors are checked by requiring warning not to read as + error rather than by naming a palette entry. A streaming assistant glyph + must actually pulse when motion is allowed, checked against + `pulse_brightness` rather than by sleeping on the 2s sine. Each of the + invariants claimed was verified to fail the new suite when deliberately + broken in the renderer. ## [0.9.10] - 2026-08-19 - Show the full slash-command or `/model` completion row in a bounded, wrapping hover popover whenever narrow terminals truncate it, closing the remaining scoped gap from [#998](https://github.com/Hmbown/CodeWhale/issues/998). Thanks [@AiurArtanis](https://github.com/AiurArtanis) and [@formp3](https://github.com/formp3) for identifying the affected surfaces. @@ -1302,9 +2054,9 @@ could end productive work without a final assistant response. - `codewhale-cli` now contains the terminal runtime directly. Release installers expose byte-identical `codewhale` and `codew` commands without a separate TUI - executable. The v0.9.5 asset set alone retains deprecated - `codewhale-tui-*` filenames as byte-identical compatibility copies so - installed v0.9.4 clients can discover and complete this upgrade. + executable. v0.9.5 introduced deprecated `codewhale-tui-*` release filenames + as byte-identical compatibility copies; later releases retain those filenames + while installed v0.9.4 clients remain supported upgrade sources. - Startup release checks cache successful lookups for one hour. The updater downloads and verifies the primary runtime once, then refreshes any existing `codew` or legacy `codewhale-tui` command paths from the same bytes. @@ -6554,7 +7306,8 @@ overflow report and `/theme` picker edge-wrapping patch in #1814. Older releases (v0.8.39 and earlier) are archived in [docs/CHANGELOG_ARCHIVE.md](docs/CHANGELOG_ARCHIVE.md). -[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.9...HEAD +[Unreleased]: https://github.com/Hmbown/CodeWhale/compare/v0.9.11...HEAD +[0.9.11]: https://github.com/Hmbown/CodeWhale/compare/v0.9.10...v0.9.11 [0.9.10]: https://github.com/Hmbown/CodeWhale/compare/v0.9.9...v0.9.10 [0.9.9]: https://github.com/Hmbown/CodeWhale/compare/v0.9.8...v0.9.9 [0.9.8]: https://github.com/Hmbown/CodeWhale/compare/v0.9.7...v0.9.8 diff --git a/Cargo.lock b/Cargo.lock index d0bb05323d..9d26faec7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -757,7 +757,7 @@ dependencies = [ [[package]] name = "codewhale-agent" -version = "0.9.10" +version = "0.9.11" dependencies = [ "codewhale-config", "serde", @@ -765,7 +765,7 @@ dependencies = [ [[package]] name = "codewhale-app-server" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "axum", @@ -793,11 +793,11 @@ dependencies = [ [[package]] name = "codewhale-build-support" -version = "0.9.10" +version = "0.9.11" [[package]] name = "codewhale-cli" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "chrono", @@ -829,6 +829,7 @@ dependencies = [ "sha2 0.11.0", "tempfile", "tokio", + "toml 1.1.4+spec-1.1.0", "tracing", "webbrowser", "windows 0.62.2", @@ -837,14 +838,14 @@ dependencies = [ [[package]] name = "codewhale-command-contract" -version = "0.9.10" +version = "0.9.11" dependencies = [ "codewhale-core", ] [[package]] name = "codewhale-config" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "codewhale-execpolicy", @@ -864,7 +865,7 @@ dependencies = [ [[package]] name = "codewhale-core" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "async-trait", @@ -890,7 +891,7 @@ dependencies = [ [[package]] name = "codewhale-execpolicy" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "codewhale-protocol", @@ -899,7 +900,7 @@ dependencies = [ [[package]] name = "codewhale-hooks" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "async-trait", @@ -914,7 +915,7 @@ dependencies = [ [[package]] name = "codewhale-lane" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "chrono", @@ -929,7 +930,7 @@ dependencies = [ [[package]] name = "codewhale-mcp" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "serde", @@ -939,14 +940,14 @@ dependencies = [ [[package]] name = "codewhale-paths" -version = "0.9.10" +version = "0.9.11" dependencies = [ "dirs", ] [[package]] name = "codewhale-protocol" -version = "0.9.10" +version = "0.9.11" dependencies = [ "chrono", "serde", @@ -956,7 +957,7 @@ dependencies = [ [[package]] name = "codewhale-release" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "reqwest 0.13.4", @@ -970,7 +971,7 @@ dependencies = [ [[package]] name = "codewhale-secrets" -version = "0.9.10" +version = "0.9.11" dependencies = [ "chrono", "codewhale-paths", @@ -985,7 +986,7 @@ dependencies = [ [[package]] name = "codewhale-state" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "chrono", @@ -1000,7 +1001,7 @@ dependencies = [ [[package]] name = "codewhale-telemetry" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "chrono", @@ -1020,7 +1021,7 @@ dependencies = [ [[package]] name = "codewhale-tools" -version = "0.9.10" +version = "0.9.11" dependencies = [ "anyhow", "async-trait", @@ -1034,7 +1035,7 @@ dependencies = [ [[package]] name = "codewhale-tui" -version = "0.9.10" +version = "0.9.11" dependencies = [ "ahash", "anyhow", @@ -1130,7 +1131,7 @@ dependencies = [ [[package]] name = "codewhale-workflow" -version = "0.9.10" +version = "0.9.11" dependencies = [ "serde", "serde_json", @@ -1142,7 +1143,7 @@ dependencies = [ [[package]] name = "codewhale-workflow-js" -version = "0.9.10" +version = "0.9.11" dependencies = [ "async-trait", "jsonschema", diff --git a/Cargo.toml b/Cargo.toml index de9b7c30f3..05aafc0d9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,7 +26,7 @@ default-members = ["crates/cli"] resolver = "2" [workspace.package] -version = "0.9.10" +version = "0.9.11" edition = "2024" # Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the # codebase relies on extensively. Cargo enforces this so users on older @@ -36,6 +36,13 @@ rust-version = "1.88" license = "MIT" repository = "https://github.com/Hmbown/CodeWhale" +# Record the policy CI already applies via `RUSTFLAGS: -Dwarnings`. +# Member crates opt in with `[lints] workspace = true`. This does not add +# new lints; it makes the existing gate visible in the manifest so +# `cargo check` without extra flags matches CI. +[workspace.lints.rust] +warnings = "deny" + [workspace.dependencies] anyhow = "1.0.100" async-trait = "0.1.89" diff --git a/Dockerfile b/Dockerfile index e2ff7dbc0a..be8e4ed886 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,8 +4,8 @@ # Build: docker buildx build --platform linux/amd64,linux/arm64 -t codewhale:latest . # Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.codewhale codewhale # -# The image ships the canonical binaries (`codewhale`, `codew`, and -# `codewhale`) in a minimal runtime layer. +# The image ships the canonical `codewhale` and `codew` command names in a +# minimal runtime layer. # # API keys MUST be passed at runtime (never baked into the image): # docker run --rm -it -e DEEPSEEK_API_KEY codewhale @@ -19,13 +19,13 @@ FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder ARG TARGETPLATFORM ARG TARGETARCH ARG BUILDPLATFORM -ARG DEEPSEEK_BUILD_SHA +ARG CODEWHALE_BUILD_SHA ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ PKG_CONFIG_ALLOW_CROSS=1 \ PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \ - DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA} + CODEWHALE_BUILD_SHA=${CODEWHALE_BUILD_SHA} RUN if [ "${TARGETARCH}" = "arm64" ] && [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \ dpkg --add-architecture arm64; \ diff --git a/README.ar.md b/README.ar.md index ef42ed6553..5030a0d4df 100644 --- a/README.ar.md +++ b/README.ar.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale وكيل مفتوح المصدر للبرمجة عبر الطرفية، مبني بلغة Rust ويتطور علنًا بالتعاون مع الأشخاص الذين يستخدمونه. @@ -21,6 +21,8 @@ codewhale يساعدك Codewhale عند التشغيل الأول على الاتصال بموفّر أو البقاء دون اتصال. ويدعم أيضًا Cargo وDocker وNix وScoop والأرشيفات المبنية مسبقًا وAndroid/Termux ومرآة CNB. راجع [دليل التثبيت](docs/INSTALL.md). +يمكن تفعيل الإكمال بمفتاح Tab بأمر واحد لكل واجهة أوامر — `codewhale completion bash|zsh|fish|powershell|elvish`. راجع [إكمال واجهة الأوامر](docs/INSTALL.md#8-shell-completions). + ## الاستخدام تحدث إلى Codewhale كما تتحدث إلى زميل في فريقك: @@ -74,4 +76,4 @@ codewhale exec "fix the failing tests and explain what changed" ## الترخيص -[MIT](LICENSE) +[MIT](LICENSE). الأجزاء المقتبسة والمعدّلة من مشاريع أخرى مفتوحة المصدر مسجّلة في [إشعارات الجهات الخارجية](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.ca.md b/README.ca.md index 9d9621d383..1e46baab7c 100644 --- a/README.ca.md +++ b/README.ca.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale és un agent de programació de codi obert per al terminal, desenvolupat amb Rust i millorat públicament amb les persones que l’utilitzen. @@ -21,6 +21,8 @@ codewhale En la primera execució, Codewhale t’ajuda a connectar un proveïdor o a continuar sense connexió. També admet Cargo, Docker, Nix, Scoop, arxius precompilats, Android/Termux i un mirall CNB. Consulta la [guia d’instal·lació](docs/INSTALL.md). +L’autocompleció amb Tab s’activa amb una sola ordre per shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta [l’autocompleció del shell](docs/INSTALL.md#8-shell-completions). + ## Ús Parla amb Codewhale tal com parlaries amb una persona del teu equip: @@ -74,4 +76,4 @@ Gràcies a totes les persones que hi han contribuït i a les comunitats de codi ## Llicència -[MIT](LICENSE) +[MIT](LICENSE). Les parts adaptades d’altres projectes de codi obert consten als [avisos de tercers](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.de.md b/README.de.md index fab233ff61..1ced4efa1d 100644 --- a/README.de.md +++ b/README.de.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale ist ein in Rust entwickelter Open-Source-Coding-Agent für dein Terminal, der gemeinsam mit seinen Nutzerinnen und Nutzern öffentlich weiterentwickelt wird. @@ -21,6 +21,8 @@ codewhale Beim ersten Start hilft dir Codewhale, einen Anbieter zu verbinden oder offline zu bleiben. Außerdem werden Cargo, Docker, Nix, Scoop, vorgefertigte Archive, Android/Termux und ein CNB-Spiegel unterstützt. Siehe [Installationsanleitung](docs/INSTALL.md). +Die Tab-Vervollständigung lässt sich für jede Shell mit einem einzigen Befehl aktivieren — `codewhale completion bash|zsh|fish|powershell|elvish`. Siehe [Shell-Vervollständigung](docs/INSTALL.md#8-shell-completions). + ## Verwendung Sprich mit Codewhale so, wie du mit einem Teammitglied sprechen würdest: @@ -74,4 +76,4 @@ Vielen Dank an alle Mitwirkenden und die Open-Source-Communitys, die das Projekt ## Lizenz -[MIT](LICENSE) +[MIT](LICENSE). Aus anderen Open-Source-Projekten übernommene Teile sind in den [Hinweisen zu Drittanbieterkomponenten](docs/THIRD_PARTY_NOTICES.md) aufgeführt. diff --git a/README.es-419.md b/README.es-419.md index f12beaa6fe..cfbc48cb2c 100644 --- a/README.es-419.md +++ b/README.es-419.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale es un agente de programación de código abierto para tu terminal, desarrollado en Rust y mejorado públicamente junto con las personas que lo usan. @@ -21,6 +21,8 @@ codewhale La primera vez que se ejecuta, Codewhale te ayuda a conectar un proveedor o a seguir sin conexión. También admite Cargo, Docker, Nix, Scoop, archivos precompilados, Android/Termux y un espejo de CNB. Consulta la [guía de instalación](docs/INSTALL.md). +El completado con Tab se configura con un comando por shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta el [completado de shell](docs/INSTALL.md#8-shell-completions). + ## Uso Habla con Codewhale como hablarías con alguien de tu equipo: @@ -74,4 +76,4 @@ Gracias a cada colaborador y a las comunidades de código abierto que ayudaron a ## Licencia -[MIT](LICENSE) +[MIT](LICENSE). Las partes adaptadas de otros proyectos de código abierto se registran en los [avisos de terceros](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.fr.md b/README.fr.md index 674a9fb877..be03072155 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale est un agent de programmation open source pour votre terminal, développé en Rust et amélioré publiquement avec les personnes qui l’utilisent. @@ -21,6 +21,8 @@ codewhale Au premier lancement, Codewhale vous aide à connecter un fournisseur ou à rester hors ligne. Il prend également en charge Cargo, Docker, Nix, Scoop, les archives précompilées, Android/Termux et un miroir CNB. Consultez le [guide d’installation](docs/INSTALL.md). +L’autocomplétion avec Tab s’active avec une commande par shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consultez [l’autocomplétion du shell](docs/INSTALL.md#8-shell-completions). + ## Utilisation Parlez à Codewhale comme vous parleriez à un membre de votre équipe : @@ -74,4 +76,4 @@ Merci à toutes les personnes qui contribuent et aux communautés open source qu ## Licence -[MIT](LICENSE) +[MIT](LICENSE). Les parties adaptées d’autres projets open source sont répertoriées dans les [mentions relatives aux logiciels tiers](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.hi.md b/README.hi.md index 3be5bebb5d..167b062362 100644 --- a/README.hi.md +++ b/README.hi.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale आपके टर्मिनल के लिए Rust में बना एक ओपन सोर्स कोडिंग एजेंट है, जिसे इसके उपयोगकर्ताओं के साथ सार्वजनिक रूप से बेहतर बनाया जाता है। @@ -21,6 +21,8 @@ codewhale पहली बार चलाने पर Codewhale आपको किसी प्रोवाइडर से जुड़ने या ऑफ़लाइन बने रहने में मदद करता है। यह Cargo, Docker, Nix, Scoop, पहले से बने आर्काइव, Android/Termux और CNB मिरर का भी समर्थन करता है। [इंस्टॉलेशन गाइड](docs/INSTALL.md) देखें। +हर शेल में Tab completion के लिए केवल एक कमांड चाहिए — `codewhale completion bash|zsh|fish|powershell|elvish`। [शेल कंप्लीशन](docs/INSTALL.md#8-shell-completions) देखें। + ## उपयोग Codewhale से वैसे ही बात करें जैसे आप अपनी टीम के किसी सदस्य से करेंगे: @@ -74,4 +76,4 @@ Codewhale की शुरुआत `deepseek-tui` के रूप में ## लाइसेंस -[MIT](LICENSE) +[MIT](LICENSE)। अन्य ओपन सोर्स प्रोजेक्ट से लिए और अनुकूलित किए गए हिस्से [थर्ड-पार्टी नोटिस](docs/THIRD_PARTY_NOTICES.md) में दर्ज हैं। diff --git a/README.id.md b/README.id.md index f387d9fa88..4c2349a78f 100644 --- a/README.id.md +++ b/README.id.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale adalah agen pemrograman sumber terbuka untuk terminal Anda, dibuat dengan Rust dan dikembangkan secara terbuka bersama orang-orang yang menggunakannya. @@ -21,6 +21,8 @@ codewhale Saat pertama dijalankan, Codewhale membantu Anda menghubungkan penyedia atau tetap bekerja secara luring. Codewhale juga mendukung Cargo, Docker, Nix, Scoop, arsip siap pakai, Android/Termux, dan mirror CNB. Lihat [panduan instalasi](docs/INSTALL.md). +Penyelesaian Tab cukup diaktifkan dengan satu perintah per shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Lihat [penyelesaian shell](docs/INSTALL.md#8-shell-completions). + ## Penggunaan Bicaralah dengan Codewhale seperti Anda berbicara dengan rekan satu tim: @@ -74,4 +76,4 @@ Terima kasih kepada setiap kontributor dan komunitas sumber terbuka yang membant ## Lisensi -[MIT](LICENSE) +[MIT](LICENSE). Bagian yang diadaptasi dari proyek sumber terbuka lain dicatat dalam [pemberitahuan pihak ketiga](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.it.md b/README.it.md index d1ba6c35ec..d14ac5190d 100644 --- a/README.it.md +++ b/README.it.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale è un agente di programmazione open source per il terminale, sviluppato in Rust e migliorato pubblicamente insieme alle persone che lo utilizzano. @@ -21,6 +21,8 @@ codewhale Al primo avvio, Codewhale ti aiuta a collegare un provider oppure a rimanere offline. Supporta inoltre Cargo, Docker, Nix, Scoop, archivi precompilati, Android/Termux e un mirror CNB. Consulta la [guida all’installazione](docs/INSTALL.md). +Il completamento con Tab si attiva con un solo comando per ogni shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta il [completamento della shell](docs/INSTALL.md#8-shell-completions). + ## Utilizzo Parla con Codewhale come parleresti con un membro del tuo team: @@ -74,4 +76,4 @@ Grazie a ogni persona che ha contribuito e alle comunità open source che hanno ## Licenza -[MIT](LICENSE) +[MIT](LICENSE). Le parti adattate da altri progetti open source sono indicate nelle [note sui componenti di terze parti](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.ja-JP.md b/README.ja-JP.md index ba519761fb..0305e09255 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale は Rust で構築された、ターミナル向けのオープンソース・コーディングエージェントです。利用者とともに、公開の場で改善を続けています。 @@ -21,6 +21,8 @@ codewhale 初回起動時にプロバイダーへの接続を案内しますが、オフラインのまま使うこともできます。Codewhale は Cargo、Docker、Nix、Scoop、ビルド済みアーカイブ、Android/Termux、CNB ミラーにも対応しています。詳しくは[インストールガイド](docs/INSTALL.md)をご覧ください。 +各シェルの Tab 補完はコマンド一つで設定できます — `codewhale completion bash|zsh|fish|powershell|elvish`。詳しくは[シェル補完](docs/INSTALL.md#8-shell-completions)をご覧ください。 + ## 使い方 チームメイトに話しかけるのと同じように、Codewhale に依頼します: @@ -74,4 +76,4 @@ Codewhale は `deepseek-tui` として始まり、その設定とセッション ## ライセンス -[MIT](LICENSE) +[MIT](LICENSE)。他のオープンソースプロジェクトを基にした部分は[サードパーティー通知](docs/THIRD_PARTY_NOTICES.md)に記載しています。 diff --git a/README.ko-KR.md b/README.ko-KR.md index b665a7b07a..2b77339fec 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale은 Rust로 만든 터미널용 오픈 소스 코딩 에이전트로, 사용자들과 함께 공개적으로 개선해 나갑니다. @@ -21,6 +21,8 @@ codewhale 처음 실행하면 공급자 연결 과정을 안내하며, 오프라인 상태로 계속 사용할 수도 있습니다. Codewhale은 Cargo, Docker, Nix, Scoop, 사전 빌드 아카이브, Android/Termux, CNB 미러도 지원합니다. [설치 안내서](docs/INSTALL.md)를 참조하세요. +각 셸에서 Tab 자동 완성은 명령 한 줄로 설정할 수 있습니다 — `codewhale completion bash|zsh|fish|powershell|elvish`. [셸 자동 완성](docs/INSTALL.md#8-shell-completions)을 참조하세요. + ## 사용법 팀원에게 말하듯 Codewhale에 요청하세요: @@ -74,4 +76,4 @@ Codewhale은 `deepseek-tui`로 시작했으며 해당 구성 및 세션과의 ## 라이선스 -[MIT](LICENSE) +[MIT](LICENSE). 다른 오픈 소스 프로젝트를 바탕으로 수정한 부분은 [타사 고지](docs/THIRD_PARTY_NOTICES.md)에 기록되어 있습니다. diff --git a/README.md b/README.md index a055ba16b2..e234b6a574 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ The first run helps you connect a provider or stay offline. Codewhale also supports Cargo, Docker, Nix, Scoop, prebuilt archives, Android/Termux, and a CNB mirror. See [the installation guide](docs/INSTALL.md). +Tab completion is one command per shell — `codewhale completion bash|zsh|fish|powershell|elvish`. +See [shell completions](docs/INSTALL.md#8-shell-completions). + ## Use Talk to Codewhale the same way you would talk to a teammate: @@ -97,4 +100,5 @@ project grow. See [the contributor record](docs/CONTRIBUTORS.md). ## License -[MIT](LICENSE) +[MIT](LICENSE). Portions adapted from other open-source projects are recorded +in [third-party notices](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.pl.md b/README.pl.md index a7f00129f9..93b2196a24 100644 --- a/README.pl.md +++ b/README.pl.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale to agent programistyczny o otwartym kodzie źródłowym do terminala, napisany w Rust i rozwijany publicznie wspólnie z osobami, które go używają. @@ -21,6 +21,8 @@ codewhale Przy pierwszym uruchomieniu Codewhale pomaga połączyć się z dostawcą lub pozostać w trybie offline. Obsługuje też Cargo, Docker, Nix, Scoop, gotowe archiwa, Android/Termux oraz serwer lustrzany CNB. Zobacz [instrukcję instalacji](docs/INSTALL.md). +Uzupełnianie klawiszem Tab można włączyć jednym poleceniem dla każdej powłoki — `codewhale completion bash|zsh|fish|powershell|elvish`. Zobacz [uzupełnianie powłoki](docs/INSTALL.md#8-shell-completions). + ## Użycie Rozmawiaj z Codewhale tak, jak z osobą ze swojego zespołu: @@ -74,4 +76,4 @@ Dziękujemy wszystkim współtwórcom oraz społecznościom open source, które ## Licencja -[MIT](LICENSE) +[MIT](LICENSE). Części zaadaptowane z innych projektów open source są wymienione w [informacjach o komponentach zewnętrznych](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.pt-BR.md b/README.pt-BR.md index 55f35aa2ff..f24651a395 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale é um agente de programação de código aberto para o seu terminal, desenvolvido em Rust e aprimorado publicamente com as pessoas que o utilizam. @@ -21,6 +21,8 @@ codewhale Na primeira execução, o Codewhale ajuda você a conectar um provedor ou a continuar offline. Ele também oferece suporte a Cargo, Docker, Nix, Scoop, arquivos pré-compilados, Android/Termux e um espelho CNB. Consulte o [guia de instalação](docs/INSTALL.md). +O preenchimento automático com Tab é ativado com um comando por shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulte o [preenchimento automático do shell](docs/INSTALL.md#8-shell-completions). + ## Uso Converse com o Codewhale como você conversaria com alguém da sua equipe: @@ -74,4 +76,4 @@ Agradecemos a cada contribuidor e às comunidades de código aberto que ajudaram ## Licença -[MIT](LICENSE) +[MIT](LICENSE). As partes adaptadas de outros projetos de código aberto estão registradas nos [avisos de terceiros](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.ru.md b/README.ru.md index c4c790bc1d..92821b3296 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale — это агент для программирования с открытым исходным кодом, работающий в терминале. Он написан на Rust и открыто развивается вместе со своими пользователями. @@ -21,6 +21,8 @@ codewhale При первом запуске Codewhale поможет подключить провайдера или остаться в автономном режиме. Он также поддерживает Cargo, Docker, Nix, Scoop, готовые архивы, Android/Termux и зеркало CNB. См. [руководство по установке](docs/INSTALL.md). +Для автодополнения по Tab достаточно одной команды для каждой оболочки — `codewhale completion bash|zsh|fish|powershell|elvish`. См. [автодополнение оболочки](docs/INSTALL.md#8-shell-completions). + ## Использование Обращайтесь к Codewhale так же, как к коллеге по команде: @@ -74,4 +76,4 @@ Codewhale начинался как `deepseek-tui` и по-прежнему со ## Лицензия -[MIT](LICENSE) +[MIT](LICENSE). Части, адаптированные из других проектов с открытым исходным кодом, указаны в [уведомлениях о сторонних компонентах](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.tr.md b/README.tr.md index 8bc53e7b89..76ada46877 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale, terminaliniz için Rust ile geliştirilmiş ve kullanıcılarıyla birlikte açık biçimde iyileştirilen açık kaynaklı bir kodlama ajanıdır. @@ -21,6 +21,8 @@ codewhale Codewhale ilk çalıştırmada bir sağlayıcıya bağlanmanıza veya çevrimdışı kalmanıza yardımcı olur. Cargo, Docker, Nix, Scoop, önceden derlenmiş arşivler, Android/Termux ve CNB aynasını da destekler. [Kurulum kılavuzuna](docs/INSTALL.md) bakın. +Her kabukta Tab tamamlama tek bir komutla etkinleştirilir — `codewhale completion bash|zsh|fish|powershell|elvish`. [Kabuk tamamlamalarına](docs/INSTALL.md#8-shell-completions) bakın. + ## Kullanım Codewhale ile ekip arkadaşınızla konuşur gibi konuşun: @@ -74,4 +76,4 @@ Projeyi büyütmeye yardımcı olan tüm katkıcılara ve açık kaynak topluluk ## Lisans -[MIT](LICENSE) +[MIT](LICENSE). Diğer açık kaynak projelerinden uyarlanan bölümler [üçüncü taraf bildirimlerinde](docs/THIRD_PARTY_NOTICES.md) kayıtlıdır. diff --git a/README.uk.md b/README.uk.md index 9aafd43dbc..16974f43df 100644 --- a/README.uk.md +++ b/README.uk.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale — це агент програмування з відкритим кодом для вашого термінала, створений на Rust і вдосконалюваний публічно разом із людьми, які ним користуються. @@ -21,6 +21,8 @@ codewhale Під час першого запуску Codewhale допоможе під’єднати провайдера або залишитися в автономному режимі. Він також підтримує Cargo, Docker, Nix, Scoop, готові архіви, Android/Termux і дзеркало CNB. Див. [посібник зі встановлення](docs/INSTALL.md). +Для автодоповнення за Tab достатньо однієї команди для кожної оболонки — `codewhale completion bash|zsh|fish|powershell|elvish`. Див. [автодоповнення оболонки](docs/INSTALL.md#8-shell-completions). + ## Використання Спілкуйтеся з Codewhale так само, як із колегою по команді: @@ -74,4 +76,4 @@ Codewhale починався як `deepseek-tui` і досі зберігає с ## Ліцензія -[MIT](LICENSE) +[MIT](LICENSE). Частини, адаптовані з інших проєктів із відкритим кодом, зазначено в [повідомленнях про сторонні компоненти](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.vi.md b/README.vi.md index 9721e689a5..89ed558bf0 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale là tác nhân lập trình mã nguồn mở dành cho terminal, được xây dựng bằng Rust và được cải thiện công khai cùng những người sử dụng nó. @@ -21,6 +21,8 @@ codewhale Trong lần chạy đầu tiên, Codewhale sẽ giúp bạn kết nối với nhà cung cấp hoặc tiếp tục làm việc ngoại tuyến. Codewhale cũng hỗ trợ Cargo, Docker, Nix, Scoop, các gói dựng sẵn, Android/Termux và bản sao CNB. Xem [hướng dẫn cài đặt](docs/INSTALL.md). +Mỗi shell chỉ cần một lệnh để bật tính năng hoàn thành bằng phím Tab — `codewhale completion bash|zsh|fish|powershell|elvish`. Xem [tính năng hoàn thành của shell](docs/INSTALL.md#8-shell-completions). + ## Sử dụng Hãy trò chuyện với Codewhale như khi bạn trao đổi với một đồng đội: @@ -74,4 +76,4 @@ Cảm ơn mọi người đóng góp và các cộng đồng mã nguồn mở đ ## Giấy phép -[MIT](LICENSE) +[MIT](LICENSE). Các phần được điều chỉnh từ những dự án nguồn mở khác được ghi trong [thông báo của bên thứ ba](docs/THIRD_PARTY_NOTICES.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 76f860d0d1..92b3cd613c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale 是一款面向终端的开源编程智能体,使用 Rust 构建,并与用户一起在公开协作中不断改进。 @@ -21,6 +21,8 @@ codewhale 首次运行会帮助你连接提供商,也可以选择保持离线。Codewhale 还支持 Cargo、Docker、Nix、Scoop、预构建压缩包、Android/Termux 和 CNB 镜像。请参阅[安装指南](docs/INSTALL.md)。 +每种 shell 只需一条命令即可启用 Tab 补全——`codewhale completion bash|zsh|fish|powershell|elvish`。请参阅 [shell 补全](docs/INSTALL.md#8-shell-completions)。 + ## 使用 像与队友交流一样向 Codewhale 描述任务: @@ -74,4 +76,4 @@ Codewhale 起初名为 `deepseek-tui`,至今仍保留与其配置和会话的 ## 许可证 -[MIT](LICENSE) +[MIT](LICENSE)。从其他开源项目改编的部分记录在[第三方声明](docs/THIRD_PARTY_NOTICES.md)中。 diff --git a/README.zh-TW.md b/README.zh-TW.md index 147051f10d..bfb5a77441 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,4 +1,4 @@ - + # Codewhale Codewhale 是一款在終端機中使用的開源程式設計代理,以 Rust 打造,並與使用者一起透過公開協作持續改進。 @@ -21,6 +21,8 @@ codewhale 第一次執行時,系統會協助你連線至供應商,也可以選擇保持離線。Codewhale 亦支援 Cargo、Docker、Nix、Scoop、預先建置的封存檔、Android/Termux 與 CNB 映像。請參閱[安裝指南](docs/INSTALL.md)。 +每種 shell 只需一個指令即可啟用 Tab 自動完成——`codewhale completion bash|zsh|fish|powershell|elvish`。請參閱 [shell 自動完成](docs/INSTALL.md#8-shell-completions)。 + ## 使用 像和隊友交談一樣告訴 Codewhale 你的需求: @@ -74,4 +76,4 @@ Codewhale 最初名為 `deepseek-tui`,至今仍保留與其設定及工作階 ## 授權條款 -[MIT](LICENSE) +[MIT](LICENSE)。從其他開放原始碼專案改編的部分記錄於[第三方聲明](docs/THIRD_PARTY_NOTICES.md)。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..b6e3a3722d --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,39 @@ +# Third-party notices + +Source vendored or ported into this repository, beyond the crates resolved by +Cargo (whose licences are enforced by `deny.toml`). + +## pi (`pi-mono`) — MIT + +`crates/config/src/device_code.rs` is a Rust port of pi's OAuth device-code +polling loop and verification-URI check: + +- `packages/ai/src/auth/oauth/device-code.ts` (`pollOAuthDeviceCodeFlow`, + the RFC 8628 polling behaviours) +- `packages/ai/src/auth/oauth/xai.ts` (`validateVerificationUri`) + +Upstream: + +``` +MIT License + +Copyright (c) 2025 Mario Zechner + +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. +``` diff --git a/config.example.toml b/config.example.toml index c9a47de6a8..0446dcdf49 100644 --- a/config.example.toml +++ b/config.example.toml @@ -266,6 +266,34 @@ memory_path = "~/.codewhale/memory.md" allow_shell = true approval_policy = "on-request" # on-request | untrusted | never sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox + +# Whether a workspace-write sandbox also lets shell commands reach the +# network. Default false: being allowed to edit this repository is not a +# reason to be allowed to open outbound connections, so `curl`, package +# installs, and `git fetch` are denied by the OS sandbox unless you opt in. +# When a command is denied, Codewhale offers an elevation prompt that grants +# network for that call only; set this to `true` to grant it for the whole +# session instead. `danger-full-access` and `yolo` are unsandboxed and +# unaffected by this key. Note that on platforms with no OS sandbox backend +# (default Linux without bubblewrap, and Windows) nothing is enforced either +# way -- `/status` and `doctor` both say so. +# sandbox_network_access = false + +# Which other agents' instruction files to import as project instructions. +# Empty by default. Codewhale reads AGENTS.md (the cross-agent standard) and +# its own .codewhale/instructions.md without being asked; a CLAUDE.md, +# .cursorrules, or .github/copilot-instructions.md written as law for a +# different tool is not treated as law here until you say so. Codewhale's own +# files always outrank anything imported. +# Accepts: "claude", "cursor", "cline", "windsurf", "gemini", "copilot", +# "muse", or "all". Env: CODEWHALE_PROJECT_INSTRUCTION_IMPORTS (comma-separated). +# project_instruction_imports = ["claude"] +# +# Everything that reaches the model as standing project instruction authority — +# the repository-root -> workspace AGENTS.md chain, the global fallback layer, +# .codewhale/rules/*.md, and any imported foreign files — shares one 48 KiB +# aggregate budget. Instructions claim it first and are trimmed from the +# broadest scope inward, so the nearest-scope file is the last thing dropped. # 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 @@ -1479,8 +1507,9 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # max_children = 1000 # # Maximum concurrently live agents inside one run (others wait for a slot). # max_concurrent = 16 -# # Maximum nested Workflow / child-orchestration depth. -# max_depth = 2 +# # Maximum structural nesting depth accepted for Workflow IR (default 5). +# # This is separate from Runtime child delegation below. +# max_depth = 5 # # Default shared token budget for a Workflow run and its children. # default_token_budget = 120000 # # Parallel write children that may share the parent worktree without @@ -1492,15 +1521,13 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # persist_completed_across_restarts = true # ───────────────────────────────────────────────────────────────────────────────── -# Agent Fleet trust, security, and role registry (#3165, #3167) +# Agent Fleet roster, role registry, and execution requests (#3165, #3167) # ───────────────────────────────────────────────────────────────────────────────── # [fleet] -# # Default trust level for fleet workers: "sandbox" | "local" | "remote-verified" | "operator" -# default_trust_level = "sandbox" -# # Require SSH host-key verification before granting remote-verified trust -# require_identity_verification = true -# # Maximum trust level any worker may have -# max_trust_level = "operator" +# # Fleet stores member identity and route intent. Project trust, host identity, +# # secrets, approvals, sandboxing, filesystem/network reach, and tool authority +# # are Runtime policy and are deliberately not configured here. Pre-0.9.11 +# # trust keys are accepted only as ignored migration input. # # # Headless worker execution hardening (#3027) # [fleet.exec] @@ -1508,12 +1535,13 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # allowed_tools = [] # # Tools always disallowed (overrides role and task spec) # disallowed_tools = ["exec_shell"] -# # Hard ceiling on worker steps (tool calls + model turns) +# # Optional hard ceiling on worker steps (tool calls + model turns). +# # Omit or set 0 for the unbounded default; use a positive value to opt in. # max_turns = 500 -# # Recursive child-agent depth for fleet workers. Shares ONE recursion axis -# # with standalone sub-agents (a fleet worker IS a headless sub-agent). -# # 0 blocks child agents (the root worker still runs); 3 is the default and the -# # cap, affording at least three nested delegation levels. +# # Runtime child-agent depth for fleet workers. Shares ONE recursion axis with +# # standalone sub-agents (a fleet worker IS a headless sub-agent). This is an +# # execution request, not Fleet member identity. 0 blocks child agents (the +# # root worker still runs); 3 is the default and 8 is the opt-in hard ceiling. # max_spawn_depth = 3 # # Extra system prompt injected into every headless worker # append_system_prompt = "Never modify .git/config or change remotes." @@ -1536,11 +1564,6 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # description = "Runs linters and formatters" # instructions = "Run cargo fmt --check and cargo clippy; never apply fixes." # -# [fleet.profiles.ci-linter.permissions] -# allow_shell = true # the only three keys are allow_shell, trust, -# trust = false # and approval_required (FleetProfilePermissions -# approval_required = true # in crates/config/src/lib.rs) -# # [fleet.profiles.pr-reviewer] # slot = "reviewer" # loadout = "inherit" @@ -1555,7 +1578,8 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # # Multiple named Fleets may coexist alongside the default [fleet] table. # Each [fleets.] entry must include an `operator` field and may configure -# its own trust levels, roles, profiles, and exec policy independently. +# its own roles, profiles, and execution requests independently. Runtime policy +# remains separate from Fleet identity. # # Selection precedence (most specific wins): # 1. Explicit fleet name — config.resolve_fleet("name") @@ -1569,10 +1593,6 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # [fleets.alice-team] # # Required: the operator/leader identity for this fleet. # operator = "alice" -# # These fields are identical to [fleet] and use the same defaults. -# default_trust_level = "local" -# require_identity_verification = true -# max_trust_level = "operator" # # [fleets.alice-team.exec] # max_turns = 200 @@ -1591,7 +1611,6 @@ default_text_model = "deepseek-ai/deepseek-v4-pro" # # [fleets.bob-team] # operator = "bob" -# default_trust_level = "sandbox" # # [fleets.bob-team.profiles.implementer] # slot = "implementer" diff --git a/crates/agent/Cargo.toml b/crates/agent/Cargo.toml index a0124559f8..c1adfb1675 100644 --- a/crates/agent/Cargo.toml +++ b/crates/agent/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true repository.workspace = true description = "Model/provider registry and fallback strategy for Codewhale" +[lints] +workspace = true + [dependencies] -codewhale-config = { path = "../config", version = "0.9.10" } +codewhale-config = { path = "../config", version = "0.9.11" } serde.workspace = true diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 584acc58cc..872157a900 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -89,6 +89,16 @@ impl Default for ModelRegistry { supports_tools: true, supports_reasoning: true, }, + ModelInfo { + id: "deepseek-v4-flash-vision-exp".to_string(), + provider: ProviderKind::Deepseek, + aliases: vec![ + "flash-vision".to_string(), + "deepseek-v4flashvisionexp".to_string(), + ], + supports_tools: true, + supports_reasoning: true, + }, ModelInfo { id: "deepseek-ai/deepseek-v4-pro".to_string(), provider: ProviderKind::NvidiaNim, @@ -1662,6 +1672,36 @@ mod tests { assert_eq!(resolved.resolved.id, "deepseek-v4-pro"); } + #[test] + fn deepseek_vision_model_lists_and_resolves_with_aliases() { + let registry = ModelRegistry::default(); + let listed = registry.list(); + + assert!(listed.iter().any(|model| { + model.provider == ProviderKind::Deepseek + && model.id == "deepseek-v4-flash-vision-exp" + && model.aliases + == [ + "flash-vision".to_string(), + "deepseek-v4flashvisionexp".to_string(), + ] + })); + + for selector in [ + "deepseek-v4-flash-vision-exp", + "flash-vision", + "deepseek-v4flashvisionexp", + ] { + let resolved = registry.resolve(Some(selector), Some(ProviderKind::Deepseek)); + assert_eq!( + resolved.resolved.id, "deepseek-v4-flash-vision-exp", + "{selector} must resolve to the experimental vision model" + ); + assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek); + assert!(!resolved.used_fallback, "{selector} must not fall back"); + } + } + #[test] fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() { let registry = ModelRegistry::default(); diff --git a/crates/app-server/Cargo.toml b/crates/app-server/Cargo.toml index d891bf3a46..c437df7373 100644 --- a/crates/app-server/Cargo.toml +++ b/crates/app-server/Cargo.toml @@ -9,19 +9,22 @@ description = "App-server transport for Codewhale runtime integrations" # `codewhale app-server` is owned by codewhale-cli; this crate is library-only. autobins = false +[lints] +workspace = true + [dependencies] anyhow.workspace = true axum.workspace = true -codewhale-agent = { path = "../agent", version = "0.9.10" } -codewhale-config = { path = "../config", version = "0.9.10" } -codewhale-core = { path = "../core", version = "0.9.10" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.9.10" } -codewhale-hooks = { path = "../hooks", version = "0.9.10" } -codewhale-mcp = { path = "../mcp", version = "0.9.10" } -codewhale-protocol = { path = "../protocol", version = "0.9.10" } -codewhale-release = { path = "../release", version = "0.9.10" } -codewhale-state = { path = "../state", version = "0.9.10" } -codewhale-tools = { path = "../tools", version = "0.9.10" } +codewhale-agent = { path = "../agent", version = "0.9.11" } +codewhale-config = { path = "../config", version = "0.9.11" } +codewhale-core = { path = "../core", version = "0.9.11" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } +codewhale-hooks = { path = "../hooks", version = "0.9.11" } +codewhale-mcp = { path = "../mcp", version = "0.9.11" } +codewhale-protocol = { path = "../protocol", version = "0.9.11" } +codewhale-release = { path = "../release", version = "0.9.11" } +codewhale-state = { path = "../state", version = "0.9.11" } +codewhale-tools = { path = "../tools", version = "0.9.11" } serde.workspace = true serde_json.workspace = true rustls.workspace = true diff --git a/crates/app-server/src/lib.rs b/crates/app-server/src/lib.rs index 43d2b0e74e..00fa7885b7 100644 --- a/crates/app-server/src/lib.rs +++ b/crates/app-server/src/lib.rs @@ -13,13 +13,13 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use codewhale_agent::ModelRegistry; -use codewhale_config::{CliRuntimeOverrides, ConfigStore}; +use codewhale_config::ConfigStore; use codewhale_core::Runtime; use codewhale_hooks::{HookDispatcher, JsonlHookSink, StdoutHookSink, UnixSocketHookSink}; use codewhale_mcp::McpManager; use codewhale_protocol::{ - AppRequest, AppResponse, PromptRequest, PromptResponse, ThreadGoalClearParams, - ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse, UserInputAnswerEvent, + AppRequest, AppResponse, EventFrame, PromptRequest, PromptResponse, ResponseChannel, + ThreadGoalClearParams, ThreadGoalGetParams, ThreadGoalSetParams, ThreadRequest, ThreadResponse, }; use codewhale_state::StateStore; use codewhale_tools::{ToolCall, ToolRegistry}; @@ -31,16 +31,6 @@ use tokio::sync::{Mutex, RwLock}; use tower_http::cors::CorsLayer; use uuid::Uuid; -/// Answers submitted for a pending `request_user_input` clarification. -/// -/// The headless runtime emits [`codewhale_protocol::EventFrame::UserInputRequest`] -/// fire-and-return (it has no resume channel, mirroring headless approval). -/// Clients POST answers back via [`AppRequest::SubmitUserInput`]; we record -/// them here keyed by `request_id` so a driver can retrieve and feed them into -/// the next turn as structured context. True in-flight resume would require an -/// awaiter in `invoke_tool` and is left as a follow-up. -type PendingUserInputAnswers = Vec; - mod chat_completions; /// Legacy DeepSeek-era naming kept for external compatibility. @@ -101,9 +91,9 @@ impl std::fmt::Debug for AppServerOptions { } } -/// Cached stdio→runtime bridge handle. +/// Cached app-server→runtime bridge handle. /// -/// The outer [`AppState::stdio_bridge`] mutex guards only the cache slot; +/// The outer [`AppState::runtime_bridge`] mutex guards only the cache slot; /// this inner mutex serializes traffic on one bridge (single child process /// plus per-thread seq bookkeeping requires ordered access). type SharedRuntimeBridge = Arc>; @@ -120,12 +110,11 @@ struct AppState { runtime: Arc>, registry: ModelRegistry, auth_token: Option, - stdio_bridge: Arc>>, + /// Cached bridge to the real runtime API. Shared by every surface that + /// executes a turn — stdio `thread/message`, HTTP `/thread` messages, and + /// both `/prompt` transports — because there is exactly one turn engine. + runtime_bridge: Arc>>, stdio_thread_hints: Arc>>, - /// Answers submitted via `AppRequest::SubmitUserInput`, keyed by - /// `request_id`. A driver polls this to resolve clarification questions - /// raised by the model during a headless run. - pending_user_input: Arc>>, /// Turns currently streaming over stdio, keyed by stdio thread id. /// /// Deliberately kept *outside* the bridge mutex: a streaming turn holds @@ -165,6 +154,13 @@ struct JsonRpcRequest { params: Value, } +/// Server error: the app-server could not reach the runtime that executes +/// turns. Kept in the JSON-RPC implementation-defined server range +/// (-32000..-32099) alongside `thread_not_found` (-32004). +const RUNTIME_UNAVAILABLE_CODE: i64 = -32005; +/// Server error: the named thread does not exist. +const THREAD_NOT_FOUND_CODE: i64 = -32004; + #[derive(Debug)] struct JsonRpcError { code: i64, @@ -202,6 +198,21 @@ enum TurnTerminalStatus { Canceled, } +/// Structured capture of one bridged turn, for callers that must *return* +/// the turn instead of streaming it (HTTP `/prompt`, HTTP `/thread` messages). +/// +/// The stdio path streams the same events to its writer and needs none of +/// this, so it passes `None` and pays nothing. +#[derive(Debug, Default)] +struct TurnTranscript { + /// Concatenated `agent_message` deltas — the model's actual output. + text: String, + /// The model the runtime reports for the thread that ran the turn. + model: Option, + /// The same frames the stdio path writes, in order. + events: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum AppTransport { Http, @@ -496,13 +507,43 @@ async fn healthz() -> Json { })) } -async fn thread_handler( - State(state): State, - Json(req): Json, -) -> (StatusCode, Json) { +/// Render a routing failure as a typed HTTP error body. +/// +/// Deliberately *not* a success-shaped payload with the error stuffed into a +/// content field: a client must be able to tell "the model said this" from +/// "nothing ran". +fn http_error_from_jsonrpc(err: JsonRpcError) -> (StatusCode, Json) { + let (status, code) = match err.code { + -32600 | -32602 => (StatusCode::BAD_REQUEST, "invalid_request"), + THREAD_NOT_FOUND_CODE => (StatusCode::NOT_FOUND, "thread_not_found"), + RUNTIME_UNAVAILABLE_CODE => (StatusCode::SERVICE_UNAVAILABLE, "runtime_unavailable"), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "internal_error"), + }; + ( + status, + Json(json!({ + "error": { + "code": code, + "jsonrpc_code": err.code, + "message": err.message, + } + })), + ) +} + +async fn thread_handler(State(state): State, Json(req): Json) -> Response { + // A message is a turn, and turns belong to the runtime — not to the + // bookkeeping `Runtime` behind the other thread operations. This mirrors + // the interception stdio `thread/message` has always done. + if let ThreadRequest::Message { thread_id, input } = req { + return match run_http_thread_message(&state, thread_id, input).await { + Ok(res) => (StatusCode::OK, Json(res)).into_response(), + Err(err) => http_error_from_jsonrpc(err).into_response(), + }; + } let mut runtime = state.runtime.write().await; match runtime.handle_thread(req).await { - Ok(res) => (StatusCode::OK, Json(res)), + Ok(res) => (StatusCode::OK, Json(res)).into_response(), Err(err) => ( StatusCode::INTERNAL_SERVER_ERROR, Json(ThreadResponse { @@ -519,26 +560,21 @@ async fn thread_handler( events: Vec::new(), data: json!({}), }), - ), + ) + .into_response(), } } -async fn prompt_handler( - State(state): State, - Json(req): Json, -) -> (StatusCode, Json) { - let mut runtime = state.runtime.write().await; - let overrides = CliRuntimeOverrides::default(); - match runtime.handle_prompt(req, &overrides).await { - Ok(res) => (StatusCode::OK, Json(res)), - Err(err) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(PromptResponse { - output: err.to_string(), - model: "unknown".to_string(), - events: Vec::new(), - }), - ), +/// `POST /prompt` — runs a genuine model turn through the runtime bridge. +/// +/// Note what this handler does *not* do: it never takes the `Runtime` write +/// lock. The old implementation held it across the whole request while doing +/// no model work at all. +async fn prompt_handler(State(state): State, Json(req): Json) -> Response { + let mut sink = tokio::io::sink(); + match run_prompt_turn(&state, &mut sink, req).await { + Ok(res) => (StatusCode::OK, Json(res)).into_response(), + Err(err) => http_error_from_jsonrpc(err).into_response(), } } @@ -673,9 +709,8 @@ fn build_state_with_transport( runtime: Arc::new(RwLock::new(runtime)), registry, auth_token, - stdio_bridge: Arc::new(Mutex::new(None)), + runtime_bridge: Arc::new(Mutex::new(None)), stdio_thread_hints: Arc::new(Mutex::new(HashMap::new())), - pending_user_input: Arc::new(Mutex::new(std::collections::HashMap::new())), in_flight_turns: Arc::new(Mutex::new(HashMap::new())), }) } @@ -851,10 +886,25 @@ impl JsonRpcError { } } + /// Server error (-32000..-32099): the turn engine could not be reached, + /// or refused to start the turn — either way nothing ran. Distinct from + /// `internal` because the caller can retry this one once a runtime is up. + fn runtime_unavailable(message: impl Into) -> Self { + let message = message.into(); + Self { + code: RUNTIME_UNAVAILABLE_CODE, + message: message.clone(), + data: Some(json!({ + "error": "runtime_unavailable", + "detail": message, + })), + } + } + /// Server error (-32000..-32099): the named thread does not exist. fn thread_not_found(thread_id: &str) -> Self { Self { - code: -32004, + code: THREAD_NOT_FOUND_CODE, message: format!("thread not found: {thread_id}"), data: Some(json!({ "error": "thread_not_found", @@ -883,45 +933,205 @@ async fn handle_thread_request( .map_err(|err| JsonRpcError::internal(err.to_string())) } -async fn handle_prompt_request( - state: &AppState, - req: PromptRequest, -) -> std::result::Result { - let mut runtime = state.runtime.write().await; - runtime - .handle_prompt(req, &CliRuntimeOverrides::default()) - .await - .map_err(|err| JsonRpcError::internal(err.to_string())) -} - -async fn handle_stdio_thread_message( +/// One turn's worth of routing decisions, shared by every surface that runs +/// a turn through the bridge. +struct BridgedTurn<'a> { + /// Client-facing thread id; the bridge maps it to a runtime thread. + thread_key: &'a str, + input: &'a str, + /// Model for the runtime thread when this call is the one that creates + /// it. An existing thread keeps the model it was created with. + model_override: Option, + /// Publish the live turn so a concurrent `thread/interrupt` can cancel + /// it. Only stdio has a mid-turn channel, so only stdio sets this. + interruptible: bool, + /// Forget the thread mapping once the turn ends. Set for one-shot + /// prompts, whose synthetic thread key no client can name again. + ephemeral: bool, +} + +/// Execute exactly one turn on the real runtime. +/// +/// This is the only way any app-server surface runs a model: `/prompt`, +/// `prompt/request`, `prompt/run`, stdio `thread/message`, and HTTP `/thread` +/// messages all land here. There is no local fallback that fabricates a +/// response — if the runtime cannot be reached the caller gets +/// [`JsonRpcError::runtime_unavailable`] and nothing is written to history. +async fn run_bridged_turn( state: &AppState, writer: &mut W, - parsed: ThreadMessageParams, + turn: BridgedTurn<'_>, + transcript: Option<&mut TurnTranscript>, ) -> std::result::Result { - let hint = { + let mut hint = { let hints = state.stdio_thread_hints.lock().await; - hints.get(&parsed.thread_id).cloned() + hints.get(turn.thread_key).cloned() }; - let bridge = acquire_stdio_bridge(state).await?; + if let Some(model) = turn.model_override { + hint.get_or_insert_with(RuntimeThreadHint::default).model = Some(model); + } + let bridge = acquire_runtime_bridge(state).await?; // The inner bridge lock is held for the whole turn: one child process // serves all threads and per-thread seq tracking requires ordered // access. The cache slot itself stays unlocked, so config updates and // bridge invalidation are never queued behind a streaming turn. let mut bridge = bridge.lock().await; let runtime_thread_id = bridge - .ensure_runtime_thread(&parsed.thread_id, hint) + .ensure_runtime_thread(turn.thread_key, hint) .await - .map_err(|err| JsonRpcError::internal(err.to_string()))?; - let mut result = bridge + .map_err(|err| JsonRpcError::runtime_unavailable(err.to_string()))?; + let registration = turn + .interruptible + .then(|| (state.in_flight_turns.clone(), turn.thread_key.to_string())); + let result = bridge .message_thread( &runtime_thread_id, - &parsed.input, + turn.input, writer, - Some((state.in_flight_turns.clone(), parsed.thread_id.clone())), + registration, + transcript, ) - .await - .map_err(|err| JsonRpcError::internal(err.to_string()))?; + .await; + if turn.ephemeral { + // Drop the mapping while we still hold the lock, so a long-lived + // app-server does not accumulate one entry per one-shot prompt. + bridge.forget_thread(turn.thread_key); + } + result.map_err(|err| JsonRpcError::internal(err.to_string())) +} + +/// Run a prompt as a genuine model turn and return what the model actually +/// said. +/// +/// `writer` receives the same streaming frames stdio `thread/message` emits; +/// HTTP callers pass a sink and read the frames back out of +/// [`PromptResponse::events`]. +async fn run_prompt_turn( + state: &AppState, + writer: &mut W, + req: PromptRequest, +) -> std::result::Result { + if req.prompt.trim().is_empty() { + return Err(JsonRpcError::invalid_params("prompt must not be empty")); + } + // The turn engine has no threadless mode, so a prompt without a thread + // gets a fresh one. Keying it on a uuid keeps a one-shot prompt out of + // any caller's history and out of the way of concurrent prompts. + let ephemeral = req.thread_id.is_none(); + let thread_key = req + .thread_id + .clone() + .unwrap_or_else(|| format!("prompt-{}", Uuid::new_v4())); + + let mut transcript = TurnTranscript::default(); + run_bridged_turn( + state, + writer, + BridgedTurn { + thread_key: &thread_key, + input: &req.prompt, + model_override: req.model.clone(), + // `thread/interrupt` addresses client-facing thread ids. A + // one-shot prompt has none to hand back, and a caller-supplied + // thread id is already interruptible through `thread/message`. + interruptible: false, + ephemeral, + }, + Some(&mut transcript), + ) + .await?; + + // Report the model the runtime actually ran, never a locally resolved + // guess. The fallbacks only matter for a runtime that omits the field. + let model = match transcript.model { + Some(model) => model, + None => match req.model { + Some(model) => model, + None => state + .config + .read() + .await + .model + .clone() + .unwrap_or_else(|| "unknown".to_string()), + }, + }; + + Ok(PromptResponse { + output: transcript.text, + model, + events: transcript.events, + }) +} + +async fn handle_prompt_request( + state: &AppState, + writer: &mut W, + req: PromptRequest, +) -> std::result::Result { + run_prompt_turn(state, writer, req).await +} + +/// HTTP `/thread` with a `Message` body: same engine as stdio +/// `thread/message`, but the turn is collected rather than streamed because +/// this transport is request/response. +async fn run_http_thread_message( + state: &AppState, + thread_id: String, + input: String, +) -> std::result::Result { + let mut transcript = TurnTranscript::default(); + let mut sink = tokio::io::sink(); + let result = run_bridged_turn( + state, + &mut sink, + BridgedTurn { + thread_key: &thread_id, + input: &input, + model_override: None, + interruptible: false, + ephemeral: false, + }, + Some(&mut transcript), + ) + .await?; + + Ok(ThreadResponse { + thread_id, + // The turn ran to a terminal state before this response was built, + // which is exactly what the old `accepted` did not mean. + status: "completed".to_string(), + thread: None, + threads: Vec::new(), + goal: None, + model: transcript.model, + model_provider: None, + cwd: None, + approval_policy: None, + sandbox: None, + events: transcript.events, + data: result.get("data").cloned().unwrap_or_else(|| json!({})), + }) +} + +async fn handle_stdio_thread_message( + state: &AppState, + writer: &mut W, + parsed: ThreadMessageParams, +) -> std::result::Result { + let mut result = run_bridged_turn( + state, + writer, + BridgedTurn { + thread_key: &parsed.thread_id, + input: &parsed.input, + model_override: None, + interruptible: true, + ephemeral: false, + }, + None, + ) + .await?; if let Some(object) = result.as_object_mut() { object.insert("thread_id".to_string(), Value::String(parsed.thread_id)); } @@ -953,20 +1163,20 @@ async fn record_stdio_thread_hint(state: &AppState, response: &ThreadResponse) { /// Fetch the cached stdio→runtime bridge, spawning one on first use. /// /// The cache-slot lock is held only for the lookup/insert — never across -/// the child spawn or any request traffic — so [`invalidate_stdio_bridge`] +/// the child spawn or any request traffic — so [`invalidate_runtime_bridge`] /// and other slot users are never blocked behind a slow bridge operation. -async fn acquire_stdio_bridge( +async fn acquire_runtime_bridge( state: &AppState, ) -> std::result::Result { - if let Some(bridge) = state.stdio_bridge.lock().await.as_ref() { + if let Some(bridge) = state.runtime_bridge.lock().await.as_ref() { return Ok(bridge.clone()); } let bridge = Arc::new(Mutex::new( RuntimeBridge::start(state.config_path.as_deref()) .await - .map_err(|err| JsonRpcError::internal(err.to_string()))?, + .map_err(|err| JsonRpcError::runtime_unavailable(err.to_string()))?, )); - let mut slot = state.stdio_bridge.lock().await; + let mut slot = state.runtime_bridge.lock().await; // Prefer a bridge cached by a concurrent caller while we were spawning; // dropping our unused one kills the extra child via `Drop`. Ok(slot.get_or_insert_with(|| bridge.clone()).clone()) @@ -1007,8 +1217,8 @@ async fn interrupt_stdio_turn( /// fresh child that re-reads the persisted config. An in-flight message /// keeps its own [`SharedRuntimeBridge`] clone and finishes against the old /// child, which is killed when the last clone drops. -async fn invalidate_stdio_bridge(state: &AppState) { - let mut bridge = state.stdio_bridge.lock().await; +async fn invalidate_runtime_bridge(state: &AppState) { + let mut bridge = state.runtime_bridge.lock().await; *bridge = None; } @@ -1130,6 +1340,14 @@ impl RuntimeBridge { Ok(runtime_thread_id) } + /// Drop a thread mapping (and its seq cursor) once no caller can name + /// the client-facing key again. + fn forget_thread(&mut self, stdio_thread_id: &str) { + if let Some(runtime_thread_id) = self.thread_map.remove(stdio_thread_id) { + self.last_seq_by_thread.remove(&runtime_thread_id); + } + } + async fn create_runtime_thread( &mut self, model: Option, @@ -1164,6 +1382,7 @@ impl RuntimeBridge { input: &str, writer: &mut W, registration: Option<(TurnRegistry, String)>, + mut transcript: Option<&mut TurnTranscript>, ) -> Result { let turn = self .request_json( @@ -1181,6 +1400,16 @@ impl RuntimeBridge { .to_string(); let response_id = format!("{thread_id}:{turn_id}"); + if let Some(transcript) = transcript.as_deref_mut() { + transcript.model = turn + .pointer("/thread/model") + .and_then(Value::as_str) + .map(str::to_string); + transcript.events.push(EventFrame::ResponseStart { + response_id: response_id.clone(), + }); + } + emit_stdio_event( writer, json!({ @@ -1207,7 +1436,14 @@ impl RuntimeBridge { let since_seq = self.last_seq_by_thread.get(thread_id).copied().unwrap_or(0); let stream_result = self - .stream_turn_events(thread_id, &turn_id, &response_id, writer, since_seq) + .stream_turn_events( + thread_id, + &turn_id, + &response_id, + writer, + since_seq, + transcript.as_deref_mut(), + ) .await; if let Some((registry, key)) = registration.as_ref() { @@ -1222,6 +1458,11 @@ impl RuntimeBridge { }), ) .await; + if let Some(transcript) = transcript { + transcript.events.push(EventFrame::ResponseEnd { + response_id: response_id.clone(), + }); + } let (last_seq, status, error) = stream_result?; self.last_seq_by_thread @@ -1263,6 +1504,7 @@ impl RuntimeBridge { response_id: &str, writer: &mut W, since_seq: u64, + mut transcript: Option<&mut TurnTranscript>, ) -> Result<(u64, TurnTerminalStatus, Option)> { let mut response = self .authed(self.client.get(format!( @@ -1315,6 +1557,14 @@ impl RuntimeBridge { }), ) .await?; + if let Some(transcript) = transcript.as_deref_mut() { + transcript.text.push_str(delta); + transcript.events.push(EventFrame::ResponseDelta { + response_id: response_id.to_string(), + delta: delta.to_string(), + channel: ResponseChannel::Text, + }); + } } } "turn.completed" => { @@ -1753,7 +2003,7 @@ async fn dispatch_stdio_request_with_writer( }, "prompt/request" | "prompt/run" => { let request: PromptRequest = parse_params(params)?; - let response = handle_prompt_request(state, request).await?; + let response = handle_prompt_request(state, writer, request).await?; StdioDispatchResult { result: serde_json::to_value(response) .map_err(|err| JsonRpcError::internal(err.to_string()))?, @@ -1780,7 +2030,7 @@ async fn dispatch_stdio_request_with_writer( for thread_id in live { let _ = interrupt_stdio_turn(state, &thread_id).await; } - if let Some(bridge) = state.stdio_bridge.lock().await.take() { + if let Some(bridge) = state.runtime_bridge.lock().await.take() { bridge.lock().await.shutdown_child(); } StdioDispatchResult { @@ -1932,30 +2182,31 @@ async fn process_app_request( }, } } - AppRequest::SubmitUserInput { - request_id, - answers, - } => { - // Record the user's answers against the pending clarification - // request so a driver can retrieve them. The headless runtime does - // not block on `request_user_input` (fire-and-return, like - // approval), so there is no in-flight turn to resume here — the - // caller is expected to feed these answers into the next turn. - let mut pending = state.pending_user_input.lock().await; - if pending.contains_key(&request_id) { - return AppResponse { - ok: false, - data: json!({ - "error": "request_id already resolved", - "request_id": request_id, - }), - events: Vec::new(), - }; - } - pending.insert(request_id.clone(), answers); + AppRequest::SubmitUserInput { request_id, .. } => { + // This transport cannot deliver a clarification answer, and + // saying otherwise was the bug: the previous implementation + // reported `resolved: true` and filed the answers in a map with + // no reader anywhere in this crate. + // + // It cannot be made to work here. `handle_line_during_turn` + // executes exactly one method while a turn is streaming — + // `thread/interrupt`. Everything else, `app/request` included, + // queues until the turn ends, so an answer sent over this + // transport would wait on the very turn that is waiting for it. + // The runtime API owns the pending request and can resume the + // turn, so that is where the reply belongs. AppResponse { - ok: true, - data: json!({ "request_id": request_id, "resolved": true }), + ok: false, + data: json!({ + "error": "user_input_reply_unsupported", + "request_id": request_id, + "message": concat!( + "the app-server control transport cannot deliver clarification answers: ", + "only `thread/interrupt` runs while a turn is streaming, so an answer sent ", + "here would queue behind the turn waiting for it. Reply on the runtime API ", + "instead: POST /v1/user-input/{thread_id}/{request_id}." + ), + }), events: Vec::new(), } } @@ -1997,7 +2248,7 @@ async fn apply_config_update( None => runtime.update_config(snapshot), } } - invalidate_stdio_bridge(state).await; + invalidate_runtime_bridge(state).await; } async fn persist_config(state: &AppState, config: codewhale_config::ConfigToml) -> Result<()> { @@ -2362,7 +2613,7 @@ mod tests { let config_path = tmp.path().join("config.toml"); fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config"); let state = build_state(Some(config_path.clone()), None).expect("state"); - *state.stdio_bridge.lock().await = Some(sentinel_bridge()); + *state.runtime_bridge.lock().await = Some(sentinel_bridge()); let response = process_app_request( &state, @@ -2375,7 +2626,7 @@ mod tests { .await; assert!(!response.ok, "invalid value must fail: {response:?}"); - let slot = state.stdio_bridge.lock().await; + let slot = state.runtime_bridge.lock().await; let kept = slot .as_ref() .expect("bridge must survive a failed config/set"); @@ -2399,7 +2650,7 @@ mod tests { let config_path = tmp.path().join("config.toml"); fs::write(&config_path, "model = \"deepseek-chat\"\n").expect("write config"); let state = build_state(Some(config_path.clone()), None).expect("state"); - *state.stdio_bridge.lock().await = Some(sentinel_bridge()); + *state.runtime_bridge.lock().await = Some(sentinel_bridge()); let response = process_app_request( &state, @@ -2412,7 +2663,7 @@ mod tests { .await; assert!(response.ok, "valid set should succeed: {response:?}"); assert!( - state.stdio_bridge.lock().await.is_none(), + state.runtime_bridge.lock().await.is_none(), "a successful config change must invalidate the cached bridge", ); } @@ -2504,7 +2755,7 @@ mod tests { let bridge = Arc::new(Mutex::new(RuntimeBridge::from_base_url_for_test( "http://127.0.0.1:9".to_string(), ))); - *state.stdio_bridge.lock().await = Some(bridge.clone()); + *state.runtime_bridge.lock().await = Some(bridge.clone()); bridge } @@ -2529,7 +2780,7 @@ mod tests { // The cached bridge child must be dropped so the next stdio request // spawns a fresh runtime that reads the persisted config. - assert!(state.stdio_bridge.lock().await.is_none()); + assert!(state.runtime_bridge.lock().await.is_none()); } #[tokio::test] @@ -2544,7 +2795,7 @@ mod tests { process_app_request(&state, AppRequest::ConfigReload, AppTransport::Stdio).await; assert!(response.ok, "reload should succeed"); - assert!(state.stdio_bridge.lock().await.is_none()); + assert!(state.runtime_bridge.lock().await.is_none()); } #[tokio::test] @@ -2557,10 +2808,10 @@ mod tests { // Invalidation only touches the cache slot, so it must complete // without waiting for the in-flight turn to release the bridge. - tokio::time::timeout(Duration::from_secs(1), invalidate_stdio_bridge(&state)) + tokio::time::timeout(Duration::from_secs(1), invalidate_runtime_bridge(&state)) .await .expect("invalidation must not wait on bridge traffic"); - assert!(state.stdio_bridge.lock().await.is_none()); + assert!(state.runtime_bridge.lock().await.is_none()); } #[tokio::test] @@ -2838,7 +3089,7 @@ mod tests { async fn interrupt_stops_a_turn_that_would_otherwise_stream_forever() { let (base_url, _notify, server) = spawn_uninterruptible_until_asked_runtime().await; let (state, _tmp) = capability_test_state(); - *state.stdio_bridge.lock().await = Some(Arc::new(Mutex::new( + *state.runtime_bridge.lock().await = Some(Arc::new(Mutex::new( RuntimeBridge::from_base_url_for_test(base_url), ))); @@ -3000,7 +3251,7 @@ mod tests { let (mut reader, mut writer) = tokio::io::duplex(4096); let result = bridge - .message_thread("thr_test", "hello", &mut writer, None) + .message_thread("thr_test", "hello", &mut writer, None, None) .await .expect("message_thread should succeed"); drop(writer); @@ -3089,6 +3340,275 @@ mod tests { ); } + // ── prompt routing runs a real turn ──────────────────────────────── + // + // `/prompt`, `prompt/request` and `prompt/run` used to return HTTP 200 + // with a stringified echo of the caller's own routing metadata, having + // called no model at all. These stand up the in-crate stub runtime and + // assert the response is what the model streamed — not an echo — and + // that an unreachable runtime is an explicit typed failure. + + /// Prompts the stub runtime was actually asked to run. + type StubPrompts = Arc>>; + + /// A minimal but honest runtime: it creates threads, starts turns, and + /// streams `agent_message` deltas followed by `turn.completed`. + async fn spawn_stub_runtime() -> (String, StubPrompts, tokio::task::JoinHandle<()>) { + async fn create_thread(Json(body): Json) -> Json { + Json(json!({ + "id": "thr_stub", + "model": body["model"].as_str().unwrap_or("stub-model-v1"), + })) + } + + async fn create_turn( + State(prompts): State, + AxumPath(thread_id): AxumPath, + Json(body): Json, + ) -> Json { + prompts + .lock() + .await + .push(body["prompt"].as_str().unwrap_or_default().to_string()); + Json(json!({ + "thread": { "id": thread_id, "model": "stub-model-v1" }, + "turn": { "id": "turn_stub" }, + })) + } + + async fn thread_events( + AxumPath(_thread_id): AxumPath, + ) -> ([(header::HeaderName, &'static str); 1], String) { + let body = [ + sse_frame( + "item.delta", + json!({ + "seq": 1, + "turn_id": "turn_stub", + "payload": { "kind": "agent_message", "delta": "the answer" } + }), + ), + sse_frame( + "item.delta", + json!({ + "seq": 2, + "turn_id": "turn_stub", + "payload": { "kind": "agent_message", "delta": " is 4" } + }), + ), + sse_frame( + "turn.completed", + json!({ + "seq": 3, + "turn_id": "turn_stub", + "payload": { "turn": { "status": "completed" } } + }), + ), + ] + .concat(); + ([(header::CONTENT_TYPE, "text/event-stream")], body) + } + + let prompts: StubPrompts = Arc::new(Mutex::new(Vec::new())); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind stub runtime"); + let addr = listener.local_addr().expect("listener addr"); + let app = Router::new() + .route("/v1/threads", post(create_thread)) + .route("/v1/threads/{thread_id}/turns", post(create_turn)) + .route("/v1/threads/{thread_id}/events", get(thread_events)) + .with_state(prompts.clone()); + let server = tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + (format!("http://{addr}"), prompts, server) + } + + async fn seed_bridge_at(state: &AppState, base_url: String) -> SharedRuntimeBridge { + let bridge = Arc::new(Mutex::new(RuntimeBridge::from_base_url_for_test(base_url))); + *state.runtime_bridge.lock().await = Some(bridge.clone()); + bridge + } + + #[tokio::test] + async fn prompt_request_executes_a_genuine_model_turn() { + let (state, _tmp) = capability_test_state(); + let (base_url, prompts, server) = spawn_stub_runtime().await; + let bridge = seed_bridge_at(&state, base_url).await; + + let (mut reader, mut writer) = tokio::io::duplex(4096); + let dispatched = dispatch_stdio_request_with_writer( + &state, + &mut writer, + "prompt/request", + json!({ "prompt": "what is 2+2" }), + ) + .await + .expect("prompt/request dispatch"); + drop(writer); + + let response: PromptResponse = + serde_json::from_value(dispatched.result).expect("prompt response"); + + // The model's words, not a restatement of the request. + assert_eq!(response.output, "the answer is 4"); + assert!( + !response.output.contains("what is 2+2"), + "prompt echo leaked into the output: {}", + response.output + ); + assert_eq!(response.model, "stub-model-v1"); + assert_eq!( + prompts.lock().await.as_slice(), + ["what is 2+2".to_string()], + "the prompt must reach the runtime's turn endpoint" + ); + + // Real streaming frames, not three canned ones. + let deltas: Vec = response + .events + .iter() + .filter_map(|event| match event { + EventFrame::ResponseDelta { delta, .. } => Some(delta.clone()), + _ => None, + }) + .collect(); + assert_eq!(deltas, vec!["the answer".to_string(), " is 4".to_string()]); + assert!(matches!( + response.events.first(), + Some(EventFrame::ResponseStart { .. }) + )); + assert!(matches!( + response.events.last(), + Some(EventFrame::ResponseEnd { .. }) + )); + + // The stdio transport sees the same turn stream `thread/message` emits. + let mut stdout = Vec::new(); + reader.read_to_end(&mut stdout).await.expect("read stdout"); + let stdout = String::from_utf8(stdout).expect("utf8 stdout"); + assert!( + stdout.contains("\"type\":\"response_delta\"") && stdout.contains("the answer"), + "stdio prompt turn must stream its deltas, got: {stdout}" + ); + + // A prompt without a thread_id must not leave a mapping behind. + assert!( + bridge.lock().await.thread_map.is_empty(), + "one-shot prompt threads must not accumulate in the bridge" + ); + + server.abort(); + let _ = server.await; + } + + #[tokio::test] + async fn prompt_without_a_reachable_runtime_fails_explicitly() { + let (state, _tmp) = capability_test_state(); + // Port 9 (discard) refuses immediately: no runtime is listening. + seed_bridge_at(&state, "http://127.0.0.1:9".to_string()).await; + + let err = dispatch_stdio_request(&state, "prompt/run", json!({ "prompt": "hello" })) + .await + .expect_err("a prompt with no reachable runtime must fail, not echo"); + assert_eq!(err.code, RUNTIME_UNAVAILABLE_CODE); + + let (status, Json(body)) = http_error_from_jsonrpc(err); + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(body["error"]["code"], "runtime_unavailable"); + assert!( + body.get("output").is_none(), + "a failure must not be shaped like a PromptResponse: {body}" + ); + } + + #[tokio::test] + async fn empty_prompt_is_rejected_before_any_runtime_work() { + let (state, _tmp) = capability_test_state(); + let err = dispatch_stdio_request(&state, "prompt/request", json!({ "prompt": " " })) + .await + .expect_err("an empty prompt must be rejected"); + assert_eq!(err.code, -32602); + assert!( + state.runtime_bridge.lock().await.is_none(), + "a rejected prompt must not start a runtime" + ); + } + + #[tokio::test] + async fn http_thread_message_runs_the_turn_instead_of_queueing_it() { + let (state, _tmp) = capability_test_state(); + let (base_url, prompts, server) = spawn_stub_runtime().await; + seed_bridge_at(&state, base_url).await; + + let response = run_http_thread_message(&state, "thr_http".to_string(), "go".to_string()) + .await + .expect("http thread message"); + + assert_eq!(response.status, "completed"); + assert_eq!(response.thread_id, "thr_http"); + assert_eq!(response.data["turn_id"], "turn_stub"); + assert_eq!(prompts.lock().await.as_slice(), ["go".to_string()]); + assert!( + response + .events + .iter() + .any(|event| matches!(event, EventFrame::ResponseDelta { .. })), + "a completed turn must carry the deltas it streamed" + ); + + server.abort(); + let _ = server.await; + } + + #[tokio::test] + async fn http_thread_message_without_a_runtime_is_a_typed_error() { + let (state, _tmp) = capability_test_state(); + seed_bridge_at(&state, "http://127.0.0.1:9".to_string()).await; + + let err = run_http_thread_message(&state, "thr_http".to_string(), "go".to_string()) + .await + .expect_err("no runtime means no turn"); + assert_eq!(err.code, RUNTIME_UNAVAILABLE_CODE); + } + + #[tokio::test] + async fn submit_user_input_refuses_instead_of_claiming_resolution() { + let (state, _tmp) = capability_test_state(); + let response = process_app_request( + &state, + AppRequest::SubmitUserInput { + request_id: "user-input-1".to_string(), + answers: Vec::new(), + }, + AppTransport::Stdio, + ) + .await; + + assert!(!response.ok, "this transport cannot deliver the answer"); + assert_eq!(response.data["error"], "user_input_reply_unsupported"); + assert!( + response.data.get("resolved").is_none(), + "nothing was resolved: {}", + response.data + ); + assert!( + response.data["message"] + .as_str() + .expect("message") + .contains("/v1/user-input/"), + "the refusal must name the transport that can accept the answer" + ); + assert!( + !response.data["message"] + .as_str() + .expect("message") + .contains(" "), + "the refusal must not expose source-formatting whitespace" + ); + } + // ── capability drift guard ───────────────────────────────────────── // // The stdio `capabilities` method is the benchmark/SDK contract: external diff --git a/crates/build-support/Cargo.toml b/crates/build-support/Cargo.toml index c99a600678..0762025bfa 100644 --- a/crates/build-support/Cargo.toml +++ b/crates/build-support/Cargo.toml @@ -7,4 +7,7 @@ license.workspace = true repository.workspace = true description = "Shared build-script helpers for embedding Codewhale build metadata" +[lints] +workspace = true + [dependencies] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 2a19465522..fe10a7eebd 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -7,6 +7,9 @@ license.workspace = true repository.workspace = true description = "Agentic terminal facade for open-source and open-weight coding models" +[lints] +workspace = true + [[bin]] name = "codewhale" path = "src/main.rs" @@ -15,24 +18,25 @@ path = "src/main.rs" anyhow.workspace = true clap.workspace = true clap_complete.workspace = true -codewhale-tui = { path = "../tui", version = "0.9.10" } -codewhale-agent = { path = "../agent", version = "0.9.10" } -codewhale-app-server = { path = "../app-server", version = "0.9.10" } -codewhale-config = { path = "../config", version = "0.9.10" } -codewhale-lane = { path = "../lane", version = "0.9.10" } -codewhale-workflow = { path = "../workflow", version = "0.9.10" } -codewhale-execpolicy = { path = "../execpolicy", version = "0.9.10" } -codewhale-mcp = { path = "../mcp", version = "0.9.10" } -codewhale-paths = { path = "../paths", version = "0.9.10" } -codewhale-release = { path = "../release", version = "0.9.10" } -codewhale-secrets = { path = "../secrets", version = "0.9.10" } -codewhale-state = { path = "../state", version = "0.9.10" } -codewhale-telemetry = { path = "../telemetry", version = "0.9.10" } +codewhale-tui = { path = "../tui", version = "0.9.11" } +codewhale-agent = { path = "../agent", version = "0.9.11" } +codewhale-app-server = { path = "../app-server", version = "0.9.11" } +codewhale-config = { path = "../config", version = "0.9.11" } +codewhale-lane = { path = "../lane", version = "0.9.11" } +codewhale-workflow = { path = "../workflow", version = "0.9.11" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } +codewhale-mcp = { path = "../mcp", version = "0.9.11" } +codewhale-paths = { path = "../paths", version = "0.9.11" } +codewhale-release = { path = "../release", version = "0.9.11" } +codewhale-secrets = { path = "../secrets", version = "0.9.11" } +codewhale-state = { path = "../state", version = "0.9.11" } +codewhale-telemetry = { path = "../telemetry", version = "0.9.11" } chrono.workspace = true console = "0.16.3" dirs.workspace = true serde.workspace = true serde_json.workspace = true +toml.workspace = true reqwest = { workspace = true, features = ["blocking"] } rustls.workspace = true semver.workspace = true @@ -45,7 +49,7 @@ webbrowser = "1.0" zeroize = "1.8.2" [build-dependencies] -codewhale-build-support = { path = "../build-support", version = "0.9.10" } +codewhale-build-support = { path = "../build-support", version = "0.9.11" } # Parent-death cleanup for delegated server children (#3259): on Linux the # dispatcher sets PR_SET_PDEATHSIG so the child is signalled if the dispatcher diff --git a/crates/cli/src/cloud.rs b/crates/cli/src/cloud.rs index 0dad11dc45..9a46692125 100644 --- a/crates/cli/src/cloud.rs +++ b/crates/cli/src/cloud.rs @@ -8,14 +8,14 @@ use std::io::{self, IsTerminal, Read, Write}; use std::net::IpAddr; use std::thread; -use std::time::{Duration, Instant}; +use std::time::Duration; use anyhow::{Context, Result, anyhow, bail}; use clap::{Args, Subcommand, ValueEnum}; +use codewhale_config::device_code::DevicePollOutcome; use codewhale_config::{ConfigStore, ProviderKind}; use codewhale_secrets::Secrets; use codewhale_secrets::account::{ - ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV as CLOUD_ALLOW_FILE_SESSION_STORE_ENV, ACCOUNT_API_BASE_ENV as CLOUD_API_BASE_ENV, AccountAuthBundle as AuthBundle, AccountSessionStore, AccountUser as CloudUser, DEFAULT_ACCOUNT_API_BASE as DEFAULT_API_BASE, StoredAccountAuth as StoredCloudAuth, normalize_account_profile as normalized_profile, @@ -29,8 +29,8 @@ const MIN_API_KEY_BYTES: usize = 8; const MAX_API_KEY_BYTES: u64 = 4096; const MAX_API_KEY_STDIN_BYTES: u64 = MAX_API_KEY_BYTES + 1024; const MAX_KEY_LABEL_CHARS: usize = 80; -const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 600; -const MAX_LOGIN_TIMEOUT_SECONDS: u64 = 3600; +pub(crate) const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 600; +pub(crate) const MAX_LOGIN_TIMEOUT_SECONDS: u64 = 3600; #[derive(Debug, Args)] pub(crate) struct CloudArgs { @@ -318,16 +318,16 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> { validate_device_code(&device.device_code)?; let server_lifetime = Duration::from_secs(device.expires_in.clamp(1, MAX_LOGIN_TIMEOUT_SECONDS)); - let timeout = timeout.min(server_lifetime); - let interval = Duration::from_secs(device.interval.clamp(1, 10)); - let started = Instant::now(); - - loop { - if started.elapsed() >= timeout { - bail!( - "Codewhale account login timed out; run `codewhale account login` to try again" - ); - } + // The Codewhale account service answers HTTP 202 while the code is + // still pending, so the first response is already meaningful: poll + // immediately and sleep afterwards. It has no slow_down. + let bundle = codewhale_config::device_code::DeviceCodePoll::new( + timeout.min(server_lifetime), + "Codewhale account login timed out; run `codewhale account login` to try again", + ) + .interval_seconds(Some(device.interval)) + .max_interval_seconds(10) + .run(sleep, || { let response = self.transport.execute(CloudRequest { method: HttpMethod::Post, path: "/api/cli/device/token".to_string(), @@ -340,21 +340,14 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> { 200 => { let bundle: AuthBundle = parse_json_body(&response.body)?; validate_auth_bundle(&bundle)?; - self.save_auth(bundle.clone())?; - return Ok(bundle); - } - 202 => { - let remaining = timeout.saturating_sub(started.elapsed()); - if remaining.is_zero() { - bail!( - "Codewhale account login timed out; run `codewhale account login` to try again" - ); - } - sleep(interval.min(remaining)); + Ok(DevicePollOutcome::Complete(bundle)) } - _ => return Err(response_error(&response)), + 202 => Ok(DevicePollOutcome::Pending), + _ => Err(response_error(&response)), } - } + })?; + self.save_auth(bundle.clone())?; + Ok(bundle) } fn load_auth(&self) -> Result> { @@ -532,19 +525,31 @@ pub(crate) fn run(args: CloudArgs, profile: Option<&str>, config: &ConfigStore) } fn cloud_session_secrets() -> Result { - match secure_account_session_secrets() { - Ok(secrets) => { - if secrets.backend_name().starts_with("file-based") { - eprintln!( - "warning: OS credential manager unavailable; {CLOUD_ALLOW_FILE_SESSION_STORE_ENV}=1 explicitly enables the local 0600 Codewhale secrets file for cloud session tokens" - ); - } - Ok(secrets) - } - Err(_) => bail!( - "Codewhale account login requires an OS credential manager for session tokens. Configure Keychain, Credential Manager, or Secret Service and try again. Headless users may explicitly opt into the local 0600 secrets file with {CLOUD_ALLOW_FILE_SESSION_STORE_ENV}=1" - ), - } + // Codex-style storage contract: the OS credential manager is preferred + // but never required; without one, sessions live in the private 0600 + // Codewhale secrets file. Only an unresolvable store path fails here. + secure_account_session_secrets().map_err(|err| anyhow!(err.to_string())) +} + +/// `codewhale login` is a convenience entry to the account device flow — the +/// same path as `codewhale account login`, without re-spelling the subcommand. +pub(crate) fn run_account_login( + no_open: bool, + timeout_seconds: u64, + profile: Option<&str>, + config: &ConfigStore, +) -> Result<()> { + run( + CloudArgs { + api_base: None, + command: CloudCommand::Login(CloudLoginArgs { + no_open, + timeout_seconds, + }), + }, + profile, + config, + ) } pub(crate) fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> { diff --git a/crates/cli/src/cloud/tests.rs b/crates/cli/src/cloud/tests.rs index 30448d19e7..ac8c61c395 100644 --- a/crates/cli/src/cloud/tests.rs +++ b/crates/cli/src/cloud/tests.rs @@ -5,7 +5,6 @@ use clap::Parser; use codewhale_secrets::account::{ ACCOUNT_SESSION_SCHEMA_VERSION, AccountSession as AuthSession, account_auth_slot as cloud_auth_slot, - account_file_session_store_opted_in_value as file_session_store_opted_in_value, }; use codewhale_secrets::{InMemoryKeyringStore, KeyringStore}; use serde_json::json; @@ -273,15 +272,6 @@ fn user_codes_and_key_inputs_match_the_server_contract() { assert!(validate_label(&"x".repeat(81)).is_err()); } -#[test] -fn file_session_store_requires_explicit_one_value() { - assert!(!file_session_store_opted_in_value(None)); - assert!(!file_session_store_opted_in_value(Some(""))); - assert!(!file_session_store_opted_in_value(Some("true"))); - assert!(file_session_store_opted_in_value(Some("1"))); - assert!(file_session_store_opted_in_value(Some(" 1 "))); -} - #[test] fn device_flow_handles_pending_then_authorized_without_printing_tokens() { let (temp, config) = test_config(); diff --git a/crates/cli/src/config_bundles.rs b/crates/cli/src/config_bundles.rs new file mode 100644 index 0000000000..eea321611b --- /dev/null +++ b/crates/cli/src/config_bundles.rs @@ -0,0 +1,3420 @@ +//! Portable config bundles: `codewhale config import` / `config export --portable`. +//! +//! A bundle is a TOML or JSON document carrying a portable subset of a +//! CodeWhale configuration (preferences, harness profiles, provider +//! non-secret settings, project/global sections) between machines. The +//! envelope is versioned and strict (`deny_unknown_fields`), secrets are +//! rejected by key name and value shape (never echoed), parsing is bounded, +//! and application is transactional with a timestamped backup and rollback. +//! +//! Security contract: +//! - No secret ever round-trips: fields whose key matches +//! [`codewhale_config::is_sensitive_config_key`] are rejected on import and +//! dropped on export, and bare credential-shaped values are rejected by +//! value shape. Rejection messages name the field, never the value. +//! - Input size is capped (5 MiB, matching the skill installer's cap). +//! - HTTPS only for remote fetch, except plain `http` on loopback; redirects +//! are followed at most a bounded number of times within the same scheme. +//! - Bundle-declared file paths must resolve inside the target config +//! directory; traversal and symlink escapes are refused. +//! - Project scope never mutates the user-global document and vice versa. + +use std::io::{IsTerminal, Read}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; + +use codewhale_config::{ConfigToml, is_sensitive_config_key}; + +/// Maximum accepted bundle size, both for reads and remote fetches. +/// Matches the skill installer's 5 MiB cap. +pub const MAX_BUNDLE_BYTES: u64 = 5 * 1024 * 1024; + +/// Envelope `kind` value required by every bundle. +pub const BUNDLE_KIND: &str = "codewhale.portable-config"; + +/// Envelope `schema_version` accepted by this build. +pub const BUNDLE_SCHEMA_VERSION: u64 = 1; + +/// Maximum number of HTTP redirects followed during a remote fetch. +const MAX_REDIRECTS: usize = 5; + +/// Timeout for the remote fetch, in seconds. +const FETCH_TIMEOUT_SECS: u64 = 30; + +/// Credential-shaped value prefixes rejected even under a benign key name. +/// Conservative on purpose: only well-known provider token shapes. +const SECRET_VALUE_PREFIXES: [&str; 6] = ["sk-", "Bearer ", "ghp_", "xoxb-", "AKIA", "eyJ"]; + +// --------------------------------------------------------------------------- +// Envelope +// --------------------------------------------------------------------------- + +/// Strict portable-bundle envelope. Unknown fields fail the parse: a bundle +/// written by a newer schema must not be silently half-applied. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PortableBundle { + pub schema_version: u64, + pub kind: String, + #[serde(default)] + pub metadata: BundleMetadata, + #[serde(default)] + pub preferences: BundleTable, + #[serde(default)] + pub profiles: BundleTable, + #[serde(default)] + pub plugins: BundleTable, + #[serde(default)] + pub project: BundleTable, + #[serde(default)] + pub global: BundleTable, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BundleMetadata { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub generator: Option, +} + +/// One bundle section: a flat table of config keys to values. Keys inside a +/// section are data, not schema, so unknown keys parse here — credential +/// rejection happens at plan time by name and value shape. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct BundleTable { + #[serde(flatten)] + pub entries: std::collections::BTreeMap, +} + +// --------------------------------------------------------------------------- +// Parsing (bounded) +// --------------------------------------------------------------------------- + +/// Parse a bundle from raw bytes, rejecting oversize input before parse. +pub fn parse_bundle_bytes(raw: &[u8], source: &str) -> Result { + if raw.len() as u64 > MAX_BUNDLE_BYTES { + bail!( + "bundle at {source} is {} bytes; the limit is {MAX_BUNDLE_BYTES} bytes", + raw.len() + ); + } + let text = std::str::from_utf8(raw) + .with_context(|| format!("bundle at {source} is not valid UTF-8"))?; + parse_bundle_str(text, source) +} + +/// Parse a bundle document: TOML by default, JSON when the source ends in +/// `.json` or the document starts with `{`. +pub fn parse_bundle_str(text: &str, source: &str) -> Result { + let trimmed = text.trim_start(); + let bundle = if trimmed.starts_with('{') { + serde_json::from_str::(text) + .with_context(|| format!("bundle at {source} is not valid JSON"))? + } else if source.ends_with(".json") { + serde_json::from_str::(text) + .with_context(|| format!("bundle at {source} is not valid JSON"))? + } else { + toml::from_str::(text) + .with_context(|| format!("bundle at {source} is not valid TOML"))? + }; + validate_bundle(&bundle, source)?; + Ok(bundle) +} + +fn validate_bundle(bundle: &PortableBundle, source: &str) -> Result<()> { + if bundle.kind != BUNDLE_KIND { + bail!( + "bundle at {source} has kind {:?}; expected {BUNDLE_KIND:?}", + bundle.kind + ); + } + if bundle.schema_version != BUNDLE_SCHEMA_VERSION { + bail!( + "bundle at {source} has schema_version {}; this build understands {BUNDLE_SCHEMA_VERSION}", + bundle.schema_version + ); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Secret rejection +// --------------------------------------------------------------------------- + +/// One rejected entry: the dotted key path and the reason. Values are never +/// included — the reason and path are all a reviewer needs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RejectedEntry { + pub key: String, + pub reason: String, +} + +/// Scan every section of the bundle for non-portable entries. Import and +/// export use the same path predicate, so machine-local route/execution/trust +/// authority cannot be stripped in one direction but accepted in the other. +/// String leaves are additionally rejected by credential shape. +pub fn find_rejected_entries(bundle: &PortableBundle) -> Vec { + let mut rejected = Vec::new(); + for (section, table) in [ + ("preferences", &bundle.preferences), + ("profiles", &bundle.profiles), + ("plugins", &bundle.plugins), + ("project", &bundle.project), + ("global", &bundle.global), + ] { + for (key, value) in &table.entries { + let dotted = format!("{section}.{key}"); + if let Some(reason) = nonportable_path_reason(key) { + rejected.push(RejectedEntry { + key: dotted, + reason: reason.to_string(), + }); + continue; + } + if let Some(reason) = value_rejection_reason(key, value) { + rejected.push(RejectedEntry { + key: dotted, + reason, + }); + } + } + } + rejected +} + +/// Why a value carries nested non-portable authority or looks like a bare +/// credential, or `None` when it is safe to move between machines. +fn value_rejection_reason(path: &str, value: &toml::Value) -> Option { + if let Some(reason) = nonportable_value_reason(path, value) { + return Some(reason.to_string()); + } + match value { + toml::Value::String(text) => string_secret_reason(text), + toml::Value::Array(items) => items + .iter() + .find_map(|value| value_rejection_reason(path, value)) + .map(|reason| format!("array contains an entry where {reason}")), + toml::Value::Table(map) => { + for (key, nested_value) in map { + let child_path = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + if let Some(reason) = nonportable_path_reason(&child_path) { + return Some(format!("nested key {key:?} {reason}")); + } + if let Some(reason) = value_rejection_reason(&child_path, nested_value) { + return Some(format!("nested under {key:?}, {reason}")); + } + } + None + } + _ => None, + } +} + +fn string_secret_reason(text: &str) -> Option { + if let Some(prefix) = SECRET_VALUE_PREFIXES + .iter() + .find(|prefix| text.trim().starts_with(*prefix)) + { + return Some(format!( + "value has the shape of a credential (prefix {prefix:?} redacted)" + )); + } + if text.contains(codewhale_config::persistence::REDACTED) { + // A placeholder is the residue of redaction, never a real setting; + // exporting it would carry nothing and importing it would write the + // placeholder into the live document. + return Some("value contains a redaction placeholder".to_string()); + } + if codewhale_config::persistence::redact_secrets(text) != text { + return Some("value contains credential-shaped text".to_string()); + } + None +} + +fn is_sensitive_bundle_key(key: &str) -> bool { + if is_sensitive_config_key(key) || is_credential_authority_key(key) { + return true; + } + // Normalize the complete dotted path, not only its final component. A + // quoted TOML key such as `"api.key"` reaches us without its quotes and + // is otherwise indistinguishable from two structural components. Either + // representation names credential material and must fail closed. + let normalized = normalize_bundle_key(key); + + matches!( + normalized.as_str(), + "access_key" + | "access_token" + | "api_key" + | "api_keys" + | "apikey" + | "authorization" + | "bearer" + | "client_secret" + | "cookie" + | "credential" + | "credentials" + | "id_token" + | "password" + | "passwords" + | "passwd" + | "private_key" + | "proxy_authorization" + | "refresh_token" + | "secret" + | "secrets" + | "set_cookie" + | "token" + | "tokens" + ) || normalized.ends_with("_access_key") + || normalized.ends_with("_api_key") + || normalized.ends_with("_authorization") + || normalized.ends_with("_cookie") + || normalized.ends_with("_password") + || normalized.ends_with("_private_key") + || normalized.ends_with("_secret") + || normalized.ends_with("_token") +} + +fn normalize_bundle_key(key: &str) -> String { + let segment = key.trim().trim_matches('"'); + let chars: Vec = segment.chars().collect(); + let mut normalized = String::with_capacity(segment.len()); + for (index, character) in chars.iter().copied().enumerate() { + if !character.is_ascii_alphanumeric() { + if !normalized.ends_with('_') { + normalized.push('_'); + } + continue; + } + if character.is_ascii_uppercase() { + let previous = index.checked_sub(1).and_then(|index| chars.get(index)); + let next = chars.get(index + 1); + let starts_word = previous.is_some_and(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() + }) || (previous + .is_some_and(|character| character.is_ascii_uppercase()) + && next.is_some_and(|character| character.is_ascii_lowercase())); + if starts_word && !normalized.ends_with('_') { + normalized.push('_'); + } + normalized.push(character.to_ascii_lowercase()); + } else { + normalized.push(character.to_ascii_lowercase()); + } + } + normalized.trim_matches('_').to_string() +} + +fn is_credential_authority_key(key: &str) -> bool { + let normalized = normalize_bundle_key(key); + if normalized == "external_credentials" + || normalized.ends_with("_external_credentials") + || normalized == "oauth_credential_generation" + || normalized.ends_with("_oauth_credential_generation") + { + return true; + } + // `auth_mode` is a declarative protocol selection; an `auth` table is + // executable or secret-store authority and is intentionally non-portable. + key.split('.') + .map(normalize_bundle_key) + .any(|segment| segment == "auth") +} + +fn is_machine_bound_top_level_key(key: &str) -> bool { + key.split('.') + .next() + .map(normalize_bundle_key) + .is_some_and(|root| { + matches!( + root.as_str(), + "auto_review" + | "hooks" + | "instructions" + | "managed_config_path" + | "project_instruction_imports" + | "projects" + | "requirements_path" + | "runtime_api" + | "workspace" + ) + }) +} + +fn is_nonportable_lsp_authority_key(key: &str) -> bool { + let mut segments = key.split('.').map(normalize_bundle_key); + matches!(segments.next().as_deref(), Some("lsp")) + && matches!(segments.next().as_deref(), Some("custom" | "servers")) +} + +fn is_nonportable_nested_authority_key(key: &str) -> bool { + let segments = key.split('.').map(normalize_bundle_key).collect::>(); + match segments.as_slice() { + [root, field, ..] + if root == "tools" && matches!(field.as_str(), "overrides" | "plugin_dir") => + { + true + } + [root, field, ..] if root == "update" && field == "update_uri" => true, + [root, field, ..] if root == "notifications" && field == "sound_file" => true, + [root, field, ..] if root == "speech" && field == "output_dir" => true, + [root, .., field] if root == "providers" && field == "api_key_env" => true, + _ => false, + } +} + +fn is_machine_specific_config_path(path: &str) -> bool { + let path = normalize_bundle_key(path); + MACHINE_SPECIFIC_KEYS.iter().any(|key| { + let key = normalize_bundle_key(key); + path == key + || path + .strip_suffix(&key) + .is_some_and(|prefix| prefix.ends_with('_')) + }) +} + +fn nonportable_path_reason(path: &str) -> Option<&'static str> { + if is_machine_bound_top_level_key(path) { + return Some("carries machine-bound execution or trust authority"); + } + if is_nonportable_lsp_authority_key(path) { + return Some("carries executable LSP authority"); + } + if is_nonportable_nested_authority_key(path) { + return Some("carries machine-local route or execution authority"); + } + if is_machine_specific_config_path(path) { + return Some("carries machine-local route or filesystem authority"); + } + if is_credential_authority_key(path) { + return Some("carries machine-local credential authority"); + } + if is_sensitive_bundle_key(path) { + return Some("names credential material"); + } + None +} + +/// Telemetry opt-out is safe to move between machines, but opt-in is durable +/// user consent coupled to SetupState. A portable bundle may tighten that +/// consent (`false`); it must never manufacture or transfer `true`. +fn nonportable_value_reason(path: &str, value: &toml::Value) -> Option<&'static str> { + let top_level_telemetry = !path.contains('.') && normalize_bundle_key(path) == "telemetry"; + (top_level_telemetry && matches!(value, toml::Value::Boolean(true))) + .then_some("would port telemetry opt-in consent between machines") +} + +// --------------------------------------------------------------------------- +// Import plan +// --------------------------------------------------------------------------- + +/// What applying the bundle would do, computed before anything is written. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImportPlan { + pub added: Vec, + pub changed: Vec, + pub skipped: Vec, + pub conflicting: Vec, + pub rejected: Vec, +} + +impl ImportPlan { + #[must_use] + pub fn is_no_op(&self) -> bool { + self.added.is_empty() && self.changed.is_empty() + } +} + +/// Compute the deterministic import plan for `bundle` against `config`. +/// +/// `section` selects the target document mapping: bundle `project` entries +/// apply only to a project-scope document, `global` entries only to a +/// user-global one; `preferences`, `profiles`, and `plugins` apply to both. +/// Entries that would not touch the target document are `skipped`, so the +/// same bundle imports cleanly at either scope. +pub fn plan_import(bundle: &PortableBundle, config: &ConfigToml, scope: BundleScope) -> ImportPlan { + let mut plan = ImportPlan { + rejected: find_rejected_entries(bundle), + ..ImportPlan::default() + }; + let rejected_keys: std::collections::BTreeSet<&str> = plan + .rejected + .iter() + .map(|entry| entry.key.as_str()) + .collect(); + // Sections are presentation and scope labels over one flat ConfigToml + // keyspace. Two applicable sections naming the same key would otherwise + // make apply order decide which value wins. Detect that ambiguity before + // classifying or writing any entry. + let mut applicable_key_counts = std::collections::BTreeMap::<&str, usize>::new(); + for (section, table) in [ + ("preferences", &bundle.preferences), + ("profiles", &bundle.profiles), + ("plugins", &bundle.plugins), + ("project", &bundle.project), + ("global", &bundle.global), + ] { + if section_applies(section, scope) { + for key in table.entries.keys() { + *applicable_key_counts.entry(key.as_str()).or_default() += 1; + } + } + } + let colliding_keys: std::collections::BTreeSet<&str> = applicable_key_counts + .into_iter() + .filter_map(|(key, count)| (count > 1).then_some(key)) + .collect(); + + for (section, table) in [ + ("preferences", &bundle.preferences), + ("profiles", &bundle.profiles), + ("plugins", &bundle.plugins), + ("project", &bundle.project), + ("global", &bundle.global), + ] { + let dotted = |key: &str| format!("{section}.{key}"); + let applies = section_applies(section, scope); + for (key, value) in &table.entries { + let dotted = dotted(key); + if rejected_keys.contains(dotted.as_str()) + || (applies && colliding_keys.contains(key.as_str())) + { + plan.conflicting.push(dotted); + continue; + } + if !applies { + plan.skipped.push(dotted); + continue; + } + if config_value_matches(config, key, value) { + plan.skipped.push(dotted); + } else if config_has_value(config, key) { + plan.changed.push(dotted); + } else { + plan.added.push(dotted); + } + } + } + plan +} + +fn config_value_matches(config: &ConfigToml, key: &str, value: &toml::Value) -> bool { + let semantically_equal = (|| { + let current = config_document(config).ok()?; + let mut candidate = config.clone(); + apply_config_value(&mut candidate, key, value).ok()?; + Some(config_document(&candidate).ok()? == current) + })() + .unwrap_or(false); + semantically_equal + || config.get_value(key).is_some_and(|current| { + render_toml_value(value).ok().as_deref() == Some(current.as_str()) + }) +} + +fn config_has_value(config: &ConfigToml, key: &str) -> bool { + config_document(config) + .ok() + .is_some_and(|table| table.contains_key(key)) + || config.get_value(key).is_some() +} + +fn section_applies(section: &str, scope: BundleScope) -> bool { + match section { + "project" => scope == BundleScope::Project, + "global" => scope == BundleScope::Global, + _ => true, + } +} + +// --------------------------------------------------------------------------- +// Scope +// --------------------------------------------------------------------------- + +/// Which document an import/export targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BundleScope { + /// The user-global config (`~/.codewhale/config.toml` by default). + Global, + /// The workspace-scoped config (`/.codewhale/config.toml`). + Project, +} + +impl BundleScope { + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::Global => "global", + Self::Project => "project", + } + } +} + +fn validate_scope_target(scope: BundleScope, target: &Path) -> Result<()> { + let workspace_scoped = codewhale_config::config_path_is_workspace_scoped(target); + match (scope, workspace_scoped) { + (BundleScope::Project, false) => bail!( + "--project requires a workspace config ({} is the user-global document)", + target.display() + ), + (BundleScope::Global, true) => bail!( + "global bundle operations cannot target workspace config {}; rerun with --project or select the user-global config", + target.display() + ), + _ => {} + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Path safety +// --------------------------------------------------------------------------- + +/// Resolve `candidate` inside `base_dir`, refusing traversal and symlink +/// escapes. Returns the resolved path or an error naming the refusal — the +/// candidate string itself is safe to echo (it is config data, not a secret). +/// Resolve `candidate` inside `base_dir`, refusing traversal and symlink +/// escapes. Returns the joined path or an error naming the refusal. +/// Reserved for path-carrying bundle sections (none shipped yet); exercised +/// by the traversal tests so the contract cannot silently rot. +#[cfg_attr( + not(test), + expect(dead_code, reason = "path-carrying sections land with the next schema") +)] +pub fn resolve_bounded_path(base_dir: &Path, candidate: &str) -> Result { + if candidate.contains('\0') { + bail!("bundle path contains a NUL byte; refused"); + } + let candidate_path = Path::new(candidate); + if candidate_path.is_absolute() { + bail!( + "bundle path {candidate:?} is absolute; only paths inside the config directory are accepted" + ); + } + let canonical_base = base_dir + .canonicalize() + .with_context(|| format!("config directory {} is unavailable", base_dir.display()))?; + let joined = base_dir.join(candidate_path); + // Walk the joined path's ancestors from the deepest existing component up: + // every existing component must canonicalize inside the base, so a symlink + // pointing outside the config directory is refused even when the final + // target does not exist yet. + let deepest_existing = joined + .ancestors() + .find(|ancestor| ancestor.symlink_metadata().is_ok()) + .context("bundle path has no existing ancestor inside the config directory")?; + let resolved = deepest_existing.canonicalize().with_context(|| { + format!( + "could not resolve bundle path component {}", + deepest_existing.display() + ) + })?; + if !resolved.starts_with(&canonical_base) { + bail!("bundle path {candidate:?} escapes the config directory via a symlink; refused"); + } + Ok(joined) +} + +// --------------------------------------------------------------------------- +// Remote fetch +// --------------------------------------------------------------------------- + +/// Fetch a bundle over HTTPS (or plain http on loopback only) with a hard +/// size cap, a timeout, and bounded redirects. Mirrors the skill installer's +/// fetch bounds. +pub fn fetch_bundle(url: &str) -> Result> { + let mut current_url = reqwest::Url::parse(url).map_err(|_| anyhow!("invalid bundle URL"))?; + validate_bundle_url(¤t_url)?; + let initial_scheme = current_url.scheme().to_string(); + + let client = codewhale_release::platform_blocking_http_client_builder() + .timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS)) + // Redirect targets must pass the same scheme/host policy as the + // initial request, so redirects are followed explicitly below. + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| anyhow!("building bundle fetch client failed"))?; + let mut redirects = 0usize; + let response = loop { + let response = client + .get(current_url.clone()) + .send() + // reqwest errors can include the full URL (including its query or + // userinfo), so keep transport failures deliberately URL-free. + .map_err(|_| anyhow!("bundle fetch request failed"))?; + + if !response.status().is_redirection() { + break response; + } + if redirects >= MAX_REDIRECTS { + bail!("bundle fetch exceeded the five-redirect limit"); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .ok_or_else(|| anyhow!("bundle redirect is missing a valid Location header"))? + .to_str() + .map_err(|_| anyhow!("bundle redirect is missing a valid Location header"))?; + let next_url = current_url + .join(location) + .map_err(|_| anyhow!("bundle redirect Location is invalid"))?; + validate_bundle_redirect(&initial_scheme, &next_url)?; + current_url = next_url; + redirects += 1; + }; + + if !response.status().is_success() { + bail!( + "bundle fetch failed with HTTP status {}", + response.status().as_u16() + ); + } + + // Read at most MAX_BUNDLE_BYTES + 1 so an oversize body is detected + // rather than silently truncated. + let mut buffer = Vec::new(); + let body = response; + body.take(MAX_BUNDLE_BYTES + 1) + .read_to_end(&mut buffer) + .map_err(|_| anyhow!("reading remote bundle failed"))?; + if buffer.len() as u64 > MAX_BUNDLE_BYTES { + bail!("remote bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused"); + } + Ok(buffer) +} + +fn validate_bundle_url(url: &reqwest::Url) -> Result<()> { + if !matches!(url.scheme(), "http" | "https") { + bail!("unsupported bundle URL scheme; use https"); + } + if !url.username().is_empty() || url.password().is_some() { + bail!("bundle URLs may not include credentials"); + } + let host = url.host_str().context("bundle URL must include a host")?; + match url.scheme() { + "https" => Ok(()), + "http" if is_loopback_bundle_host(host) => Ok(()), + "http" => bail!("plain http is only allowed for loopback hosts; use https"), + _ => unreachable!("scheme was validated above"), + } +} + +fn validate_bundle_redirect(initial_scheme: &str, next_url: &reqwest::Url) -> Result<()> { + validate_bundle_url(next_url)?; + if next_url.scheme() != initial_scheme { + bail!("bundle redirects may not change URL scheme"); + } + Ok(()) +} + +fn is_loopback_bundle_host(host: &str) -> bool { + let normalized = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + normalized.eq_ignore_ascii_case("localhost") + || normalized.to_ascii_lowercase().ends_with(".localhost") + || normalized + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/// Build a deterministic, secret-free export from `config`. +/// +/// Keys are sorted, machine-specific absolute paths and credential fields are +/// dropped, and the same section mapping as import is used so an exported +/// bundle re-imports at the same scope. +pub fn export_bundle( + config: &ConfigToml, + scope: BundleScope, + metadata: BundleMetadata, +) -> Result { + let mut preferences = BundleTable::default(); + let mut profiles = BundleTable::default(); + let mut global = BundleTable::default(); + let mut project = BundleTable::default(); + + for (key, value) in config_document(config)? { + if let Some(value) = sanitize_export_value(&key, &value) { + match export_section_for(&key, scope) { + ExportSection::Preferences => { + preferences.entries.insert(key, value); + } + ExportSection::Profiles => { + profiles.entries.insert(key, value); + } + ExportSection::Global => { + global.entries.insert(key, value); + } + ExportSection::Project => { + project.entries.insert(key, value); + } + ExportSection::Drop => {} + } + } + } + + let bundle = PortableBundle { + schema_version: BUNDLE_SCHEMA_VERSION, + kind: BUNDLE_KIND.to_string(), + metadata, + preferences, + profiles, + plugins: BundleTable::default(), + project, + global, + }; + let rejected = find_rejected_entries(&bundle); + if !rejected.is_empty() { + bail!( + "portable export refused credential-bearing config paths: {}", + rejected + .iter() + .map(|entry| entry.key.as_str()) + .collect::>() + .join(", ") + ); + } + Ok(bundle) +} + +/// Serialize a bundle deterministically (sorted keys, TOML). +pub fn serialize_bundle(bundle: &PortableBundle) -> Result { + toml::to_string_pretty(bundle).context("serializing portable bundle") +} + +/// Config keys that name a machine-local location and must never be exported. +const MACHINE_SPECIFIC_KEYS: [&str; 14] = [ + "base_url", + "bwrap_dev_roots", + "bwrap_ro_roots", + "hook_sinks.unix_socket_path", + "mcp_config_path", + "mcp_oauth_callback_port", + "mcp_oauth_callback_url", + "memory_path", + "network.proxy", + "notes_path", + "sandbox_backend", + "sandbox_url", + "skills_dir", + "telemetry_endpoint", +]; + +enum ExportSection { + Preferences, + Profiles, + Global, + Project, + Drop, +} + +fn export_section_for(key: &str, scope: BundleScope) -> ExportSection { + if key.starts_with("harness") || key.contains("harness_profiles") { + return ExportSection::Profiles; + } + if key.starts_with("skills") || key.starts_with("tools") || key.starts_with("snapshots") { + return ExportSection::Preferences; + } + if key.starts_with("auth.") { + return ExportSection::Drop; + } + match scope { + BundleScope::Global => ExportSection::Global, + BundleScope::Project => ExportSection::Project, + } +} + +fn config_document(config: &ConfigToml) -> Result> { + // Serialize through TOML text before parsing to Value. Direct + // `Value::try_from` double-encodes datetime values held inside flattened + // `toml::Value` extras as the serializer's private marker table. + let text = toml::to_string(config).context("serializing typed config for bundle")?; + let value: toml::Value = + toml::from_str(&text).map_err(|_| anyhow!("serialized typed config was not valid TOML"))?; + let toml::Value::Table(mut table) = value else { + bail!("typed config did not serialize to a TOML table"); + }; + // `selected_provider_id` is runtime parse state and is skipped by serde; + // restore the exact named-provider identity that ConfigStore writes. + table.insert( + "provider".to_string(), + toml::Value::String(config.provider_id().to_string()), + ); + Ok(table) +} + +/// Return a recursively scrubbed export value. Secret-bearing leaves and +/// machine-local paths are omitted rather than replaced with a placeholder, +/// because a placeholder would become literal config on re-import. +fn sanitize_export_value(path: &str, value: &toml::Value) -> Option { + if nonportable_path_reason(path).is_some() || nonportable_value_reason(path, value).is_some() { + return None; + } + match value { + toml::Value::String(text) if string_secret_reason(text).is_some() => None, + toml::Value::Array(values) => Some(toml::Value::Array( + values + .iter() + .filter_map(|value| sanitize_export_value(path, value)) + .collect(), + )), + toml::Value::Table(table) => { + let mut scrubbed = toml::map::Map::new(); + for (key, value) in table { + let child_path = format!("{path}.{key}"); + if let Some(value) = sanitize_export_value(&child_path, value) { + scrubbed.insert(key.clone(), value); + } + } + Some(toml::Value::Table(scrubbed)) + } + _ => Some(value.clone()), + } +} + +// --------------------------------------------------------------------------- +// Transactional apply +// --------------------------------------------------------------------------- + +/// Outcome of a committed import. +#[derive(Debug)] +pub struct ImportReceipt { + pub plan: ImportPlan, + pub backup_path: Option, + pub target: PathBuf, +} + +/// Apply a validated bundle to `store` transactionally. +/// +/// The current document is backed up to `.bundle-backup--`, +/// entries are applied through `ConfigStore::set_value`, and any failure +/// restores the backup before returning the error. The receipt redacts by +/// construction: it carries only key paths and counts, never values. +pub fn apply_bundle( + bundle: &PortableBundle, + store: &mut codewhale_config::ConfigStore, + scope: BundleScope, + workspace: &Path, +) -> Result { + apply_bundle_with(bundle, store, scope, workspace, apply_entries) +} + +fn apply_bundle_with( + bundle: &PortableBundle, + store: &mut codewhale_config::ConfigStore, + scope: BundleScope, + workspace: &Path, + apply: F, +) -> Result +where + F: FnOnce( + &PortableBundle, + &mut codewhale_config::ConfigStore, + BundleScope, + &Path, + &mut bool, + ) -> Result<()>, +{ + // Scope isolation is structural: project entries belong only in a + // project document, global entries only in the user-global one. A bundle + // carrying the other scope's section is refused up front rather than + // silently writing across the boundary. + match scope { + BundleScope::Global if !bundle.project.entries.is_empty() => { + bail!( + "bundle carries [project] entries; import it with --project from the workspace instead" + ); + } + BundleScope::Project if !bundle.global.entries.is_empty() => { + bail!( + "bundle carries [global] entries; importing them into a project document would leak machine state" + ); + } + _ => {} + } + // A project-scoped import must target an actual workspace document — the + // user-global file is never a landing zone for [project] entries. + validate_scope_target(scope, store.path())?; + let plan = plan_import(bundle, &store.config, scope); + if !plan.conflicting.is_empty() { + bail!( + "bundle contains conflicting or rejected entries: {}; remove duplicate keys or credential-shaped entries and re-export", + plan.conflicting.join(", ") + ); + } + if plan.is_no_op() { + return Ok(ImportReceipt { + plan, + backup_path: None, + target: store.path().to_path_buf(), + }); + } + + let target = store.path().to_path_buf(); + let original_config = store.config.clone(); + let backup_path = if target + .try_exists() + .with_context(|| format!("checking config target {}", target.display()))? + { + Some(create_collision_safe_backup(&target)?) + } else { + None + }; + + let mut target_written = false; + let apply_result = apply(bundle, store, scope, workspace, &mut target_written); + if let Err(error) = apply_result { + store.config = original_config; + let rollback = rollback_import_target(&target, backup_path.as_deref(), target_written) + .and_then(|()| store.reload()); + match rollback { + Ok(()) => bail!("{error:#}; rolled back to the pre-import document"), + Err(_) => bail!( + "{error:#}; ROLLBACK FAILED — the pre-import document is preserved at {}", + backup_path + .as_deref() + .map(Path::display) + .map(|path| path.to_string()) + .unwrap_or_else( + || "".to_string() + ) + ), + } + } + + Ok(ImportReceipt { + plan, + backup_path, + target, + }) +} + +fn rollback_import_target( + target: &Path, + backup_path: Option<&Path>, + target_written: bool, +) -> Result<()> { + // ConfigStore fails closed before replacing a stale target. If it did not + // report a successful write, leave a concurrently-created or edited file + // alone instead of mistaking somebody else's bytes for ours. + if !target_written { + return Ok(()); + } + if let Some(backup_path) = backup_path { + let bytes = std::fs::read(backup_path) + .with_context(|| format!("reading pre-import backup {}", backup_path.display()))?; + std::fs::write(target, bytes) + .with_context(|| format!("restoring pre-import config {}", target.display()))?; + return Ok(()); + } + + match std::fs::remove_file(target) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error) + .with_context(|| format!("removing newly-created config {}", target.display())), + } +} + +fn apply_entries( + bundle: &PortableBundle, + store: &mut codewhale_config::ConfigStore, + scope: BundleScope, + workspace: &Path, + target_written: &mut bool, +) -> Result<()> { + let mut candidate = store.config.clone(); + for (section, table) in [ + ("preferences", &bundle.preferences), + ("profiles", &bundle.profiles), + ("plugins", &bundle.plugins), + ("project", &bundle.project), + ("global", &bundle.global), + ] { + let applies = match section { + "project" => scope == BundleScope::Project, + "global" => scope == BundleScope::Global, + _ => true, + }; + if !applies { + continue; + } + for (key, value) in &table.entries { + if key == "provider" { + continue; + } + let dotted = format!("{section}.{key}"); + if nonportable_path_reason(key).is_some() + || value_rejection_reason(key, value).is_some() + { + bail!("refusing to import non-portable config path {dotted}"); + } + apply_config_value(&mut candidate, key, value)?; + } + } + // Apply provider selection after provider tables so an exact named custom + // provider exported with its definition can validate successfully. + for (section, table) in [ + ("preferences", &bundle.preferences), + ("profiles", &bundle.profiles), + ("plugins", &bundle.plugins), + ("project", &bundle.project), + ("global", &bundle.global), + ] { + if !section_applies(section, scope) { + continue; + } + if let Some(value) = table.entries.get("provider") { + apply_config_value(&mut candidate, "provider", value)?; + } + } + store.config = candidate; + store.save().context("saving imported bundle")?; + *target_written = true; + let _ = workspace; + Ok(()) +} + +fn apply_config_value(config: &mut ConfigToml, key: &str, value: &toml::Value) -> Result<()> { + if key == "provider" + || key == "auth.mode" + || key == "hook_sinks.unix_socket_path" + || key.starts_with("providers.") + { + return config.set_value(key, &render_toml_value(value)?); + } + + let selected_provider_id = config.selected_provider_id.clone(); + let mut document = config_document(config)?; + if let Some(current) = document.get_mut(key) { + deep_merge_toml_value(current, value); + } else { + document.insert(key.to_string(), value.clone()); + } + // As in `config_document`, round-trip through TOML text so datetimes in + // flattened extras stay TOML datetimes instead of serde-private marker + // tables or strings. + let text = toml::to_string(&toml::Value::Table(document)) + .with_context(|| format!("config entry {key:?} could not be serialized"))?; + let mut updated: ConfigToml = toml::from_str(&text) + .map_err(|_| anyhow!("config entry {key:?} has an invalid TOML type"))?; + updated.selected_provider_id = selected_provider_id; + *config = updated; + Ok(()) +} + +/// Merge a portable value into the target document without treating omitted +/// table leaves as deletions. Tables recurse; arrays and scalars represent an +/// explicit portable choice and replace the corresponding target value. +fn deep_merge_toml_value(target: &mut toml::Value, incoming: &toml::Value) { + match (target, incoming) { + (toml::Value::Table(target), toml::Value::Table(incoming)) => { + for (key, value) in incoming { + if let Some(current) = target.get_mut(key) { + deep_merge_toml_value(current, value); + } else { + target.insert(key.clone(), value.clone()); + } + } + } + (target, incoming) => *target = incoming.clone(), + } +} + +/// Render a TOML value into the scalar text `config set` accepts. +fn render_toml_value(value: &toml::Value) -> Result { + Ok(match value { + toml::Value::String(text) => text.clone(), + toml::Value::Integer(number) => number.to_string(), + toml::Value::Float(number) => number.to_string(), + toml::Value::Boolean(flag) => flag.to_string(), + toml::Value::Datetime(text) => text.to_string(), + toml::Value::Array(_) | toml::Value::Table(_) => { + toml::to_string(value)?.trim_end().to_string() + } + }) +} + +fn create_collision_safe_backup(target: &Path) -> Result { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|since| since.as_secs()) + .unwrap_or_default(); + let file_name = target + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "config.toml".to_string()); + let parent = target.parent().unwrap_or_else(|| Path::new(".")); + let prefix = format!("{file_name}.bundle-backup-{timestamp}-"); + // NamedTempFile uses exclusive creation and restrictive initial + // permissions, so concurrent same-second imports cannot clobber an older + // receipt or expose config bytes before target permissions are applied. + let mut backup = tempfile::Builder::new() + .prefix(&prefix) + .tempfile_in(parent) + .with_context(|| { + format!( + "creating a collision-safe backup beside {}", + target.display() + ) + })?; + let mut source = std::fs::File::open(target) + .with_context(|| format!("opening {} for bundle backup", target.display()))?; + std::io::copy(&mut source, backup.as_file_mut()) + .with_context(|| format!("copying {} into its bundle backup", target.display()))?; + use std::io::Write as _; + backup + .as_file_mut() + .flush() + .context("flushing bundle backup")?; + let permissions = source + .metadata() + .with_context(|| format!("reading permissions for {}", target.display()))? + .permissions(); + std::fs::set_permissions(backup.path(), permissions) + .context("preserving config permissions on bundle backup")?; + backup + .as_file() + .sync_all() + .context("syncing bundle backup")?; + let (_file, path) = backup + .keep() + .map_err(|error| error.error) + .context("persisting bundle backup")?; + Ok(path) +} + +// --------------------------------------------------------------------------- +// Consent +// --------------------------------------------------------------------------- + +/// Require explicit consent before mutating: interactive sessions get a +/// prompt; headless runs require `--yes`. +pub fn require_import_consent(yes: bool, plan: &ImportPlan) -> Result<()> { + if yes { + return Ok(()); + } + if !std::io::stdin().is_terminal() { + bail!( + "import refused: non-interactive use requires explicit --yes after reviewing the plan" + ); + } + print!( + "Apply this bundle ({} added, {} changed)? Type 'yes': ", + plan.added.len(), + plan.changed.len() + ); + use std::io::Write; + std::io::stdout().flush()?; + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .context("reading import consent")?; + if answer.trim() != "yes" { + bail!("import cancelled; no configuration was changed"); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// CLI surface +// --------------------------------------------------------------------------- + +/// Arguments for `codewhale config import`. +#[derive(Debug, clap::Args)] +pub struct ImportArgs { + /// Bundle source: a file path, an HTTPS URL, or `-` for stdin. + pub source: String, + /// Print the deterministic import plan without writing anything. + #[arg(long, default_value_t = false)] + dry_run: bool, + /// Skip the interactive consent prompt (required for headless use). + #[arg(long, default_value_t = false)] + yes: bool, + /// Target the project config instead of the user-global document. + #[arg(long, default_value_t = false)] + project: bool, +} + +/// Arguments for `codewhale config export --portable`. +#[derive(Debug, clap::Args)] +pub struct ExportArgs { + /// Emit a portable, secret-free bundle (required flag; plain `export` + /// is reserved so a future non-portable format cannot silently change + /// what the command writes). + #[arg(long, default_value_t = false)] + portable: bool, + /// Export the project config instead of the user-global document. + #[arg(long, default_value_t = false)] + project: bool, + /// Write to this path instead of stdout. + #[arg(long, value_name = "FILE")] + out: Option, +} + +/// Run `config import`. +pub fn run_import( + args: &ImportArgs, + store: &mut codewhale_config::ConfigStore, + workspace: &Path, +) -> Result<()> { + let scope = if args.project { + BundleScope::Project + } else { + BundleScope::Global + }; + validate_scope_target(scope, store.path())?; + let remote_source = args.source.starts_with("https://") || args.source.starts_with("http://"); + let source_label = if args.source == "-" { + "stdin" + } else if remote_source { + "remote bundle" + } else { + args.source.as_str() + }; + let raw = if args.source == "-" { + let mut buffer = Vec::new(); + std::io::stdin() + .lock() + .take(MAX_BUNDLE_BYTES + 1) + .read_to_end(&mut buffer) + .context("reading bundle from stdin")?; + if buffer.len() as u64 > MAX_BUNDLE_BYTES { + bail!("stdin bundle exceeds the {MAX_BUNDLE_BYTES} byte limit; refused"); + } + buffer + } else if remote_source { + fetch_bundle(&args.source)? + } else { + let path = PathBuf::from(&args.source); + let metadata = std::fs::metadata(&path) + .with_context(|| format!("reading bundle at {}", path.display()))?; + if metadata.len() > MAX_BUNDLE_BYTES { + bail!( + "bundle at {} is {} bytes; the limit is {MAX_BUNDLE_BYTES} bytes", + path.display(), + metadata.len() + ); + } + std::fs::read(&path).with_context(|| format!("reading bundle at {}", path.display()))? + }; + + let bundle = parse_bundle_bytes(&raw, source_label)?; + let plan = plan_import(&bundle, &store.config, scope); + + println!("import plan ({} scope, {source_label}):", scope.label()); + println!(" added: {}", plan.added.len()); + println!(" changed: {}", plan.changed.len()); + println!(" skipped: {}", plan.skipped.len()); + println!(" conflicting: {}", plan.conflicting.len()); + println!(" rejected: {}", plan.rejected.len()); + for entry in &plan.added { + println!(" + {entry}"); + } + for entry in &plan.changed { + println!(" ~ {entry}"); + } + for entry in &plan.rejected { + println!(" ! {} — {}", entry.key, entry.reason); + } + for entry in &plan.conflicting { + println!(" x {entry}"); + } + + if args.dry_run { + println!("dry run: nothing was written"); + return Ok(()); + } + + require_import_consent(args.yes, &plan)?; + let receipt = apply_bundle(&bundle, store, scope, workspace)?; + if receipt.plan.is_no_op() { + println!("nothing to apply; config already matches the bundle (idempotent re-import)"); + return Ok(()); + } + println!( + "imported: {} added, {} changed into {}", + receipt.plan.added.len(), + receipt.plan.changed.len(), + receipt.target.display() + ); + if let Some(backup) = &receipt.backup_path { + println!("pre-import backup: {}", backup.display()); + } + Ok(()) +} + +/// Run `config export --portable`. +pub fn run_export(args: &ExportArgs, store: &codewhale_config::ConfigStore) -> Result<()> { + if !args.portable { + bail!("config export requires --portable; plain export is not defined yet"); + } + let scope = if args.project { + BundleScope::Project + } else { + BundleScope::Global + }; + validate_scope_target(scope, store.path())?; + let metadata = BundleMetadata { + name: None, + created_at: Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)), + generator: Some(format!("codewhale {}", env!("CARGO_PKG_VERSION"))), + }; + let bundle = export_bundle(&store.config, scope, metadata)?; + let body = serialize_bundle(&bundle)?; + match &args.out { + Some(path) => { + codewhale_config::persistence::atomic_write(path, body.as_bytes()) + .with_context(|| format!("writing bundle to {}", path.display()))?; + println!("wrote portable bundle to {}", path.display()); + } + None => { + use std::io::Write; + std::io::stdout().write_all(body.as_bytes())?; + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use codewhale_config::ConfigStore; + use std::io::Write; + use std::net::{Ipv4Addr, TcpListener}; + + const VALID_TOML: &str = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[metadata] +name = "team-baseline" + +[preferences] +verbosity = "quiet" +telemetry = false + +[global] +output_mode = "plain" +"#; + + fn sample_bundle() -> PortableBundle { + parse_bundle_str(VALID_TOML, "test.toml").expect("valid bundle") + } + + #[test] + fn valid_bundle_parses_and_validates() { + let bundle = sample_bundle(); + assert_eq!(bundle.schema_version, 1); + assert_eq!(bundle.kind, "codewhale.portable-config"); + assert_eq!(bundle.metadata.name.as_deref(), Some("team-baseline")); + assert_eq!(bundle.preferences.entries.len(), 2); + } + + #[test] + fn unknown_envelope_fields_fail_the_parse() { + let text = r#" +schema_version = 1 +kind = "codewhale.portable-config" +sneaky_extra = true +"#; + let err = parse_bundle_str(text, "test.toml").expect_err("unknown field must fail"); + let rendered = format!("{err:#}"); + assert!(rendered.contains("unknown field"), "{rendered}"); + } + + #[test] + fn wrong_kind_or_schema_version_is_refused() { + let bad_kind = "schema_version = 1 +kind = \"something-else\"\n"; + let err = parse_bundle_str(bad_kind, "t.toml").expect_err("kind must match"); + assert!(err.to_string().contains("kind"), "{err:#}"); + + let bad_version = "schema_version = 99\nkind = \"codewhale.portable-config\"\n"; + let err = parse_bundle_str(bad_version, "t.toml").expect_err("schema version must match"); + assert!(err.to_string().contains("schema_version"), "{err:#}"); + } + + #[test] + fn json_bundles_parse_when_the_document_is_json() { + let json = r#"{"schema_version": 1, "kind": "codewhale.portable-config", + "preferences": {"verbosity": "quiet"}}"#; + let bundle = parse_bundle_str(json, "bundle.json").expect("json bundle"); + assert_eq!(bundle.preferences.entries.len(), 1); + } + + #[test] + fn oversize_input_is_refused_before_parse() { + let big = vec![b'#'; (MAX_BUNDLE_BYTES + 1) as usize]; + let err = parse_bundle_bytes(&big, "big.toml").expect_err("oversize must fail"); + assert!(err.to_string().contains("limit"), "{err:#}"); + } + + #[test] + fn credential_keys_are_rejected_by_name() { + let text = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +api_key = "value-is-never-echoed" + +[preferences] +openai_api_key = "also-secret" +"#; + let bundle = parse_bundle_str(text, "t.toml").expect("parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 2, "{rejected:?}"); + assert!(rejected.iter().any(|r| r.key == "global.api_key")); + assert!( + rejected + .iter() + .all(|r| !r.reason.contains("value-is-never-echoed")) + ); + } + + #[test] + fn credential_shaped_values_are_rejected_under_benign_names() { + let text = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences] +note = "sk-abcdefghij0123456789" +"#; + let bundle = parse_bundle_str(text, "t.toml").expect("parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 1, "{rejected:?}"); + assert!(!rejected[0].reason.contains("sk-abcdefghij")); + } + + #[test] + fn nested_secret_keys_and_values_are_rejected_without_echoing_values() { + let shaped_value = ["Bear", "er nested-token-must-not-leak"].concat(); + let text = format!( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences.with_key.nested] +password = "nested-password-must-not-leak" + +[preferences.with_value.nested] +note = "{shaped_value}" +"# + ); + let bundle = parse_bundle_str(&text, "nested.toml").expect("bundle parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 2, "{rejected:?}"); + assert!( + rejected + .iter() + .any(|entry| entry.key == "preferences.with_key") + ); + assert!( + rejected + .iter() + .any(|entry| entry.key == "preferences.with_value") + ); + let rendered = format!("{rejected:?}"); + assert!(!rendered.contains("nested-password-must-not-leak")); + assert!(!rendered.contains("nested-token-must-not-leak")); + } + + #[test] + fn network_proxy_routes_are_rejected_on_import_and_scrubbed_on_export() { + let proxy_url = ["http://proxy-user:proxy-", "pass@proxy.internal:3128"].concat(); + let text = format!( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global.network] +default = "prompt" +allow = ["registry.example"] +proxy = ["{proxy_url}"] +"# + ); + let bundle = parse_bundle_str(&text, "network-proxy.toml").expect("bundle parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 1, "{rejected:?}"); + assert_eq!(rejected[0].key, "global.network"); + let rendered = format!("{rejected:?}"); + assert!(!rendered.contains("proxy-pass"), "{rendered}"); + assert!(!rendered.contains("proxy.internal"), "{rendered}"); + + let config: ConfigToml = toml::from_str(&format!( + r#" +[network] +default = "prompt" +allow = ["registry.example"] +proxy = ["{proxy_url}"] +"# + )) + .expect("network config parses"); + let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("network proxy is scrubbed"); + let body = serialize_bundle(&exported).expect("serialize network export"); + assert!(!body.contains("proxy-user"), "{body}"); + assert!(!body.contains("proxy.internal"), "{body}"); + let reparsed: toml::Value = toml::from_str(&body).expect("export reparses"); + let network = reparsed + .get("global") + .and_then(|global| global.get("network")) + .expect("portable network policy is kept"); + assert!(network.get("proxy").is_none(), "{body}"); + assert_eq!( + network.get("default").and_then(toml::Value::as_str), + Some("prompt") + ); + assert!(body.contains("registry.example"), "{body}"); + } + + #[test] + fn redaction_placeholders_are_rejected_on_import_and_export() { + let placeholder = codewhale_config::persistence::REDACTED; + let text = format!( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences] +verbosity = "quiet" +note = "prefix {placeholder} suffix" +"# + ); + let bundle = parse_bundle_str(&text, "placeholder.toml").expect("bundle parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 1, "{rejected:?}"); + assert_eq!(rejected[0].key, "preferences.note"); + assert!( + rejected[0].reason.contains("redaction placeholder"), + "{rejected:?}" + ); + + let config: ConfigToml = toml::from_str(&format!( + "verbosity = \"quiet\"\nnote = \"prefix {placeholder} suffix\"\n" + )) + .expect("placeholder config parses"); + let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("placeholder is scrubbed"); + let body = serialize_bundle(&exported).expect("serialize placeholder export"); + assert!(!body.contains(placeholder), "{body}"); + assert!(body.contains("quiet"), "{body}"); + } + + #[test] + fn camel_case_and_dotted_secret_keys_avoid_token_count_false_positives() { + for key in [ + "accessToken", + "refreshToken", + "clientSecret", + "apiKey", + "api.key", + "private.key", + "accessKey", + "aws_access_key", + "awsSecretAccessKey", + "Cookie", + "Set-Cookie", + "providers.xai.auth.command", + "providers.xai.external_credentials", + "providers.xai.oauth_credential_generation", + "nested.service.accessToken", + "nested.service.refreshToken", + ] { + assert!(is_sensitive_bundle_key(key), "must reject {key}"); + } + for key in [ + "auth_mode", + "maxTokens", + "tokenizer", + "tokenBudget", + "max_tokens", + ] { + assert!(!is_sensitive_bundle_key(key), "must preserve {key}"); + } + } + + #[test] + fn compound_and_access_keys_are_rejected_before_import_without_mutation() { + let api_dot = ["api", ".key"].concat(); + let private_dot = ["private", ".key"].concat(); + let access_camel = ["access", "Key"].concat(); + let aws_snake = ["aws", "_access_key"].concat(); + let aws_camel = ["aws", "SecretAccessKey"].concat(); + let cookie = ["Coo", "kie"].concat(); + let set_cookie = ["Set-", "Cookie"].concat(); + let text = format!( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences] +"{api_dot}" = "opaque-api-value" +"{private_dot}" = "opaque-private-value" +{access_camel} = "opaque-access-value" +{aws_snake} = "opaque-aws-access-value" +{aws_camel} = "opaque-aws-secret-access-value" +maxTokens = 8192 +tokenizer = "bpe" + +[preferences.http_headers] +{cookie} = "opaque-cookie-import-value" +{set_cookie} = "opaque-set-cookie-import-value" +"# + ); + let bundle = + parse_bundle_str(&text, "compound-secrets.toml").expect("compound-key bundle parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 6, "{rejected:?}"); + assert!( + rejected + .iter() + .any(|entry| entry.key == "preferences.http_headers"), + "{rejected:?}" + ); + assert!( + rejected + .iter() + .all(|entry| !entry.key.contains("maxTokens") && !entry.key.contains("tokenizer")), + "{rejected:?}" + ); + + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config"); + let before = std::fs::read(&path).expect("config before import"); + let mut store = ConfigStore::load(Some(path.clone())).expect("store loads"); + let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect_err("credential keys must refuse the entire import"); + let rendered = format!("{error:#}"); + assert!(rendered.contains("conflicting or rejected"), "{rendered}"); + for secret in [ + "opaque-api-value", + "opaque-private-value", + "opaque-access-value", + "opaque-aws-access-value", + "opaque-aws-secret-access-value", + "opaque-cookie-import-value", + "opaque-set-cookie-import-value", + ] { + assert!( + !rendered.contains(secret), + "error leaked {secret}: {rendered}" + ); + } + assert_eq!(std::fs::read(path).expect("config after refusal"), before); + assert_eq!(store.config.verbosity.as_deref(), Some("quiet")); + } + + #[test] + fn plan_reports_added_changed_skipped_deterministically() { + let store = isolated_store(); + let bundle_text = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences] +verbosity = "quiet" +log_level = "debug" + +[global] +output_mode = "plain" +"#; + let bundle = parse_bundle_str(bundle_text, "t.toml").expect("bundle"); + // verbosity already matches; log_level is new; output_mode is global-scope. + let plan_global = plan_import(&bundle, &store.config, BundleScope::Global); + assert!( + plan_global + .added + .contains(&"preferences.log_level".to_string()) + ); + // `verbosity` resolves to a shipped default even when the file key is + // unset, so an equal value reads as changed-or-skipped by resolution; + // what matters for determinism is that every entry lands in exactly + // one bucket and nothing is dropped silently. + let all: std::collections::BTreeSet<&String> = plan_global + .added + .iter() + .chain(plan_global.changed.iter()) + .chain(plan_global.skipped.iter()) + .collect(); + assert_eq!(all.len(), 3, "{plan_global:?}"); + // Project scope skips global-section entries. + let plan_project = plan_import(&bundle, &store.config, BundleScope::Project); + assert!( + plan_project + .skipped + .contains(&"global.output_mode".to_string()) + ); + } + + #[test] + fn rejected_entries_show_up_as_conflicting_in_the_plan() { + let store = isolated_store(); + let text = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +api_key = "never-echoed" +"#; + let bundle = parse_bundle_str(text, "t.toml").expect("bundle"); + let plan = plan_import(&bundle, &store.config, BundleScope::Global); + assert!(plan.conflicting.contains(&"global.api_key".to_string())); + assert!(plan.added.is_empty()); + } + + #[test] + fn duplicate_flat_keys_across_applicable_sections_fail_before_apply() { + let mut store = isolated_store(); + let before = std::fs::read(store.path()).expect("config before import"); + let text = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences] +verbosity = "quiet" + +[global] +verbosity = "verbose" +"#; + let bundle = parse_bundle_str(text, "collision.toml").expect("bundle parses"); + let plan = plan_import(&bundle, &store.config, BundleScope::Global); + + assert_eq!( + plan.conflicting, + ["preferences.verbosity", "global.verbosity"] + ); + assert!(plan.added.is_empty(), "{plan:?}"); + assert!(plan.changed.is_empty(), "{plan:?}"); + assert!(plan.skipped.is_empty(), "{plan:?}"); + + let workspace = tempfile::tempdir().expect("workspace"); + let error = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path()) + .expect_err("ambiguous flat key must fail closed"); + let rendered = error.to_string(); + assert!(rendered.contains("conflicting"), "{error:#}"); + assert!(!rendered.contains("quiet"), "{error:#}"); + assert!(!rendered.contains("verbose"), "{error:#}"); + assert_eq!( + std::fs::read(store.path()).expect("config after refused import"), + before, + "collision must be refused before any write" + ); + } + + #[test] + fn dry_run_semantics_plan_never_mutates() { + let store = isolated_store(); + let before = std::fs::read_to_string(store.path()).expect("read config"); + let bundle = sample_bundle(); + let _plan = plan_import(&bundle, &store.config, BundleScope::Global); + let after = std::fs::read_to_string(store.path()).expect("read config"); + assert_eq!(before, after, "planning must not write"); + } + + #[test] + fn apply_is_idempotent_on_reimport() { + let mut store = isolated_store(); + let workspace = tempfile::tempdir().expect("workspace"); + let bundle = sample_bundle(); + + let first = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path()) + .expect("first import"); + assert!(first.plan.added.len() + first.plan.changed.len() > 0); + + let second = apply_bundle(&bundle, &mut store, BundleScope::Global, workspace.path()) + .expect("second import"); + assert!( + second.plan.is_no_op(), + "re-import must be a no-op: {:?}", + second.plan + ); + assert!(second.backup_path.is_none()); + } + + #[test] + fn immediate_mutating_imports_create_distinct_no_clobber_backups() { + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + let original = b"verbosity = \"quiet\"\n"; + std::fs::write(&path, original).expect("seed config"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) + .expect("restrict target permissions"); + } + let mut store = ConfigStore::load(Some(path.clone())).expect("store loads"); + let first_bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +verbosity = "verbose" +"#, + "first.toml", + ) + .expect("first bundle parses"); + let first = apply_bundle(&first_bundle, &mut store, BundleScope::Global, dir.path()) + .expect("first import"); + let first_backup = first.backup_path.expect("first backup receipt"); + assert_eq!( + std::fs::read(&first_backup).expect("first backup"), + original + ); + let after_first = std::fs::read(&path).expect("target after first import"); + + let second_bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +output_mode = "plain" +"#, + "second.toml", + ) + .expect("second bundle parses"); + let second = apply_bundle(&second_bundle, &mut store, BundleScope::Global, dir.path()) + .expect("second import"); + let second_backup = second.backup_path.expect("second backup receipt"); + assert_ne!(first_backup, second_backup, "backups must never collide"); + assert_eq!( + std::fs::read(&second_backup).expect("second backup"), + after_first, + "second receipt must preserve its exact pre-import document" + ); + assert_eq!( + std::fs::read(&first_backup).expect("first backup remains"), + original, + "second import must not overwrite the first receipt" + ); + for backup in [&first_backup, &second_backup] { + assert!( + backup + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.contains(".bundle-backup-")), + "unexpected backup name: {}", + backup.display() + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + assert_eq!( + std::fs::metadata(backup) + .expect("backup metadata") + .permissions() + .mode() + & 0o777, + 0o600, + "backup must preserve restrictive target permissions" + ); + } + } + } + + #[test] + fn non_no_op_import_creates_a_missing_config_without_a_backup() { + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + let mut store = ConfigStore::load(Some(path.clone())).expect("missing config loads"); + assert!(!path.exists(), "load must not create the config"); + + let receipt = apply_bundle( + &sample_bundle(), + &mut store, + BundleScope::Global, + dir.path(), + ) + .expect("import creates config"); + + assert!(path.is_file(), "non-no-op import must create the config"); + assert!( + receipt.backup_path.is_none(), + "no prior file means no backup" + ); + let reloaded = ConfigStore::load(Some(path)).expect("created config reloads"); + assert_eq!(reloaded.config.verbosity.as_deref(), Some("quiet")); + assert_eq!(reloaded.config.output_mode.as_deref(), Some("plain")); + } + + #[test] + fn failed_import_removes_a_config_created_during_the_transaction() { + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + let mut store = ConfigStore::load(Some(path.clone())).expect("missing config loads"); + + let error = apply_bundle_with( + &sample_bundle(), + &mut store, + BundleScope::Global, + dir.path(), + |bundle, store, scope, workspace, target_written| { + apply_entries(bundle, store, scope, workspace, target_written)?; + bail!("forced failure after the new document was saved") + }, + ) + .expect_err("forced post-save failure must roll back"); + + assert!(error.to_string().contains("rolled back"), "{error:#}"); + assert!( + !path.exists(), + "rollback must remove the newly-created file" + ); + assert_eq!( + store.config.verbosity, None, + "in-memory state also rolls back" + ); + } + + #[test] + fn project_scope_never_touches_the_global_document() { + let mut store = isolated_store(); + let global_before = std::fs::read_to_string(store.path()).expect("global doc"); + + let text = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[project] +approval_policy = "unless-allowed" +"#; + let bundle = parse_bundle_str(text, "t.toml").expect("bundle"); + let ws = tempfile::tempdir().expect("ws"); + apply_bundle(&bundle, &mut store, BundleScope::Project, ws.path()) + .expect_err("project entries cannot land in a global-scoped store"); + let global_after = std::fs::read_to_string(store.path()).expect("global doc"); + assert_eq!(global_before, global_after); + } + + #[cfg(unix)] + #[test] + fn failed_apply_rolls_back_to_the_prior_document() { + let mut store = isolated_store(); + let original = std::fs::read_to_string(store.path()).expect("config"); + + // A bundle whose entry fails mid-apply: `providers.deepseek.wire` is a + // real key path but an invalid value for it, so set_value errors after + // earlier entries were applied. + let text = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences] +log_level = "debug" + +[global] +providers_deepseek_wire = "not-a-real-key-so-this-errors" +"#; + let _ = text; + // Simpler deterministic failure: make the target file read-only. + let text_ok = r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences] +log_level = "debug" +"#; + let bundle = parse_bundle_str(text_ok, "t.toml").expect("bundle"); + let path = store.path().to_path_buf(); + // Atomic saves replace the file via rename, so the *directory* must + // be made unwritable to force the write failure. + use std::os::unix::fs::PermissionsExt; + let dir = path.parent().expect("config dir").to_path_buf(); + let mut perms = std::fs::metadata(&dir).expect("dir meta").permissions(); + perms.set_mode(0o555); + std::fs::set_permissions(&dir, perms).expect("chmod dir"); + + let result = apply_bundle(&bundle, &mut store, BundleScope::Global, Path::new(".")); + // Restore permissions so the tempdir can be cleaned up. + let mut perms = std::fs::metadata(&dir).expect("dir meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&dir, perms).expect("chmod restore"); + + assert!(result.is_err(), "apply must fail on a read-only document"); + let restored = std::fs::read_to_string(&path).expect("config after rollback"); + assert_eq!(restored, original, "rollback must preserve the prior bytes"); + } + + #[test] + fn export_is_deterministic_and_secret_free() { + let mut store = isolated_store(); + store + .config + .set_value("verbosity", "quiet") + .expect("set verbosity"); + store + .config + .set_value("default_text_model", "deepseek-v4-pro") + .expect("set model"); + store.save().expect("save"); + + let metadata = BundleMetadata::default(); + let one = export_bundle(&store.config, BundleScope::Global, metadata.clone()) + .and_then(|b| serialize_bundle(&b)) + .expect("export one"); + let two = export_bundle(&store.config, BundleScope::Global, metadata) + .and_then(|b| serialize_bundle(&b)) + .expect("export two"); + assert_eq!(one, two, "export must be deterministic"); + + // No machine-specific absolute paths in the body. + assert!(!one.contains("/Users/"), "{one}"); + assert!(!one.contains("/home/"), "{one}"); + } + + #[test] + fn export_preserves_typed_structured_config_and_toml_value_kinds() { + let config: ConfigToml = toml::from_str( + r#" +provider = "deepseek" +telemetry = false +retry_count = 3 +ratio = 1.25 +started_at = 1979-05-27T07:32:00Z +labels = ["alpha", "beta"] + +[skills] +registry_url = "https://registry.example/skills.json" +max_install_size_bytes = 12345 + +[snapshots] +enabled = false +max_age_days = 11 + +[portable_table] +enabled = true +count = 4 + +[[harness_profiles]] +provider_route = "deepseek" +model_pattern = "deepseek-v4-*" + +[harness_profiles.posture] +kind = "custom" +max_subagents = 3 +prefer_codebase_search = true +compaction_strategy = "prefix-cache" +tool_surface = "read-only" +safety_posture = "strict" +"#, + ) + .expect("typed config parses"); + + let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("typed export"); + assert!(matches!( + bundle.profiles.entries.get("harness_profiles"), + Some(toml::Value::Array(_)) + )); + assert!(matches!( + bundle.preferences.entries.get("skills"), + Some(toml::Value::Table(_)) + )); + assert!(matches!( + bundle.preferences.entries.get("snapshots"), + Some(toml::Value::Table(_)) + )); + assert!(matches!( + bundle.global.entries.get("telemetry"), + Some(toml::Value::Boolean(false)) + )); + assert!(matches!( + bundle.global.entries.get("retry_count"), + Some(toml::Value::Integer(3)) + )); + assert!(matches!( + bundle.global.entries.get("ratio"), + Some(toml::Value::Float(value)) if *value == 1.25 + )); + assert!( + matches!( + bundle.global.entries.get("started_at"), + Some(toml::Value::Datetime(_)) + ), + "{bundle:#?}" + ); + + let dir = tempfile::tempdir().expect("round-trip dir"); + let path = dir.path().join("config.toml"); + let mut store = ConfigStore::load(Some(path.clone())).expect("fresh store"); + apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect("typed bundle imports"); + let reloaded = ConfigStore::load(Some(path)).expect("typed config reloads"); + let reexported = export_bundle( + &reloaded.config, + BundleScope::Global, + BundleMetadata::default(), + ) + .expect("round-trip export"); + assert_eq!( + serialize_bundle(&reexported).expect("serialize round trip"), + serialize_bundle(&bundle).expect("serialize original"), + "typed portable config must round-trip without stringification or loss" + ); + let plan = plan_import(&bundle, &reloaded.config, BundleScope::Global); + assert!( + plan.is_no_op(), + "typed re-import must be idempotent: {plan:?}" + ); + } + + #[test] + fn typed_reimport_normalizes_omitted_serde_defaults_before_comparison() { + let bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[preferences.snapshots] +enabled = false +"#, + "defaults.toml", + ) + .expect("bundle with omitted typed default"); + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + let mut store = ConfigStore::load(Some(path)).expect("fresh store"); + apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect("first typed import"); + + assert_eq!( + store + .config + .snapshots + .as_ref() + .expect("snapshots configured") + .max_age_days, + 7, + "serde default must be materialized" + ); + let plan = plan_import(&bundle, &store.config, BundleScope::Global); + assert!( + plan.is_no_op(), + "normalized re-import must be a no-op: {plan:?}" + ); + } + + #[test] + fn telemetry_opt_out_round_trips_but_opt_in_consent_never_does() { + let opted_in = ConfigToml { + telemetry: Some(true), + ..ConfigToml::default() + }; + let exported = export_bundle(&opted_in, BundleScope::Global, BundleMetadata::default()) + .expect("opt-in export is safely omitted"); + assert!( + !exported.global.entries.contains_key("telemetry"), + "opt-in consent must not be portable: {exported:?}" + ); + + let opt_in_bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +telemetry = true +"#, + "telemetry-opt-in.toml", + ) + .expect("opt-in bundle parses before policy validation"); + let rejected = find_rejected_entries(&opt_in_bundle); + assert_eq!(rejected.len(), 1, "{rejected:?}"); + assert!( + rejected[0].reason.contains("opt-in consent"), + "{rejected:?}" + ); + + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config"); + let before = std::fs::read(&path).expect("config before refusal"); + let mut store = ConfigStore::load(Some(path.clone())).expect("store loads"); + apply_bundle(&opt_in_bundle, &mut store, BundleScope::Global, dir.path()) + .expect_err("portable opt-in consent must be refused"); + assert_eq!(std::fs::read(&path).expect("config after refusal"), before); + assert_eq!(store.config.telemetry, None); + + let opt_out_bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +telemetry = false +"#, + "telemetry-opt-out.toml", + ) + .expect("opt-out bundle parses"); + assert!(find_rejected_entries(&opt_out_bundle).is_empty()); + apply_bundle(&opt_out_bundle, &mut store, BundleScope::Global, dir.path()) + .expect("portable opt-out applies"); + assert_eq!(store.config.telemetry, Some(false)); + let reloaded = ConfigStore::load(Some(path)).expect("opt-out config reloads"); + let plan = plan_import(&opt_out_bundle, &reloaded.config, BundleScope::Global); + assert!( + plan.is_no_op(), + "opt-out re-import must be idempotent: {plan:?}" + ); + let reexported = export_bundle( + &reloaded.config, + BundleScope::Global, + BundleMetadata::default(), + ) + .expect("opt-out re-exports"); + assert_eq!( + reexported.global.entries.get("telemetry"), + Some(&toml::Value::Boolean(false)) + ); + } + + #[test] + fn structured_import_deep_merges_without_erasing_local_authority() { + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + let api_key_name = ["api", "_key"].concat(); + let api_key_env_name = ["api", "_key_env"].concat(); + let target = format!( + r#" +provider = "acme_gateway" + +[providers.acme_gateway] +kind = "openai-compatible" +base_url = "https://local-only.invalid/v1" +model = "old-model" +{api_key_name} = "opaque-local-api-value" +{api_key_env_name} = "LOCAL_ACME_GATEWAY_KEY" + +[providers.acme_gateway.auth] +source = "command" +command = ["/synthetic/local-credential-helper"] + +[lsp] +enabled = true +include_warnings = false + +[lsp.servers] +rust = ["/synthetic/local-rust-analyzer", "--stdio"] + +[lsp.custom.foo] +language_id = "foo-language" +command = "/synthetic/local-foo-lsp" +args = ["--stdio"] + +[hook_sinks] +unix_socket_path = "/synthetic/local-codewhale.sock" +"# + ); + std::fs::write(&path, target).expect("seed local-authority config"); + let mut store = ConfigStore::load(Some(path.clone())).expect("target config loads"); + let bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +provider = "acme_gateway" + +[global.providers.acme_gateway] +kind = "openai-compatible" +model = "new-portable-model" + +[global.lsp] +enabled = false +include_warnings = true +"#, + "deep-merge.toml", + ) + .expect("portable update parses"); + assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}"); + + let receipt = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect("portable values merge into target"); + assert_eq!( + receipt.plan.changed, + ["global.lsp", "global.providers"], + "{:?}", + receipt.plan + ); + assert_eq!( + receipt.plan.skipped, + ["global.provider"], + "{:?}", + receipt.plan + ); + assert_eq!(store.config.provider_id(), "acme_gateway"); + let document = config_document(&store.config).expect("merged typed document"); + let acme = document + .get("providers") + .and_then(toml::Value::as_table) + .and_then(|providers| providers.get("acme_gateway")) + .and_then(toml::Value::as_table) + .expect("custom provider survives"); + assert_eq!( + acme.get("model").and_then(toml::Value::as_str), + Some("new-portable-model") + ); + assert_eq!( + acme.get("base_url").and_then(toml::Value::as_str), + Some("https://local-only.invalid/v1") + ); + assert_eq!( + acme.get("api_key").and_then(toml::Value::as_str), + Some("opaque-local-api-value") + ); + assert_eq!( + acme.get("api_key_env").and_then(toml::Value::as_str), + Some("LOCAL_ACME_GATEWAY_KEY") + ); + assert_eq!( + acme.get("auth") + .and_then(toml::Value::as_table) + .and_then(|auth| auth.get("command")) + .and_then(toml::Value::as_array) + .and_then(|command| command.first()) + .and_then(toml::Value::as_str), + Some("/synthetic/local-credential-helper") + ); + let lsp = store.config.lsp.as_ref().expect("LSP config survives"); + assert_eq!(lsp.enabled, Some(false)); + assert_eq!(lsp.include_warnings, Some(true)); + assert!(lsp.servers.as_ref().is_some_and(|servers| { + servers + .get("rust") + .is_some_and(|command| command.first().is_some_and(|part| part.contains("rust"))) + })); + assert!( + lsp.custom + .as_ref() + .is_some_and(|custom| custom.contains_key("foo")) + ); + assert_eq!( + store + .config + .hook_sinks + .as_ref() + .and_then(|sinks| sinks.unix_socket_path.as_deref()), + Some(Path::new("/synthetic/local-codewhale.sock")) + ); + + let reloaded = ConfigStore::load(Some(path)).expect("merged config reloads"); + assert_eq!(reloaded.config.provider_id(), "acme_gateway"); + let plan = plan_import(&bundle, &reloaded.config, BundleScope::Global); + assert!( + plan.is_no_op(), + "deep-merged re-import must be idempotent: {plan:?}" + ); + } + + #[test] + fn export_recursively_drops_nested_secrets_but_keeps_safe_typed_siblings() { + let provider_prefix = ["s", "k-"].concat(); + let bearer_prefix = ["Bear", "er "].concat(); + let access_key = ["access", "Token"].concat(); + let dotted_refresh_key = ["service.refresh", "Token"].concat(); + let refresh_key = ["refresh", "Token"].concat(); + let fixture = format!( + r#" +[tools] +always_load = ["read_file", "{provider_prefix}nested-tool-value-must-not-leak", "write_file"] + +[portable] +safe_count = 7 +note = "{bearer_prefix}nested-export-value-must-not-leak" +values = ["plain", "{provider_prefix}nested-array-value-must-not-leak"] + +[portable.nested] +{access_key} = "nested-export-key-must-not-leak" +"{dotted_refresh_key}" = "nested-dotted-value-must-not-leak" +label = "keep-me" + +[[portable.records]] +{refresh_key} = "nested-record-value-must-not-leak" +count = 2 + +[[portable.records]] +label = "safe-record" +"# + ); + let config: ConfigToml = toml::from_str(&fixture).expect("secret-bearing config parses"); + let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("safe export"); + let body = serialize_bundle(&bundle).expect("serialize export"); + for secret in [ + "nested-export-value-must-not-leak", + "nested-array-value-must-not-leak", + "nested-tool-value-must-not-leak", + "nested-export-key-must-not-leak", + "nested-dotted-value-must-not-leak", + "nested-record-value-must-not-leak", + ] { + assert!(!body.contains(secret), "export leaked {secret}: {body}"); + } + assert!(body.contains("safe_count = 7"), "{body}"); + assert!(body.contains("label = \"keep-me\""), "{body}"); + assert!(body.contains("label = \"safe-record\""), "{body}"); + assert!(body.contains("values = [\"plain\"]"), "{body}"); + assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}"); + + let dir = tempfile::tempdir().expect("sanitized import dir"); + let path = dir.path().join("config.toml"); + let mut store = ConfigStore::load(Some(path)).expect("fresh store"); + apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect("sanitized typed arrays re-import"); + assert_eq!( + store + .config + .tools + .as_ref() + .expect("tools preserved") + .always_load + .as_slice(), + ["read_file", "write_file"], + "dropping a secret array element must preserve a valid typed array" + ); + let plan = plan_import(&bundle, &store.config, BundleScope::Global); + assert!( + plan.is_no_op(), + "sanitized re-import must be idempotent: {plan:?}" + ); + } + + #[test] + fn compound_access_and_cookie_fields_are_scrubbed_on_export() { + let cookie = ["Coo", "kie"].concat(); + let set_cookie = ["Set-", "Cookie"].concat(); + let api_dot = ["api", ".key"].concat(); + let private_dot = ["private", ".key"].concat(); + let access_camel = ["access", "Key"].concat(); + let aws_snake = ["aws", "_access_key"].concat(); + let aws_camel = ["aws", "SecretAccessKey"].concat(); + let fixture = format!( + r#" +[http_headers] +{cookie} = "opaque-cookie-value" +{set_cookie} = "opaque-set-cookie-value" +X-Safe = "portable-header" + +[portable] +"{api_dot}" = "opaque-api-value" +"{private_dot}" = "opaque-private-value" +{access_camel} = "opaque-access-value" +{aws_snake} = "opaque-aws-access-value" +{aws_camel} = "opaque-aws-secret-access-value" +maxTokens = 8192 +tokenizer = "bpe" +"# + ); + let config: ConfigToml = toml::from_str(&fixture).expect("credential-key config parses"); + let bundle = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("credential fields are scrubbed"); + let body = serialize_bundle(&bundle).expect("serialize scrubbed export"); + for forbidden in [ + "opaque-cookie-value", + "opaque-set-cookie-value", + "opaque-api-value", + "opaque-private-value", + "opaque-access-value", + "opaque-aws-access-value", + "opaque-aws-secret-access-value", + "api.key", + "private.key", + "accessKey", + "aws_access_key", + "awsSecretAccessKey", + "Cookie", + "Set-Cookie", + ] { + assert!( + !body.contains(forbidden), + "export retained {forbidden}: {body}" + ); + } + assert!(body.contains("X-Safe = \"portable-header\""), "{body}"); + assert!(body.contains("maxTokens = 8192"), "{body}"); + assert!(body.contains("tokenizer = \"bpe\""), "{body}"); + assert!(find_rejected_entries(&bundle).is_empty(), "{bundle:?}"); + } + + #[test] + fn provider_credential_authority_is_rejected_on_import_and_scrubbed_on_export() { + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config"); + let before = std::fs::read(&path).expect("config before imports"); + let mut store = ConfigStore::load(Some(path.clone())).expect("store loads"); + let api_key_env_name = ["api", "_key_env"].concat(); + + for (name, body) in [ + ( + "auth", + r#" +[global.providers.xai.auth] +source = "command" +command = ["synthetic-credential-helper"] +"# + .to_string(), + ), + ( + "external", + r#" +[global.providers.xai.external_credentials] +access = "read_only" +provider = "xai" +source = "grok_cli" +path = "/synthetic/external/auth.json" +consent_version = 1 +"# + .to_string(), + ), + ( + "oauth-generation", + r#" +[global.providers.xai] +oauth_credential_generation = "synthetic-owned-generation.toml" +"# + .to_string(), + ), + ( + "api-key-env", + format!( + r#" +[global.providers.xai] +{api_key_env_name} = "SYNTHETIC_RANDOM_PROVIDER_KEY" +"# + ), + ), + ] { + let text = format!("schema_version = 1\nkind = \"codewhale.portable-config\"\n{body}"); + let bundle = parse_bundle_str(&text, name).expect("authority bundle parses"); + assert_eq!(find_rejected_entries(&bundle).len(), 1, "{name}"); + let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect_err("authority-bearing import must fail"); + assert!( + error.to_string().contains("conflicting or rejected"), + "{name}: {error:#}" + ); + assert_eq!(std::fs::read(&path).expect("config after refusal"), before); + assert_eq!(store.config.verbosity.as_deref(), Some("quiet")); + } + + let fixture = format!( + r#" +provider = "xai" + +[providers.xai] +model = "grok-safe-model" +oauth_credential_generation = "synthetic-owned-generation.toml" +{api_key_env_name} = "SYNTHETIC_RANDOM_PROVIDER_KEY" + +[providers.xai.auth] +source = "command" +command = ["synthetic-credential-helper"] + +[providers.xai.external_credentials] +access = "read_only" +provider = "xai" +source = "grok_cli" +path = "/synthetic/external/auth.json" +consent_version = 1 +"# + ); + let config: ConfigToml = + toml::from_str(&fixture).expect("provider authority config parses"); + let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("provider authority is scrubbed"); + let body = serialize_bundle(&exported).expect("serialize provider export"); + for forbidden in [ + "external_credentials", + "oauth_credential_generation", + "synthetic-credential-helper", + "synthetic-owned-generation.toml", + "SYNTHETIC_RANDOM_PROVIDER_KEY", + api_key_env_name.as_str(), + "/synthetic/external/auth.json", + ] { + assert!( + !body.contains(forbidden), + "export retained {forbidden}: {body}" + ); + } + assert!(body.contains("model = \"grok-safe-model\""), "{body}"); + assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}"); + } + + #[test] + fn machine_local_route_and_path_fields_are_rejected_and_scrubbed_symmetrically() { + let bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +telemetry_endpoint = "https://synthetic.invalid/telemetry" +mcpConfigPath = "/synthetic/import-mcp.json" + +[global.providers.deepseek] +baseUrl = "https://synthetic.invalid/provider/v1" +model = "safe-model" + +[global.hook_sinks] +unix_socket_path = "/synthetic/import-codewhale.sock" +"#, + "machine-local-paths.toml", + ) + .expect("machine-local bundle parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 4, "{rejected:?}"); + for key in [ + "global.telemetry_endpoint", + "global.mcpConfigPath", + "global.providers", + "global.hook_sinks", + ] { + assert!( + rejected.iter().any(|entry| entry.key == key), + "{rejected:?}" + ); + } + + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config"); + let before = std::fs::read(&path).expect("config before import"); + let mut store = ConfigStore::load(Some(path.clone())).expect("store loads"); + let error = apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect_err("machine-local paths must refuse the entire import"); + assert!( + error.to_string().contains("conflicting or rejected"), + "{error:#}" + ); + assert_eq!(std::fs::read(&path).expect("config after refusal"), before); + assert_eq!(store.config.verbosity.as_deref(), Some("quiet")); + + let config: ConfigToml = toml::from_str( + r#" +telemetry_endpoint = "https://synthetic.invalid/telemetry" +mcp_config_path = "/synthetic/export-mcp.json" + +[providers.deepseek] +base_url = "https://synthetic.invalid/provider/v1" +model = "safe-model" + +[hook_sinks] +unix_socket_path = "/synthetic/export-codewhale.sock" +"#, + ) + .expect("machine-local config parses"); + let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("machine-local paths are scrubbed"); + let body = serialize_bundle(&exported).expect("serialize machine-local export"); + for forbidden in [ + "synthetic.invalid", + "/synthetic/export-mcp.json", + "/synthetic/export-codewhale.sock", + "telemetry_endpoint", + "mcp_config_path", + "unix_socket_path", + "base_url", + ] { + assert!( + !body.contains(forbidden), + "export retained {forbidden}: {body}" + ); + } + assert!(body.contains("model = \"safe-model\""), "{body}"); + assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}"); + } + + #[test] + fn remaining_local_authority_is_rejected_while_safe_policy_stays_portable() { + for safe in [ + "databaseUrl", + "baseUrlTemplate", + "memoryPathology", + "sandboxUrlTemplate", + "skills.registry_url", + "network.allow", + "workflow.automatic", + "fleet.exec.allowed_tools", + ] { + assert_eq!(nonportable_path_reason(safe), None, "must preserve {safe}"); + } + + let bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +instructions = "/synthetic/import-instructions.md" +project_instruction_imports = "all" +projectInstructionImports = "all" +sandbox_backend = "synthetic-local-backend" +sandboxUrl = "http://127.0.0.1:47891" +bwrapRoRoots = ["/synthetic/import-ro"] +bwrap_dev_roots = ["/synthetic/import-dev"] +skills_dir = "/synthetic/import-skills" +memoryPath = "/synthetic/import-memory.md" +mcpOauthCallbackUrl = "http://127.0.0.1:47892/callback" +mcp_oauth_callback_port = 47892 +notes_path = "/synthetic/import-notes.md" + +[global.runtime_api] +bind = "127.0.0.1:47893" + +[global.auto_review] +allow = ["synthetic-shell-action"] + +[global.tools] +always_load = ["read_file"] +plugin_dir = "/synthetic/import-plugins" + +[global.tools.overrides.shell] +command = "/synthetic/import-tool-override" + +[global.update] +channel = "stable" +update_uri = "file:///synthetic/import-update" + +[global.notifications] +enabled = true +sound_file = "/synthetic/import-sound.wav" + +[global.speech] +enabled = true +output_dir = "/synthetic/import-speech" + +[global.skills] +registry_url = "https://registry.example/skills.json" + +[global.network] +default = "prompt" +allow = ["registry.example"] + +[global.workflow] +automatic = false + +[global.fleet.exec] +allowed_tools = ["read_file"] +"#, + "remaining-local-authority.toml", + ) + .expect("remaining authority bundle parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 18, "{rejected:?}"); + for safe in [ + "global.skills", + "global.network", + "global.workflow", + "global.fleet", + ] { + assert!( + rejected.iter().all(|entry| entry.key != safe), + "safe entry {safe} was rejected: {rejected:?}" + ); + } + + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config"); + let before = std::fs::read(&path).expect("config before import"); + let mut store = ConfigStore::load(Some(path.clone())).expect("store loads"); + apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect_err("remaining local authority import must fail"); + assert_eq!(std::fs::read(&path).expect("config after refusal"), before); + assert_eq!(store.config.verbosity.as_deref(), Some("quiet")); + + let config: ConfigToml = toml::from_str( + r#" +instructions = "/synthetic/export-instructions.md" +project_instruction_imports = "all" +projectInstructionImports = "all" +sandbox_backend = "synthetic-local-backend" +sandboxUrl = "http://127.0.0.1:47894" +bwrapRoRoots = ["/synthetic/export-ro"] +bwrap_dev_roots = ["/synthetic/export-dev"] +skills_dir = "/synthetic/export-skills" +memoryPath = "/synthetic/export-memory.md" +mcpOauthCallbackUrl = "http://127.0.0.1:47895/callback" +mcp_oauth_callback_port = 47895 +notes_path = "/synthetic/export-notes.md" + +[runtime_api] +bind = "127.0.0.1:47896" + +[auto_review] +allow = ["synthetic-shell-action"] + +[tools] +always_load = ["read_file"] +plugin_dir = "/synthetic/export-plugins" + +[tools.overrides.shell] +command = "/synthetic/export-tool-override" + +[update] +channel = "stable" +update_uri = "file:///synthetic/export-update" + +[notifications] +enabled = true +sound_file = "/synthetic/export-sound.wav" + +[speech] +enabled = true +output_dir = "/synthetic/export-speech" + +[skills] +registry_url = "https://registry.example/skills.json" +max_install_size_bytes = 12345 + +[network] +default = "prompt" +allow = ["registry.example"] + +[workflow] +automatic = false + +[fleet.exec] +allowed_tools = ["read_file"] +"#, + ) + .expect("remaining authority config parses"); + let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("remaining authority is scrubbed"); + for key in [ + "instructions", + "project_instruction_imports", + "projectInstructionImports", + "sandbox_backend", + "sandboxUrl", + "bwrapRoRoots", + "bwrap_dev_roots", + "skills_dir", + "memoryPath", + "mcpOauthCallbackUrl", + "mcp_oauth_callback_port", + "notes_path", + "runtime_api", + "auto_review", + ] { + assert!(!exported.global.entries.contains_key(key), "retained {key}"); + } + let body = serialize_bundle(&exported).expect("serialize safe policy export"); + for forbidden in [ + "/synthetic/export-instructions.md", + "/synthetic/export-ro", + "/synthetic/export-dev", + "/synthetic/export-skills", + "/synthetic/export-memory.md", + "/synthetic/export-notes.md", + "/synthetic/export-sound.wav", + "/synthetic/export-speech", + "file:///synthetic/export-update", + "127.0.0.1:47896", + ] { + assert!( + !body.contains(forbidden), + "export retained {forbidden}: {body}" + ); + } + for safe in [ + "https://registry.example/skills.json", + "registry.example", + "automatic = false", + "allowed_tools = [\"read_file\"]", + "channel = \"stable\"", + "enabled = true", + ] { + assert!(body.contains(safe), "export lost {safe}: {body}"); + } + + // ToolsToml currently ignores these legacy fields while parsing, so + // exercise the recursive export sanitizer directly as defense in depth. + let raw: toml::Value = toml::from_str( + r#" +[tools] +always_load = ["read_file"] +plugin_dir = "/synthetic/direct-plugin-dir" + +[tools.overrides.shell] +command = "/synthetic/direct-tool-override" +"#, + ) + .expect("raw tools table parses"); + let scrubbed = + sanitize_export_value("tools", raw.get("tools").expect("raw tools table exists")) + .expect("safe tools sibling remains"); + let scrubbed = scrubbed.to_string(); + assert!(scrubbed.contains("read_file"), "{scrubbed}"); + assert!(!scrubbed.contains("plugin_dir"), "{scrubbed}"); + assert!(!scrubbed.contains("overrides"), "{scrubbed}"); + assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}"); + } + + #[test] + fn lsp_executable_authority_is_rejected_while_inert_settings_remain_portable() { + let config: ConfigToml = toml::from_str( + r#" +[lsp] +enabled = true +poll_after_edit_ms = 250 +max_diagnostics_per_file = 12 +include_warnings = true + +[lsp.servers] +rust = ["/synthetic/rust-analyzer", "--stdio"] + +[lsp.custom.foo] +language_id = "foo-language" +command = "/synthetic/foo-language-server" +args = ["--stdio", "--synthetic"] +"#, + ) + .expect("LSP config parses"); + let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("LSP executable authority is scrubbed"); + let body = serialize_bundle(&exported).expect("serialize LSP export"); + for forbidden in [ + "/synthetic/rust-analyzer", + "/synthetic/foo-language-server", + "foo-language", + "--stdio", + "--synthetic", + ] { + assert!( + !body.contains(forbidden), + "export retained {forbidden}: {body}" + ); + } + for inert in [ + "enabled = true", + "poll_after_edit_ms = 250", + "max_diagnostics_per_file = 12", + "include_warnings = true", + ] { + assert!(body.contains(inert), "export lost {inert}: {body}"); + } + assert!(find_rejected_entries(&exported).is_empty(), "{exported:?}"); + + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config"); + let before = std::fs::read(&path).expect("config before imports"); + let mut store = ConfigStore::load(Some(path.clone())).expect("store loads"); + for (name, body) in [ + ( + "servers", + r#" +[global.lsp] +enabled = true + +[global.lsp.servers] +rust = ["/synthetic/import-rust-analyzer", "--stdio"] +"#, + ), + ( + "custom", + r#" +[global.lsp] +include_warnings = true + +[global.lsp.custom.foo] +language_id = "foo-language" +command = "/synthetic/import-foo-server" +args = ["--stdio"] +"#, + ), + ] { + let text = format!("schema_version = 1\nkind = \"codewhale.portable-config\"\n{body}"); + let bundle = parse_bundle_str(&text, name).expect("LSP bundle parses"); + assert_eq!(find_rejected_entries(&bundle).len(), 1, "{name}"); + apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect_err("LSP executable authority import must fail"); + assert_eq!(std::fs::read(&path).expect("config after refusal"), before); + assert_eq!(store.config.verbosity.as_deref(), Some("quiet")); + } + } + + #[test] + fn machine_bound_authority_subtrees_are_rejected_and_never_exported() { + let config: ConfigToml = toml::from_str( + r#" +managed_config_path = "/synthetic/managed-config.toml" +requirements_path = "/synthetic/requirements.md" + +[workspace] +root = "/synthetic/workspace-root" +trust = "trusted" +allow_shell = true + +[projects."/synthetic/project-root"] +trust = "trusted" +allow_shell = true + +[hooks.session_start] +command = "/synthetic/session-start" + +[portable.workspace] +label = "safe-nested-workspace-label" + +[portable.projects] +label = "safe-nested-projects-label" + +[portable.hooks] +label = "safe-nested-hooks-label" +"#, + ) + .expect("machine-bound authority config parses"); + let exported = export_bundle(&config, BundleScope::Global, BundleMetadata::default()) + .expect("machine-bound authority is scrubbed"); + assert!(!exported.global.entries.contains_key("workspace")); + assert!(!exported.global.entries.contains_key("projects")); + assert!(!exported.global.entries.contains_key("hooks")); + assert!(!exported.global.entries.contains_key("managed_config_path")); + assert!(!exported.global.entries.contains_key("requirements_path")); + let body = serialize_bundle(&exported).expect("serialize machine-bound export"); + for path in [ + "/synthetic/workspace-root", + "/synthetic/project-root", + "/synthetic/session-start", + "/synthetic/managed-config.toml", + "/synthetic/requirements.md", + ] { + assert!(!body.contains(path), "export retained {path}: {body}"); + } + assert!(body.contains("safe-nested-workspace-label"), "{body}"); + assert!(body.contains("safe-nested-projects-label"), "{body}"); + assert!(body.contains("safe-nested-hooks-label"), "{body}"); + + let bundle = parse_bundle_str( + r#" +schema_version = 1 +kind = "codewhale.portable-config" + +[global] +managed_config_path = "/synthetic/import-managed-config.toml" +requirements_path = "/synthetic/import-requirements.md" + +[global.workspace] +root = "/synthetic/import-workspace" +trust = "trusted" +allow_shell = true + +[global.projects."/synthetic/import-project"] +trust = "trusted" +allow_shell = true + +[global.hooks.session_start] +command = "/synthetic/import-session-start" + +[global.portable.workspace] +label = "safe-nested-workspace-label" +"#, + "machine-bound-authority.toml", + ) + .expect("machine-bound authority bundle parses"); + let rejected = find_rejected_entries(&bundle); + assert_eq!(rejected.len(), 5, "{rejected:?}"); + assert!(rejected.iter().any(|entry| entry.key == "global.workspace")); + assert!(rejected.iter().any(|entry| entry.key == "global.projects")); + assert!(rejected.iter().any(|entry| entry.key == "global.hooks")); + assert!( + rejected + .iter() + .any(|entry| entry.key == "global.managed_config_path") + ); + assert!( + rejected + .iter() + .any(|entry| entry.key == "global.requirements_path") + ); + + let dir = tempfile::tempdir().expect("config dir"); + let path = dir.path().join("config.toml"); + std::fs::write(&path, "verbosity = \"quiet\"\n").expect("seed config"); + let before = std::fs::read(&path).expect("config before import"); + let mut store = ConfigStore::load(Some(path.clone())).expect("store loads"); + apply_bundle(&bundle, &mut store, BundleScope::Global, dir.path()) + .expect_err("machine-bound authority import must fail"); + assert_eq!(std::fs::read(path).expect("config after refusal"), before); + assert_eq!(store.config.verbosity.as_deref(), Some("quiet")); + } + + #[test] + fn bundle_scope_must_match_the_target_for_import_and_export() { + let dir = tempfile::tempdir().expect("config dir"); + let global_path = dir.path().join("config.toml"); + let error = validate_scope_target(BundleScope::Project, &global_path) + .expect_err("global path cannot masquerade as project scope"); + assert!(error.to_string().contains("workspace config"), "{error:#}"); + validate_scope_target(BundleScope::Global, &global_path) + .expect("global config accepts global scope"); + + let project_path = dir.path().join(".codewhale").join("config.toml"); + std::fs::create_dir(dir.path().join(".git")).expect("checkout marker"); + validate_scope_target(BundleScope::Project, &project_path) + .expect("workspace config accepts project scope"); + let error = validate_scope_target(BundleScope::Global, &project_path) + .expect_err("workspace path cannot masquerade as global scope"); + assert!(error.to_string().contains("--project"), "{error:#}"); + + let mut store = ConfigStore::load(Some(project_path)).expect("workspace store loads"); + let import = ImportArgs { + source: dir + .path() + .join("must-not-be-read.toml") + .display() + .to_string(), + dry_run: true, + yes: true, + project: false, + }; + let error = run_import(&import, &mut store, dir.path()) + .expect_err("global import must refuse a workspace config before reading input"); + assert!(error.to_string().contains("--project"), "{error:#}"); + + let output = dir.path().join("must-not-be-written.toml"); + let export = ExportArgs { + portable: true, + project: false, + out: Some(output.clone()), + }; + let error = run_export(&export, &store) + .expect_err("global export must refuse a workspace config before writing output"); + assert!(error.to_string().contains("--project"), "{error:#}"); + assert!(!output.exists(), "refused export must not create an output"); + } + + #[test] + fn exported_bundle_reimports_cleanly() { + let mut store = isolated_store(); + store.config.set_value("verbosity", "quiet").expect("set"); + store.save().expect("save"); + + let bundle = export_bundle( + &store.config, + BundleScope::Global, + BundleMetadata::default(), + ) + .expect("export"); + let rejected = find_rejected_entries(&bundle); + assert!( + rejected.is_empty(), + "export must be secret-free: {rejected:?}" + ); + + let plan = plan_import(&bundle, &store.config, BundleScope::Global); + assert!( + plan.rejected.is_empty() && plan.conflicting.is_empty(), + "own export must not trip rejection: {plan:?}" + ); + } + + #[test] + fn http_non_loopback_fetch_is_refused_without_network_access() { + let err = fetch_bundle("http://example.com/bundle.toml") + .expect_err("plain http to a public host must be refused"); + assert!(err.to_string().contains("loopback"), "{err:#}"); + assert!(!err.to_string().contains("example.com"), "{err:#}"); + } + + #[test] + fn redirect_to_non_loopback_http_is_refused_without_leaking_location() { + let secret = "location-secret-must-not-leak"; + let location = format!("http://example.com/internal?token={secret}"); + let response = http_response("302 Found", &[("Location", location.as_str())], b""); + let (url, server) = spawn_bundle_http_server(vec![response]); + + let error = fetch_bundle(&url).expect_err("redirect target must be revalidated"); + let rendered = format!("{error:#}"); + assert!(rendered.contains("loopback"), "{rendered}"); + assert!(!rendered.contains("example.com"), "{rendered}"); + assert!(!rendered.contains(secret), "{rendered}"); + assert_eq!(server.join().expect("server joins"), 1); + } + + #[test] + fn https_redirect_cannot_downgrade_to_loopback_http() { + let secret = "downgrade-secret-must-not-leak"; + let target = reqwest::Url::parse(&format!("http://127.0.0.1/internal?token={secret}")) + .expect("test target URL"); + + let error = validate_bundle_redirect("https", &target) + .expect_err("HTTPS redirect must not downgrade to loopback HTTP"); + let rendered = format!("{error:#}"); + assert!(rendered.contains("scheme"), "{rendered}"); + assert!(!rendered.contains(secret), "{rendered}"); + assert!(!rendered.contains("127.0.0.1"), "{rendered}"); + } + + #[test] + fn relative_loopback_redirect_fetches_bundle() { + let redirect = http_response("302 Found", &[("Location", "/bundle.toml")], b""); + let body = VALID_TOML.as_bytes(); + let success = http_response("200 OK", &[("Content-Type", "text/plain")], body); + let (url, server) = spawn_bundle_http_server(vec![redirect, success]); + + let fetched = fetch_bundle(&url).expect("relative redirect remains allowed"); + assert_eq!(fetched, body); + assert_eq!(server.join().expect("server joins"), 2); + } + + #[test] + fn redirect_limit_is_enforced_before_a_sixth_hop() { + let responses = (0..=MAX_REDIRECTS) + .map(|hop| { + let location = format!("/hop-{}", hop + 1); + http_response("302 Found", &[("Location", location.as_str())], b"") + }) + .collect(); + let (url, server) = spawn_bundle_http_server(responses); + + let error = fetch_bundle(&url).expect_err("sixth redirect must be refused"); + assert!(error.to_string().contains("five-redirect"), "{error:#}"); + assert_eq!(server.join().expect("server joins"), MAX_REDIRECTS + 1); + } + + #[test] + fn bundle_url_credentials_are_rejected_without_echoing_them() { + let secret = "credential-secret-must-not-leak"; + let error = fetch_bundle(&format!("https://user:{secret}@example.com/bundle.toml")) + .expect_err("URL userinfo must be refused"); + let rendered = format!("{error:#}"); + assert!(rendered.contains("credentials"), "{rendered}"); + assert!(!rendered.contains(secret), "{rendered}"); + assert!(!rendered.contains("example.com"), "{rendered}"); + } + + #[test] + fn unsupported_schemes_are_refused() { + let err = fetch_bundle("file:///etc/passwd").expect_err("file scheme refused"); + assert!(err.to_string().contains("scheme"), "{err:#}"); + } + + #[test] + fn headless_import_requires_yes() { + let plan = ImportPlan { + added: vec!["preferences.x".to_string()], + ..ImportPlan::default() + }; + // The test harness runs headless (no tty), so consent without --yes + // must refuse before any prompt. + let err = require_import_consent(false, &plan).expect_err("headless needs --yes"); + assert!(err.to_string().contains("--yes"), "{err:#}"); + require_import_consent(true, &plan).expect("--yes short-circuits consent"); + } + + #[test] + fn bounded_paths_refuse_traversal_and_absolute_escapes() { + let base = tempfile::tempdir().expect("base"); + let err = + resolve_bounded_path(base.path(), "../escape.toml").expect_err("traversal refused"); + assert!( + err.to_string().contains("escapes") || err.to_string().contains("absolute"), + "{err:#}" + ); + let absolute = base.path().join("absolute.toml"); + let err = resolve_bounded_path(base.path(), absolute.to_string_lossy().as_ref()) + .expect_err("absolute refused"); + assert!(err.to_string().contains("absolute"), "{err:#}"); + let ok = resolve_bounded_path(base.path(), "nested/thing.toml").expect("inside ok"); + assert!(ok.starts_with(base.path())); + } + + // -- helpers ------------------------------------------------------------ + + fn http_response(status: &str, headers: &[(&str, &str)], body: &[u8]) -> Vec { + let mut response = format!( + "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n", + body.len() + ) + .into_bytes(); + for (name, value) in headers { + response.extend_from_slice(format!("{name}: {value}\r\n").as_bytes()); + } + response.extend_from_slice(b"\r\n"); + response.extend_from_slice(body); + response + } + + fn spawn_bundle_http_server( + responses: Vec>, + ) -> (String, std::thread::JoinHandle) { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind HTTP fixture"); + let address = listener.local_addr().expect("fixture address"); + let handle = std::thread::spawn(move || { + let mut served = 0usize; + for response in responses { + let (mut stream, _) = listener.accept().expect("accept fixture request"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .expect("fixture 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 = stream.read(&mut chunk).expect("read fixture request"); + if read == 0 { + break; + } + request.extend_from_slice(&chunk[..read]); + } + stream.write_all(&response).expect("write fixture response"); + served += 1; + } + served + }); + (format!("http://{address}/start"), handle) + } + + /// A store over a config file that outlives the helper: the tempdir is + /// leaked deliberately (tests are short-lived; explicit cleanup would need + /// to thread the guard through every call site). + fn isolated_store() -> ConfigStore { + // Serialize with every other env-mutating test in this crate: a private + // lock here would still race `ScopedEnvVar` users (observed as a flaky + // credentials-dir failure in `api_key_config_failure_restores_*`). + let _guard = crate::tests::env_lock(); + + let dir = { + // TempDir::keep() is the non-deprecated ownership transfer. + let temp = tempfile::TempDir::new().expect("tempdir"); + temp.keep() + }; + let unique = dir.join("home").join(std::process::id().to_string()); + std::fs::create_dir_all(&unique).expect("unique home"); + // SAFETY: test-only env mutation, serialized by the lock above. + unsafe { std::env::set_var("CODEWHALE_HOME", &unique) }; + let path = unique.join("config.toml"); + std::fs::write(&path, "# test config\n").expect("seed config file"); + ConfigStore::load(Some(path)).expect("store loads") + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 89aa36d99e..ce34917515 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,6 +1,7 @@ #![allow(clippy::uninlined_format_args)] mod cloud; +mod config_bundles; mod credential_handoff; mod metrics; #[cfg(not(target_env = "ohos"))] @@ -12,7 +13,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; use anyhow::{Context, Result, anyhow, bail}; -use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; use codewhale_agent::ModelRegistry; use codewhale_app_server::{ @@ -385,9 +386,7 @@ New integrations should prefer `codewhale app-server`.")] after_help = "The browser receives a one-time loopback bootstrap capability, never the Runtime token.\nThe capability is exchanged for a bounded, process-local HttpOnly, SameSite=Strict web session and then invalidated." )] Web(WebArgs), - /// Generate shell completions. - Completions(TuiPassthroughArgs), - /// Configure provider credentials. + /// Sign in to your Codewhale account (browser device flow). Login(LoginArgs), /// Remove saved authentication state. Logout, @@ -421,7 +420,11 @@ is read from --auth-token, CODEWHALE_RUNTIME_TOKEN, or DEEPSEEK_RUNTIME_TOKEN. See docs/RUNTIME_API.md.")] AppServer(AppServerArgs), /// Generate shell completions. - #[command(after_help = r#"Examples: + #[command( + visible_alias = "completions", + after_help = r#"Every script completes both `codewhale` and the `codew` shorthand. + +Examples: Bash (current shell only): source <(codewhale completion bash) @@ -444,7 +447,15 @@ See docs/RUNTIME_API.md.")] PowerShell (current shell only): codewhale completion powershell | Out-String | Invoke-Expression -The command prints the completion script to stdout; redirect it to a path your shell loads automatically."#)] + PowerShell (persistent): + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $PROFILE) + codewhale completion powershell >> $PROFILE + + Elvish: + codewhale completion elvish >> ~/.config/elvish/rc.elv + +The command prints the completion script to stdout; redirect it to a path your shell loads automatically."# + )] Completion { #[arg(value_enum)] shell: Shell, @@ -455,6 +466,81 @@ The command prints the completion script to stdout; redirect it to a path your s Update(UpdateArgs), } +/// The name of this crate's `[[bin]]` target, and the command users actually +/// type. Completion scripts must register *this*, not the in-tree +/// `codewhale-tui` binary that used to render them (#5526). +/// +/// GitHub releases do not ship a separately compiled TUI: `release-artifacts.yml` +/// builds `-p codewhale-cli` and publishes `codewhale` plus a byte-identical +/// `codew` copy. The `codewhale-tui-*` filenames still attached to the release +/// are that same binary (a v0.9.4 updater bridge), not a third runtime. +const COMPLETION_BIN_NAME: &str = "codewhale"; + +/// Releases publish `codew` as a byte-identical copy of `codewhale` +/// (`release-artifacts.yml` copies the binary and `cmp`s it), so a completion +/// script that fires only for `codewhale` is half-installed for anyone who +/// types the short name. +const COMPLETION_ALIAS_NAME: &str = "codew"; + +/// Render the completion script for `shell` from this binary's own clap tree, +/// registered for both published command names. +fn render_completion_script(shell: Shell) -> String { + let mut cmd = Cli::command(); + let mut buf = Vec::new(); + generate(shell, &mut cmd, COMPLETION_BIN_NAME, &mut buf); + let script = String::from_utf8_lossy(&buf).into_owned(); + register_completion_alias(shell, script) +} + +/// Extend a clap_complete script so the `codew` shorthand completes too. +/// +/// Each shell gets its own idiomatic hook rather than a second copy of the +/// script: bash re-binds the generated function, zsh widens the `#compdef` +/// tag line, fish wraps the primary command, PowerShell registers an array +/// of command names, and Elvish aliases the completer map entry. `Shell` is +/// non-exhaustive, so any future variant falls through unchanged. +fn register_completion_alias(shell: Shell, script: String) -> String { + let bin = COMPLETION_BIN_NAME; + let alias = COMPLETION_ALIAS_NAME; + match shell { + Shell::Bash => format!( + "{script}\n\ + if [[ \"${{BASH_VERSINFO[0]}}\" -eq 4 && \"${{BASH_VERSINFO[1]}}\" -ge 4 || \"${{BASH_VERSINFO[0]}}\" -gt 4 ]]; then\n \ + complete -F _{bin} -o nosort -o bashdefault -o default {alias}\n\ + else\n \ + complete -F _{bin} -o bashdefault -o default {alias}\n\ + fi\n" + ), + // Two install paths, two hooks. Autoloaded from `fpath` the tag line + // on the first line is what binds the names; sourced directly, the + // `compdef` call clap emits at the bottom is. Cover both, and reuse + // clap's own `funcstack` guard so the appended call is skipped when + // the body runs as the completion function itself. + Shell::Zsh => { + let tagged = match script.strip_prefix(&format!("#compdef {bin}\n")) { + Some(rest) => format!("#compdef {bin} {alias}\n{rest}"), + None => script, + }; + format!( + "{tagged}\nif [ \"$funcstack[1]\" != \"_{bin}\" ]; then\n \ + compdef _{bin} {alias}\n\ + fi\n" + ) + } + Shell::Fish => format!("{script}\ncomplete -c {alias} -w {bin}\n"), + Shell::PowerShell => script.replacen( + &format!("-CommandName '{bin}'"), + &format!("-CommandName '{bin}','{alias}'"), + 1, + ), + Shell::Elvish => format!( + "{script}\n\ + set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]\n" + ), + _ => script, + } +} + fn command_accepts_raw_provider(command: Option<&Commands>) -> bool { matches!(command, Some(Commands::Exec(_) | Commands::Fleet(_))) } @@ -1432,10 +1518,22 @@ fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec { #[derive(Debug, Args)] struct LoginArgs { + /// Print the verification URL without trying to open a browser. + #[arg(long, default_value_t = false)] + no_open: bool, + /// Maximum time to wait for browser authorization. + #[arg( + long = "timeout-seconds", + default_value_t = cloud::DEFAULT_LOGIN_TIMEOUT_SECONDS, + value_parser = clap::value_parser!(u64).range(1..=cloud::MAX_LOGIN_TIMEOUT_SECONDS) + )] + timeout_seconds: u64, + /// Legacy provider-key flag: rejected with a redirect to `auth set`. + #[arg(long, hide = true)] + api_key: Option, + /// Legacy provider flag: rejected with a redirect to `auth set`. #[arg(long, value_enum, hide = true)] provider: Option, - #[arg(long)] - api_key: Option, } #[derive(Debug, Args)] @@ -1540,11 +1638,22 @@ struct ConfigArgs { #[derive(Debug, Subcommand)] enum ConfigCommand { - Get { key: String }, - Set { key: String, value: String }, - Unset { key: String }, + Get { + key: String, + }, + Set { + key: String, + value: String, + }, + Unset { + key: String, + }, List, Path, + /// Import a portable config bundle from a file, HTTPS URL, or stdin (-). + Import(config_bundles::ImportArgs), + /// Export a portable, secret-free config bundle. + Export(config_bundles::ExportArgs), } #[derive(Debug, Args)] @@ -1718,8 +1827,52 @@ fn split_lane_log_proxy_command( } } +fn config_command_targets_project(matches: &clap::ArgMatches) -> bool { + let Some(config_matches) = matches.subcommand_matches("config") else { + return false; + }; + let Some((command, command_matches)) = config_matches.subcommand() else { + return false; + }; + if !matches!(command, "import" | "export") { + return false; + } + command_matches + .try_get_one::("project") + .ok() + .flatten() + .copied() + .unwrap_or(false) +} + +fn config_store_path_for_dispatch( + explicit_path: Option, + project_bundle_scope: bool, + cwd: &Path, +) -> Option { + if explicit_path.is_none() && project_bundle_scope { + // Mirror the project-config loader: the current app dir wins, but a + // workspace that still keeps its document under the legacy app dir + // must be read and updated in place rather than shadowed by a new + // empty document. + let current = cwd + .join(codewhale_config::CODEWHALE_APP_DIR) + .join(codewhale_config::CONFIG_FILE_NAME); + let legacy = cwd + .join(codewhale_config::LEGACY_APP_DIR) + .join(codewhale_config::CONFIG_FILE_NAME); + if !current.is_file() && legacy.is_file() { + return Some(legacy); + } + return Some(current); + } + explicit_path +} + fn run() -> Result<()> { - let mut cli = Cli::parse(); + let matches = Cli::command().get_matches(); + let project_bundle_scope = config_command_targets_project(&matches); + let mut cli = Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit()); // The detached log proxy must not depend on user config parsing: its job // is to frame child output and publish a terminal receipt even when the @@ -1761,7 +1914,10 @@ fn run() -> Result<()> { return run_tui_in_process(&cli, &resolved_runtime, passthrough); } - let mut store = ConfigStore::load(cli.config.clone()).map_err(|error| { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let config_path = + config_store_path_for_dispatch(cli.config.clone(), project_bundle_scope, &cwd); + let mut store = ConfigStore::load(config_path).map_err(|error| { if pipe_api_key_handoff { anyhow!("unavailable credential") } else { @@ -1878,11 +2034,16 @@ fn run() -> Result<()> { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); run_tui_server_in_process(&cli, &resolved_runtime, web_serve_passthrough(&args)) } - Some(Commands::Completions(args)) => { - let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); - run_tui_in_process(&cli, &resolved_runtime, tui_args("completions", args)) + Some(Commands::Login(args)) => { + reject_legacy_login_provider_args(&args)?; + cloud::reject_inline_api_key(cli.api_key.as_deref())?; + cloud::run_account_login( + args.no_open, + args.timeout_seconds, + cli.profile.as_deref(), + &store, + ) } - Some(Commands::Login(args)) => run_login_command(&mut store, args), Some(Commands::Logout) => run_logout_command(&mut store), Some(Commands::Auth(args)) => match args.command { AuthCommand::XaiDevice => { @@ -1946,7 +2107,7 @@ fn run() -> Result<()> { Some(store.path().to_path_buf()), Surface::Cli, ); - let outcome = run_config_command(&mut store, args.command); + let outcome = run_config_command(&mut store, args.command, project_bundle_scope); finish_cli_telemetry(session, &outcome); outcome } @@ -1980,8 +2141,9 @@ fn run() -> Result<()> { run_app_server_command(&cli, &resolved_runtime, args) } Some(Commands::Completion { shell }) => { - let mut cmd = Cli::command(); - generate(shell, &mut cmd, "codewhale", &mut io::stdout()); + let mut stdout = io::stdout(); + stdout.write_all(render_completion_script(shell).as_bytes())?; + stdout.flush()?; Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), @@ -2189,39 +2351,18 @@ fn reject_exec_global_flags(args: &[String]) -> Result<()> { Ok(()) } -fn run_login_command(store: &mut ConfigStore, args: LoginArgs) -> Result<()> { - run_login_command_with_secrets(store, args, &Secrets::auto_detect()) -} - -fn run_login_command_with_secrets( - store: &mut ConfigStore, - args: LoginArgs, - secrets: &Secrets, -) -> Result<()> { - let provider: ProviderKind = args.provider.unwrap_or(ProviderArg::Deepseek).into(); - let api_key = match args.api_key { - Some(v) => v, - None => read_api_key_from_stdin()?, - }; - let mut credential_store = credential_metadata_store(store)?; - let store = credential_store.as_mut().unwrap_or(store); - store.config.provider = provider; - - let secret_store_saved = persist_provider_api_key(store, secrets, provider, &api_key)?; - let destination = if secret_store_saved { - secrets.backend_name().to_string() - } else { - codewhale_config::quote_os_path(store.path()) - }; - if provider == ProviderKind::Deepseek { - println!("logged in using API key mode (deepseek); saved key to {destination}"); - } else { - println!( - "logged in using API key mode ({}); saved key to {destination}", - provider.as_str(), - ); +/// `codewhale login` used to configure provider API keys; that surface moved +/// to `auth set --provider`. The hidden legacy flags stay parseable so the +/// redirect below can name the replacement instead of an unknown-flag error. +fn reject_legacy_login_provider_args(args: &LoginArgs) -> Result<()> { + if args.api_key.is_none() && args.provider.is_none() { + return Ok(()); } - Ok(()) + bail!( + "`codewhale login` now signs in to your Codewhale account via the browser device flow. \ + To configure a provider key, run `codewhale auth set --provider ` (hidden prompt) \ + or `codewhale auth set --provider --api-key-stdin`." + ) } fn run_logout_command(store: &mut ConfigStore) -> Result<()> { @@ -4091,7 +4232,17 @@ fn run_auth_migrate(store: &mut ConfigStore, secrets: &Secrets, dry_run: bool) - Ok(()) } -fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result<()> { +fn run_config_command( + store: &mut ConfigStore, + command: ConfigCommand, + project_bundle_scope: bool, +) -> Result<()> { + if project_bundle_scope && !codewhale_config::config_path_is_workspace_scoped(store.path()) { + bail!( + "--project requires a workspace config ({} is the user-global document)", + store.path().display() + ); + } match command { ConfigCommand::Get { key } => { if let Some(value) = store.config.get_display_value(&key) { @@ -4131,6 +4282,11 @@ fn run_config_command(store: &mut ConfigStore, command: ConfigCommand) -> Result println!("{}", store.path().display()); Ok(()) } + ConfigCommand::Import(args) => { + let workspace = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + config_bundles::run_import(&args, store, &workspace) + } + ConfigCommand::Export(args) => config_bundles::run_export(&args, store), } } @@ -4171,6 +4327,16 @@ fn provider_source_label(source: ProviderSource) -> String { } } +fn canonical_model_for_set(model: &str) -> &str { + match model.to_ascii_lowercase().as_str() { + "pro" | "deepseek-v4pro" => "deepseek-v4-pro", + "flash" | "deepseek-v4flash" => "deepseek-v4-flash", + "flash-vision" | "deepseek-v4flashvisionexp" => "deepseek-v4-flash-vision-exp", + "auto" => "auto", + _ => model, + } +} + fn run_model_command( store: &mut ConfigStore, command: ModelCommand, @@ -4269,12 +4435,7 @@ fn run_model_command( if trimmed.is_empty() { bail!("Model name cannot be empty"); } - let canonical = match trimmed.to_ascii_lowercase().as_str() { - "pro" | "deepseek-v4pro" => "deepseek-v4-pro", - "flash" | "deepseek-v4flash" => "deepseek-v4-flash", - "auto" => "auto", - _ => trimmed, - }; + let canonical = canonical_model_for_set(trimmed); store.config.default_text_model = Some(canonical.to_string()); store.save()?; println!("Default model set to '{canonical}'"); @@ -4449,6 +4610,9 @@ fn run_app_server_command( // Legacy in-process app-server HTTP transport (`/healthz`, `/thread`, `/app`, // `/prompt`, `/tool`, `/jobs`). Kept for backward compatibility; defaults to // 127.0.0.1:8787 to avoid colliding with the runtime API default of :7878. + // `/prompt` and `/thread` messages are not served locally: they run a real + // turn by bridging to a runtime API child, and fail with an explicit + // `runtime_unavailable` when one cannot be started. let host = args.host.as_deref().unwrap_or("127.0.0.1"); let port = args.port.unwrap_or(8787); let outcome = format!("{host}:{port}") @@ -5154,6 +5318,216 @@ mod tests { )); } + fn config_dispatch_from( + argv: &[OsString], + cwd: &Path, + ) -> (Option, ConfigCommand, bool) { + let matches = Cli::command() + .try_get_matches_from(argv.iter().cloned()) + .unwrap_or_else(|error| panic!("config command should parse: {error}")); + let project_bundle_scope = config_command_targets_project(&matches); + let cli = Cli::from_arg_matches(&matches) + .unwrap_or_else(|error| panic!("config command should decode: {error}")); + let selected_path = config_store_path_for_dispatch(cli.config, project_bundle_scope, cwd); + let Some(Commands::Config(ConfigArgs { command })) = cli.command else { + panic!("expected config command"); + }; + (selected_path, command, project_bundle_scope) + } + + fn write_config_fixture(path: &Path, body: &str) { + std::fs::create_dir_all(path.parent().expect("config should have a parent")) + .expect("create config parent"); + std::fs::write(path, body).expect("write config fixture"); + } + + #[test] + fn project_config_dispatch_prefers_current_app_dir_and_falls_back_to_legacy() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + let current = workspace.join(".codewhale/config.toml"); + let legacy = workspace.join(".deepseek/config.toml"); + + // Fresh workspace: create under the current app dir. + std::fs::create_dir_all(&workspace).expect("workspace"); + assert_eq!( + config_store_path_for_dispatch(None, true, &workspace), + Some(current.clone()) + ); + + // Legacy-only workspace: operate on the legacy document in place. + write_config_fixture(&legacy, "verbosity = \"legacy\"\n"); + assert_eq!( + config_store_path_for_dispatch(None, true, &workspace), + Some(legacy.clone()) + ); + + // Both present: the current app dir wins, matching the loader. + write_config_fixture(¤t, "verbosity = \"current\"\n"); + assert_eq!( + config_store_path_for_dispatch(None, true, &workspace), + Some(current.clone()) + ); + + // An explicit --config path always wins; without --project nothing is selected. + let explicit = temp.path().join("explicit.toml"); + assert_eq!( + config_store_path_for_dispatch(Some(explicit.clone()), true, &workspace), + Some(explicit) + ); + assert_eq!( + config_store_path_for_dispatch(None, false, &workspace), + None + ); + } + + #[test] + fn project_config_import_dispatches_to_the_cwd_document() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker"); + let project_path = workspace.join(".codewhale/config.toml"); + let global_path = temp.path().join("global-config.toml"); + write_config_fixture(&project_path, "verbosity = \"project-before\"\n"); + write_config_fixture(&global_path, "verbosity = \"global-only\"\n"); + + let bundle_path = temp.path().join("project-bundle.toml"); + std::fs::write( + &bundle_path, + r#"schema_version = 1 +kind = "codewhale.portable-config" + +[project] +verbosity = "project-imported" +"#, + ) + .expect("write project bundle"); + let argv = [ + OsString::from("codewhale"), + OsString::from("config"), + OsString::from("import"), + bundle_path.as_os_str().to_owned(), + OsString::from("--yes"), + OsString::from("--project"), + ]; + let (selected_path, command, project_bundle_scope) = + config_dispatch_from(&argv, &workspace); + assert_eq!(selected_path.as_deref(), Some(project_path.as_path())); + + let mut store = ConfigStore::load(selected_path).expect("load selected project config"); + run_config_command(&mut store, command, project_bundle_scope) + .expect("import project bundle"); + let project = ConfigStore::load(Some(project_path.clone())).expect("reload project"); + let global = ConfigStore::load(Some(global_path.clone())).expect("reload global"); + assert_eq!( + project.config.verbosity.as_deref(), + Some("project-imported") + ); + assert_eq!(global.config.verbosity.as_deref(), Some("global-only")); + + let explicit_argv = [ + OsString::from("codewhale"), + OsString::from("--config"), + global_path.as_os_str().to_owned(), + OsString::from("config"), + OsString::from("import"), + bundle_path.as_os_str().to_owned(), + OsString::from("--yes"), + OsString::from("--project"), + ]; + let global_before = std::fs::read(&global_path).expect("read global before refusal"); + let (selected_path, command, project_bundle_scope) = + config_dispatch_from(&explicit_argv, &workspace); + assert_eq!(selected_path.as_deref(), Some(global_path.as_path())); + let mut explicit_store = + ConfigStore::load(selected_path).expect("load explicit global config"); + let error = run_config_command(&mut explicit_store, command, project_bundle_scope) + .expect_err("project import must reject an explicit non-workspace config"); + assert!( + error + .to_string() + .contains("--project requires a workspace config"), + "{error:#}" + ); + assert_eq!( + std::fs::read(&global_path).expect("read global after refusal"), + global_before + ); + } + + #[test] + fn project_config_export_reads_the_cwd_document() { + let temp = tempfile::tempdir().expect("tempdir"); + let workspace = temp.path().join("workspace"); + std::fs::create_dir_all(workspace.join(".git")).expect("create checkout marker"); + let project_path = workspace.join(".codewhale/config.toml"); + let global_path = temp.path().join("global-config.toml"); + let output_path = temp.path().join("portable.toml"); + write_config_fixture(&project_path, "verbosity = \"project-only\"\n"); + write_config_fixture(&global_path, "verbosity = \"global-only\"\n"); + + let argv = [ + OsString::from("codewhale"), + OsString::from("config"), + OsString::from("export"), + OsString::from("--portable"), + OsString::from("--project"), + OsString::from("--out"), + output_path.as_os_str().to_owned(), + ]; + let (selected_path, command, project_bundle_scope) = + config_dispatch_from(&argv, &workspace); + assert_eq!(selected_path.as_deref(), Some(project_path.as_path())); + + let mut store = ConfigStore::load(selected_path).expect("load selected project config"); + run_config_command(&mut store, command, project_bundle_scope) + .expect("export project bundle"); + let body = std::fs::read_to_string(&output_path).expect("read portable export"); + let bundle = config_bundles::parse_bundle_str(&body, "portable.toml") + .expect("parse portable export"); + assert_eq!( + bundle + .project + .entries + .get("verbosity") + .and_then(toml::Value::as_str), + Some("project-only") + ); + assert!(bundle.global.entries.is_empty()); + + let explicit_output_path = temp.path().join("explicit-portable.toml"); + let explicit_argv = [ + OsString::from("codewhale"), + OsString::from("--config"), + global_path.as_os_str().to_owned(), + OsString::from("config"), + OsString::from("export"), + OsString::from("--portable"), + OsString::from("--project"), + OsString::from("--out"), + explicit_output_path.as_os_str().to_owned(), + ]; + let global_before = std::fs::read(&global_path).expect("read global before refusal"); + let (selected_path, command, project_bundle_scope) = + config_dispatch_from(&explicit_argv, &workspace); + assert_eq!(selected_path.as_deref(), Some(global_path.as_path())); + let mut explicit_store = + ConfigStore::load(selected_path).expect("load explicit global config"); + let error = run_config_command(&mut explicit_store, command, project_bundle_scope) + .expect_err("project export must reject an explicit non-workspace config"); + assert!( + error + .to_string() + .contains("--project requires a workspace config"), + "{error:#}" + ); + assert!(!explicit_output_path.exists()); + assert_eq!( + std::fs::read(&global_path).expect("read global after refusal"), + global_before + ); + } + #[test] fn parses_update_beta_flag() { let cli = parse_ok(&["codewhale", "update"]); @@ -5275,6 +5649,20 @@ mod tests { )); } + #[test] + fn model_set_canonicalizes_deepseek_vision_aliases() { + for alias in ["flash-vision", "deepseek-v4flashvisionexp"] { + assert_eq!( + canonical_model_for_set(alias), + "deepseek-v4-flash-vision-exp" + ); + } + assert_eq!( + canonical_model_for_set("deepseek-v4-flash-vision-exp"), + "deepseek-v4-flash-vision-exp" + ); + } + #[test] fn parses_thread_command_matrix() { let cli = parse_ok(&["deepseek", "thread", "list", "--all", "--limit", "50"]); @@ -5401,6 +5789,162 @@ mod tests { )); } + /// The `[[bin]] name` declared in this crate's manifest is the only thing a + /// user ever types. Read it from disk rather than restating it, so renaming + /// the binary without re-pointing the completion generator fails here + /// instead of silently shipping a script nobody's shell loads (#5526). + fn declared_bin_name() -> String { + let manifest = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")) + .expect("read crates/cli/Cargo.toml"); + let bin_section = manifest + .split("[[bin]]") + .nth(1) + .expect("crates/cli/Cargo.toml declares a [[bin]] target"); + for line in bin_section.lines() { + let line = line.trim(); + if let Some(rest) = line.strip_prefix("name") { + let value = rest.trim_start().trim_start_matches('=').trim(); + return value.trim_matches('"').to_string(); + } + } + panic!("[[bin]] section has no name key"); + } + + #[test] + fn completion_bin_name_matches_the_declared_bin_target() { + assert_eq!( + COMPLETION_BIN_NAME, + declared_bin_name(), + "completion scripts must register the binary this crate actually builds" + ); + } + + /// Issue #5526: `codewhale completions ` used to forward to the + /// in-tree `codewhale-tui` binary, so every generated script registered + /// `codewhale-tui` — not a GitHub-release command — and exposed the TUI's + /// smaller subcommand tree. Pin the registered names per shell. + #[test] + fn generated_completion_scripts_register_the_published_command_names() { + let bin = declared_bin_name(); + let alias = COMPLETION_ALIAS_NAME; + + // Match whole lines throughout: `codew` is a prefix of `codewhale`, + // so a substring check for the alias is satisfied by the primary + // binding and would pass on an unfixed build. + let has_line = + |script: &str, wanted: &str| script.lines().any(|line| line.trim() == wanted); + + let bash = render_completion_script(Shell::Bash); + assert!( + has_line( + &bash, + &format!("complete -F _{bin} -o bashdefault -o default {bin}") + ), + "bash script must bind the real binary name:\n{bash}" + ); + assert!( + has_line( + &bash, + &format!("complete -F _{bin} -o bashdefault -o default {alias}") + ), + "bash script must bind the {alias} shorthand too" + ); + + let zsh = render_completion_script(Shell::Zsh); + assert_eq!( + zsh.lines().next(), + Some(format!("#compdef {bin} {alias}").as_str()), + "zsh compdef tag line must list both published command names" + ); + assert!( + has_line(&zsh, &format!("compdef _{bin} {bin}")), + "zsh script must bind {bin} on the sourced path" + ); + assert!( + has_line(&zsh, &format!("compdef _{bin} {alias}")), + "zsh script must bind {alias} on the sourced path too" + ); + + let fish = render_completion_script(Shell::Fish); + assert!( + fish.contains(&format!("complete -c {bin} ")), + "fish script must complete the real binary name" + ); + assert!( + has_line(&fish, &format!("complete -c {alias} -w {bin}")), + "fish script must wrap the {alias} shorthand onto {bin}" + ); + + let powershell = render_completion_script(Shell::PowerShell); + assert!( + powershell.contains(&format!( + "Register-ArgumentCompleter -Native -CommandName '{bin}','{alias}'" + )), + "PowerShell script must register both published command names" + ); + + let elvish = render_completion_script(Shell::Elvish); + assert!( + has_line( + &elvish, + &format!("set edit:completion:arg-completer[{bin}] = {{|@words|") + ), + "elvish script must bind the real binary name:\n{elvish}" + ); + assert!( + has_line( + &elvish, + &format!( + "set edit:completion:arg-completer[{alias}] = $edit:completion:arg-completer[{bin}]" + ) + ), + "elvish script must alias the {alias} shorthand onto {bin}" + ); + + for (shell, script) in [ + ("bash", &bash), + ("zsh", &zsh), + ("fish", &fish), + ("powershell", &powershell), + ("elvish", &elvish), + ] { + assert!( + !script.contains("codewhale-tui"), + "{shell} completions leaked the in-tree codewhale-tui name (#5526)" + ); + } + } + + /// The other half of #5526: the script has to describe *this* CLI's + /// commands. Rendering from a different clap tree would drop or invent + /// subcommands, which is exactly how the forwarded script went stale. + #[test] + fn generated_completion_scripts_cover_the_real_subcommand_surface() { + let bash = render_completion_script(Shell::Bash); + for sub in Cli::command().get_subcommands() { + if sub.is_hide_set() { + continue; + } + let name = sub.get_name(); + assert!( + bash.contains(name), + "bash completions omit the `{name}` subcommand" + ); + } + } + + /// `completions` is what the issue reporter typed and what the TUI called + /// it; keep it working, now as an alias that renders in-process. + #[test] + fn completions_is_an_alias_for_completion() { + assert!(matches!( + parse_ok(&["codewhale", "completions", "powershell"]).command, + Some(Commands::Completion { + shell: Shell::PowerShell + }) + )); + } + #[test] fn app_server_transports_are_mutually_exclusive() { assert!(matches!( @@ -6079,7 +6623,7 @@ mod tests { } #[test] - fn deepseek_login_uses_isolated_file_store_and_preserves_tui_defaults() { + fn auth_set_uses_isolated_file_store_and_preserves_tui_defaults() { let _lock = env_lock(); let dir = tempfile::TempDir::new().expect("tempdir"); let codewhale_home = dir.path().join("codewhale-home"); @@ -6090,15 +6634,16 @@ mod tests { let mut store = ConfigStore::load(Some(path.clone())).expect("store should load"); let secrets = Secrets::auto_detect(); - run_login_command_with_secrets( + run_auth_command_with_secrets( &mut store, - LoginArgs { - provider: Some(ProviderArg::Deepseek), + AuthCommand::Set { + provider: ProviderArg::Deepseek, api_key: Some("sk-test".to_string()), + api_key_stdin: false, }, &secrets, ) - .expect("login should persist credential"); + .expect("auth set should persist credential"); assert!(store.config.api_key.is_none()); assert!(store.config.providers.deepseek.api_key.is_none()); @@ -6111,7 +6656,7 @@ mod tests { assert!( !saved .lines() - .any(|line| line.trim_start().starts_with("api_key =")) + .any(|line| line.trim_start().starts_with("api_key=")) ); assert!(saved.contains("default_text_model = \"deepseek-v4-pro\"")); assert_eq!( @@ -6120,57 +6665,77 @@ mod tests { ); } - /// #5198: with CODEWHALE_CONFIG_PATH pointing at a workspace-scoped - /// `/.codewhale/config.toml`, login must write the provider binding - /// and auth markers to the user-global document, never the repo file. + /// `codewhale login` now means the Codewhale account device flow: the + /// account-login flags parse through and reach the cloud path. #[test] - fn login_with_repo_scoped_ambient_config_writes_user_global_metadata() { - let _lock = env_lock(); - let dir = tempfile::TempDir::new().expect("tempdir"); - let repo = dir.path().join("repo"); - std::fs::create_dir_all(repo.join(".git")).expect("git marker"); - let repo_config_dir = repo.join(".codewhale"); - std::fs::create_dir_all(&repo_config_dir).expect("repo config dir"); - let repo_config = repo_config_dir.join("config.toml"); - std::fs::write(&repo_config, "approval_policy = \"never\"\n").expect("repo config"); - - let codewhale_home = dir.path().join("codewhale-home"); - let _home = ScopedEnvVar::set("CODEWHALE_HOME", &codewhale_home.to_string_lossy()); - let _config = ScopedEnvVar::set("CODEWHALE_CONFIG_PATH", &repo_config.to_string_lossy()); - let _legacy_config = ScopedEnvVar::remove("DEEPSEEK_CONFIG_PATH"); - let _backend = ScopedEnvVar::set("CODEWHALE_SECRET_BACKEND", "file"); - let mut store = ConfigStore::load(None).expect("ambient store should load"); - let secrets = Secrets::auto_detect(); - - run_login_command_with_secrets( - &mut store, - LoginArgs { - provider: Some(ProviderArg::Deepseek), - api_key: Some("sk-repo-scoped".to_string()), - }, - &secrets, - ) - .expect("login should persist credential"); + fn login_parses_account_device_flow_flags() { + let cli = parse_ok(&["codewhale", "login", "--no-open", "--timeout-seconds", "5"]); + let Some(Commands::Login(args)) = cli.command else { + panic!("expected Login"); + }; + assert!(args.no_open); + assert_eq!(args.timeout_seconds, 5); + assert!(args.api_key.is_none()); + assert!(args.provider.is_none()); + + let cli = parse_ok(&["codewhale", "login"]); + let Some(Commands::Login(args)) = cli.command else { + panic!("expected Login"); + }; + assert!(!args.no_open); + assert_eq!(args.timeout_seconds, 600); + } - assert_eq!( - secrets.get("deepseek").expect("read secret").as_deref(), - Some("sk-repo-scoped") + /// The provider-key surface moved to `auth set --provider`; the hidden + /// legacy flags must redirect loudly instead of silently configuring a key. + #[test] + fn login_rejects_legacy_provider_flags_with_redirect() { + let err = reject_legacy_login_provider_args(&LoginArgs { + no_open: false, + timeout_seconds: 600, + api_key: Some("sk-x".to_string()), + provider: None, + }) + .expect_err("legacy --api-key must be rejected"); + let rendered = err.to_string(); + assert!( + rendered.contains("auth set --provider"), + "redirect must name `auth set --provider`: {rendered}" ); - let global_config = codewhale_home.join("config.toml"); - let global = std::fs::read_to_string(&global_config).expect("user-global config"); + + let err = reject_legacy_login_provider_args(&LoginArgs { + no_open: false, + timeout_seconds: 600, + api_key: None, + provider: Some(ProviderArg::Deepseek), + }) + .expect_err("legacy --provider must be rejected"); assert!( - global.contains("auth_mode = \"api_key\""), - "user-global config must carry the auth marker: {global}" + err.to_string().contains("auth set --provider"), + "redirect must name `auth set --provider`" ); + + reject_legacy_login_provider_args(&LoginArgs { + no_open: false, + timeout_seconds: 600, + api_key: None, + provider: None, + }) + .expect("plain account login carries no legacy flags"); + } + + /// Root help keeps the `login` token, but its meaning is now the account + /// sign-in; the subcommand help must say so. + #[test] + fn login_help_describes_account_signin() { + let help = help_for(&["codewhale", "login", "--help"]); assert!( - global.contains("provider = \"deepseek\""), - "user-global config must carry the provider binding: {global}" + help.contains("Codewhale account"), + "login help must describe account sign-in: {help}" ); - assert!(!global.contains("sk-repo-scoped"), "{global}"); - let repo_after = std::fs::read_to_string(&repo_config).expect("repo config"); - assert_eq!( - repo_after, "approval_policy = \"never\"\n", - "workspace config must stay untouched by credential metadata: {repo_after}" + assert!( + !help.to_lowercase().contains("api key"), + "login help must not advertise provider API keys: {help}" ); } @@ -8488,11 +9053,13 @@ mod tests { vec![ "", "bash", + "Every script completes both `codewhale` and the `codew` shorthand.", "source <(codewhale completion bash)", "~/.local/share/bash-completion/completions/codewhale", "fpath=(~/.zfunc $fpath)", "codewhale completion fish > ~/.config/fish/completions/codewhale.fish", "codewhale completion powershell | Out-String | Invoke-Expression", + "codewhale completion elvish >> ~/.config/elvish/rc.elv", ], ), ("metrics", vec!["--json", "--since"]), diff --git a/crates/cli/tests/mcp_server_proxy.rs b/crates/cli/tests/mcp_server_proxy.rs index 007df2c8e2..147b723d57 100644 --- a/crates/cli/tests/mcp_server_proxy.rs +++ b/crates/cli/tests/mcp_server_proxy.rs @@ -28,14 +28,20 @@ while IFS= read -r line; do fi case "$method" in initialize) - printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"fake-mcp","version":"0"}}}\n' "$id" + printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"fake-mcp","version":"0"}}}\n' "$id" ;; tools/list) - printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"whoami","description":"report the spawned process"}]}}\n' "$id" + printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"whoami","description":"report the spawned process","inputSchema":{"type":"object","properties":{}}}]}}\n' "$id" ;; tools/call) printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"spawned-child"}]}}\n' "$id" ;; + resources/list) + printf '{"jsonrpc":"2.0","id":%s,"result":{"resources":[{"uri":"file:///fake/readme.txt","name":"Fake readme","description":"resource from the spawned process","mimeType":"text/plain","size":16,"annotations":{"audience":["assistant"],"priority":0.75}}]}}\n' "$id" + ;; + resources/read) + printf '{"jsonrpc":"2.0","id":%s,"result":{"contents":[{"uri":"file:///fake/readme.txt","mimeType":"text/plain","text":"spawned-resource"}]}}\n' "$id" + ;; *) printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"unsupported method"}}\n' "$id" ;; @@ -145,7 +151,63 @@ fn response_for(responses: &[Value], id: i64) -> &Value { } #[test] -fn mcp_server_proxies_tools_from_the_configured_child_process() { +fn mcp_server_enforces_jsonrpc_identity_and_initialize_lifecycle() { + let fixture = Fixture::new(); + let (responses, stderr) = fixture.run_mcp_server(&[ + json!({"id": 1, "method": "ping"}), + json!({"jsonrpc": "2.0", "id": null, "method": "ping"}), + json!({"jsonrpc": "2", "id": 2, "method": "ping"}), + json!({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "lifecycle-test", "version": "1"}, + "capabilities": {} + } + }), + json!({"jsonrpc": "2.0", "id": 5, "method": "resources/list"}), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json!({"jsonrpc": "2.0", "method": "ping"}), + json!({"jsonrpc": "2.0", "id": 6, "method": "tools/list"}), + json!({"jsonrpc": "2.0", "id": 7, "method": "shutdown"}), + ]); + + let null_id_responses: Vec<&Value> = responses + .iter() + .filter(|response| response["id"].is_null()) + .collect(); + assert_eq!( + null_id_responses.len(), + 2, + "missing id must be a notification while explicit null receives a response: {responses:?}" + ); + assert_eq!(null_id_responses[0]["error"]["code"], -32600); + assert!(null_id_responses[1]["result"].is_object()); + + assert_eq!(response_for(&responses, 2)["error"]["code"], -32600); + assert_eq!(response_for(&responses, 3)["error"]["code"], -32600); + assert!( + response_for(&responses, 3)["error"]["message"] + .as_str() + .is_some_and(|message| message.contains("completed initialize")) + ); + assert_eq!( + response_for(&responses, 4)["result"]["protocolVersion"], + "2024-11-05" + ); + assert_eq!(response_for(&responses, 5)["error"]["code"], -32600); + assert_eq!(response_for(&responses, 6)["result"]["tools"], json!([])); + assert!( + stderr.contains("codewhale mcp-server: stdio server exited"), + "missing clean shutdown receipt:\n{stderr}" + ); +} + +#[test] +fn mcp_server_proxies_tools_and_resources_from_the_configured_child_process() { let fixture = Fixture::new(); let script = fixture.write_fake_server(); fixture.configure_servers(json!([{ @@ -157,6 +219,17 @@ fn mcp_server_proxies_tools_from_the_configured_child_process() { }])); let (responses, stderr) = fixture.run_mcp_server(&[ + json!({ + "jsonrpc": "2.0", + "id": 0, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "proxy-test", "version": "1"}, + "capabilities": {} + } + }), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}), json!({ "jsonrpc": "2.0", @@ -164,28 +237,80 @@ fn mcp_server_proxies_tools_from_the_configured_child_process() { "method": "tools/call", "params": {"name": "mcp__fake__whoami", "arguments": {}} }), - json!({"jsonrpc": "2.0", "id": 3, "method": "shutdown"}), + json!({"jsonrpc": "2.0", "id": 3, "method": "resources/list"}), + json!({ + "jsonrpc": "2.0", + "id": 4, + "method": "resources/read", + "params": {"uri": "file:///fake/readme.txt"} + }), + json!({"jsonrpc": "2.0", "id": 5, "method": "shutdown"}), ]); + let initialize = response_for(&responses, 0); + assert_eq!(initialize["result"]["protocolVersion"], "2024-11-05"); + assert_eq!( + initialize["result"]["serverInfo"]["name"], + "codewhale-mcp-server" + ); + assert!(initialize["result"]["capabilities"]["tools"].is_object()); + assert!(initialize["result"]["capabilities"]["resources"].is_object()); + let tools = response_for(&responses, 1)["result"]["tools"] .as_array() .unwrap_or_else(|| panic!("tools/list returned no array; stderr:\n{stderr}")) .clone(); let names: Vec<&str> = tools .iter() - .filter_map(|tool| tool["tool_name"].as_str()) + .filter_map(|tool| tool["name"].as_str()) .collect(); assert_eq!( names, - vec!["whoami"], + vec!["mcp__fake__whoami"], "only the child's real tools may be exposed; the stub's fabricated \ `health`/`capabilities` must be gone. stderr:\n{stderr}" ); + assert_eq!(tools[0]["tool_name"], "whoami"); + assert!(tools[0]["inputSchema"].is_object()); let call = response_for(&responses, 2); + assert_eq!( + call["result"]["content"][0]["text"], "spawned-child", + "the standard MCP result must come from the spawned process: {call}" + ); assert_eq!( call["result"]["result"]["content"][0]["text"], "spawned-child", - "the tool result must come from the spawned process: {call}" + "the legacy nested result must remain available: {call}" + ); + + let resources = response_for(&responses, 3)["result"]["resources"] + .as_array() + .unwrap_or_else(|| panic!("resources/list returned no array; stderr:\n{stderr}")); + assert_eq!(resources.len(), 1); + assert_eq!(resources[0]["uri"], "file:///fake/readme.txt"); + assert_eq!(resources[0]["name"], "Fake readme"); + assert_eq!(resources[0]["mimeType"], "text/plain"); + assert_eq!(resources[0]["size"], 16); + assert_eq!( + resources[0]["annotations"]["audience"], + json!(["assistant"]) + ); + assert_eq!(resources[0]["annotations"]["priority"], 0.75); + assert_eq!(resources[0]["server_name"], "fake"); + + let read = response_for(&responses, 4); + assert_eq!(read["result"]["contents"][0]["text"], "spawned-resource"); + assert_eq!( + read["result"]["resource"]["contents"][0]["text"], "spawned-resource", + "the legacy nested resource must remain available: {read}" + ); + assert!( + !stderr.contains("deepseek-mcp"), + "stale identity in stderr:\n{stderr}" + ); + assert!( + stderr.contains("codewhale mcp-server: stdio server exited"), + "missing Codewhale shutdown identity in stderr:\n{stderr}" ); } @@ -201,6 +326,17 @@ fn mcp_server_reports_a_server_it_could_not_spawn() { let (responses, stderr) = fixture.run_mcp_server(&[ json!({"jsonrpc": "2.0", "id": 1, "method": "server/list"}), + json!({ + "jsonrpc": "2.0", + "id": 10, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "clientInfo": {"name": "failure-test", "version": "1"}, + "capabilities": {} + } + }), + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}), json!({ "jsonrpc": "2.0", "id": 2, diff --git a/crates/command-contract/Cargo.toml b/crates/command-contract/Cargo.toml index 0b27418110..d1e9700311 100644 --- a/crates/command-contract/Cargo.toml +++ b/crates/command-contract/Cargo.toml @@ -7,5 +7,8 @@ license.workspace = true repository.workspace = true description = "Prototype command capability and dispatch shapes for the staged TUI command extraction" +[lints] +workspace = true + [dependencies] -codewhale-core = { path = "../core", version = "0.9.10" } +codewhale-core = { path = "../core", version = "0.9.11" } diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index 68a3f049f9..e2e1d3f9a2 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -5,7 +5,7 @@ //! `codewhale-tui` one command group at a time. Only after every group uses //! these shapes will groups move physically into a commands crate. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use codewhale_core::request::{Message, SystemPrompt}; @@ -73,4 +73,41 @@ pub trait CommandSkillsContext { pub trait CommandWorkspaceContext { fn workspace(&self) -> PathBuf; fn work_state_snapshot(&self) -> Result, String>; + /// Session-aware canonical operation digest. Returns the final user-facing + /// digest text or a safe explicit error; never a serialized snapshot. + /// No-active-work and temporary-unavailability semantics are preserved by + /// the host implementation (FEAT-018 D5). + fn operation_digest(&mut self) -> Result; +} + +/// Stable-key translation with named replacements (FEAT-018 D3). +/// +/// Message identity uses stable snake_case keys plus named replacements. The +/// TUI host maps those keys to the current catalog and preserves the existing +/// English fallback for intentionally incomplete locale packs. Unknown keys or +/// invalid replacement contracts fail safely and produce a command error; they +/// never panic and never display a raw lookup key. +pub trait CommandPresentationContext { + /// Resolve a stable message key with its named replacements. + fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result; +} + +/// Portable receipt for a successful atomic media attachment (FEAT-018 D4). +/// Carries only the information needed for the existing confirmation text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MediaAttachmentReceipt { + pub kind: String, + pub path: std::path::PathBuf, +} + +/// Atomic composer/media capability (FEAT-018 D4). +/// +/// The host performs media validation and composer insertion as one atomic +/// operation. Rejected, missing, unsupported, corrupt, or oversized media +/// leaves composer state unchanged and returns a safe error. Only portable +/// success information crosses the boundary; composer markup, mutable input +/// text, decoder internals, and TUI types never do. +pub trait CommandMediaContext { + /// Validate and insert a resolved media path atomically. + fn attach_media(&mut self, resolved_path: &Path) -> Result; } diff --git a/crates/command-contract/src/handler.rs b/crates/command-contract/src/handler.rs index 6c2aebef33..cdef0cd9b4 100644 --- a/crates/command-contract/src/handler.rs +++ b/crates/command-contract/src/handler.rs @@ -5,8 +5,9 @@ //! `CommandHandler`. use crate::facets::{ - CommandCostContext, CommandModePolicyContext, CommandModelContext, CommandSessionContext, - CommandSkillsContext, CommandSystemPromptContext, CommandWorkspaceContext, + CommandCostContext, CommandMediaContext, CommandModePolicyContext, CommandModelContext, + CommandPresentationContext, CommandSessionContext, CommandSkillsContext, + CommandSystemPromptContext, CommandWorkspaceContext, }; /// A command handler that is either argument-only or capability-scoped. @@ -25,6 +26,8 @@ pub struct CommandContexts<'a> { system_prompt: Option<&'a mut dyn CommandSystemPromptContext>, skills: Option<&'a mut dyn CommandSkillsContext>, workspace: Option<&'a mut dyn CommandWorkspaceContext>, + presentation: Option<&'a mut dyn CommandPresentationContext>, + media: Option<&'a mut dyn CommandMediaContext>, } /// Consumed envelope used when one handler needs several independent facets. @@ -36,6 +39,8 @@ pub struct ContextParts<'a> { pub system_prompt: Option<&'a mut dyn CommandSystemPromptContext>, pub skills: Option<&'a mut dyn CommandSkillsContext>, pub workspace: Option<&'a mut dyn CommandWorkspaceContext>, + pub presentation: Option<&'a mut dyn CommandPresentationContext>, + pub media: Option<&'a mut dyn CommandMediaContext>, } impl<'a> CommandContexts<'a> { @@ -48,6 +53,8 @@ impl<'a> CommandContexts<'a> { system_prompt: None, skills: None, workspace: None, + presentation: None, + media: None, } } @@ -60,6 +67,8 @@ impl<'a> CommandContexts<'a> { system_prompt: self.system_prompt, skills: self.skills, workspace: self.workspace, + presentation: self.presentation, + media: self.media, } } @@ -115,6 +124,22 @@ impl<'a> CommandContexts<'a> { ); self } + + pub fn with_presentation(mut self, value: &'a mut dyn CommandPresentationContext) -> Self { + assert!( + self.presentation.replace(value).is_none(), + "presentation facet already set" + ); + self + } + + pub fn with_media(mut self, value: &'a mut dyn CommandMediaContext) -> Self { + assert!( + self.media.replace(value).is_none(), + "media facet already set" + ); + self + } } impl Default for CommandContexts<'_> { diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index 6dff337f19..8fbcfd4259 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -1,4 +1,4 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use codewhale_core::request::{Message, SystemPrompt}; @@ -109,6 +109,9 @@ impl CommandWorkspaceContext for Workspace { fn work_state_snapshot(&self) -> Result, String> { Ok(None) } + fn operation_digest(&mut self) -> Result { + Ok("No active operations or to-do items.".to_string()) + } } #[test] @@ -187,3 +190,157 @@ fn registration_shape_has_no_app_dependency() { assert_eq!(Sample::info().name, "sample"); assert!(matches!(Sample::handler(), CommandHandler::Pure(_))); } + +// --------------------------------------------------------------------------- +// FEAT-018: presentation, media, and digest capabilities (D2-D5) +// --------------------------------------------------------------------------- + +struct Presentation; +impl CommandPresentationContext for Presentation { + fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result { + if key == "automation_usage" { + return Ok("Usage: /automation [list|show ]".to_string()); + } + if key == "mcp_recommended_unknown_id" { + let command = replacements + .iter() + .find(|(name, _)| *name == "recommendations_command") + .map(|(_, value)| *value) + .unwrap_or("/mcp recommendations"); + return Ok(format!("Unknown recommended MCP ID (try {command})")); + } + // D3: unknown keys fail safely without echoing the raw lookup key. + Err("unknown translation key".to_string()) + } +} + +struct Media; +impl CommandMediaContext for Media { + fn attach_media(&mut self, path: &Path) -> Result { + if path.extension().and_then(|ext| ext.to_str()) == Some("png") { + Ok(MediaAttachmentReceipt { + kind: "image".to_string(), + path: path.to_path_buf(), + }) + } else { + Err("Unsupported attachment type".to_string()) + } + } +} + +struct DigestWorkspace; +impl CommandWorkspaceContext for DigestWorkspace { + fn workspace(&self) -> PathBuf { + PathBuf::from(".") + } + fn work_state_snapshot(&self) -> Result, String> { + Ok(None) + } + fn operation_digest(&mut self) -> Result { + Ok("No active operations or to-do items.".to_string()) + } +} + +#[test] +fn new_capabilities_are_object_safe_and_independently_transportable() { + fn presentation(_: &dyn CommandPresentationContext) {} + fn media(_: &dyn CommandMediaContext) {} + fn digest_workspace(_: &dyn CommandWorkspaceContext) {} + + presentation(&Presentation); + media(&Media); + digest_workspace(&DigestWorkspace); + + let mut presentation = Presentation; + let mut media = Media; + let parts = CommandContexts::empty() + .with_presentation(&mut presentation) + .with_media(&mut media) + .into_parts(); + assert!(parts.presentation.is_some()); + assert!(parts.media.is_some()); + assert!(parts.session.is_none()); +} + +#[test] +fn translation_contract_resolves_known_keys_and_fails_safely() { + let presentation = Presentation; + assert_eq!( + presentation + .translate("automation_usage", &[]) + .expect("known key"), + "Usage: /automation [list|show ]" + ); + assert_eq!( + presentation + .translate( + "mcp_recommended_unknown_id", + &[("recommendations_command", "/mcp recommendations")], + ) + .expect("known key with named replacement"), + "Unknown recommended MCP ID (try /mcp recommendations)" + ); + let unknown = presentation.translate("no_such_key", &[]); + assert!(unknown.is_err(), "unknown key must fail safely"); + let err = unknown.unwrap_err(); + assert!( + !err.contains("no_such_key"), + "no raw lookup key exposure (D3)" + ); +} + +#[test] +fn media_contract_is_atomic_and_returns_only_portable_data() { + let mut media = Media; + let ok = media + .attach_media(Path::new("/tmp/photo.png")) + .expect("png"); + assert_eq!(ok.kind, "image"); + assert_eq!(ok.path, PathBuf::from("/tmp/photo.png")); + + let err = media.attach_media(Path::new("/tmp/notes.txt")).unwrap_err(); + assert!(!err.is_empty(), "safe error string"); +} + +#[test] +fn digest_operation_returns_final_text_and_safe_errors() { + let mut workspace = DigestWorkspace; + assert_eq!( + workspace.operation_digest().expect("digest"), + "No active operations or to-do items." + ); +} + +#[test] +fn envelope_rejects_duplicate_new_slots_deterministically() { + struct SecondPresentation; + impl CommandPresentationContext for SecondPresentation { + fn translate(&self, _key: &str, _r: &[(&str, &str)]) -> Result { + Ok(String::new()) + } + } + struct SecondMedia; + impl CommandMediaContext for SecondMedia { + fn attach_media(&mut self, _p: &Path) -> Result { + Err("unused".to_string()) + } + } + + let mut a = Presentation; + let mut b = SecondPresentation; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + CommandContexts::empty() + .with_presentation(&mut a) + .with_presentation(&mut b); + })); + assert!(result.is_err(), "duplicate presentation slot must assert"); + + let mut a = Media; + let mut b = SecondMedia; + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + CommandContexts::empty() + .with_media(&mut a) + .with_media(&mut b); + })); + assert!(result.is_err(), "duplicate media slot must assert"); +} diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 7a6a857bcc..bc3c8ad652 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -7,11 +7,14 @@ license.workspace = true repository.workspace = true description = "Config schema and precedence model for Codewhale" +[lints] +workspace = true + [dependencies] anyhow.workspace = true -codewhale-execpolicy = { path = "../execpolicy", version = "0.9.10" } -codewhale-paths = { path = "../paths", version = "0.9.10" } -codewhale-secrets = { path = "../secrets", version = "0.9.10" } +codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" } +codewhale-paths = { path = "../paths", version = "0.9.11" } +codewhale-secrets = { path = "../secrets", version = "0.9.11" } fd-lock = "4.0.4" libc = "0.2" serde.workspace = true diff --git a/crates/config/assets/models_dev.bundled.json b/crates/config/assets/models_dev.bundled.json index 6b1ff19eba..39101a2705 100644 --- a/crates/config/assets/models_dev.bundled.json +++ b/crates/config/assets/models_dev.bundled.json @@ -9,7 +9,7 @@ "curated": "qwen3.8-max (GA) is curated ahead of upstream Models.dev, which as of 2026-08-03 lists only qwen3.8-max-preview; facts verified against the owner's Token Plan console (2026-08-03): ~1M context, 128K output, image understanding, always-on reasoning. deepseek-v4-flash-0731 keeps the console/in-repo wire id for the row upstream serves as deepseek-v4-flash. Coding Plan rows for qwen3.8-max-preview, deepseek-v4-pro, deepseek-v4-flash-0731, and glm-5.2 are curated from the Token Plan upstream entries (upstream alibaba-coding-plan does not list them yet); the in-repo route layer already offers the same model set on both plans. Upstream provider ids alibaba-token-plan(-cn) / alibaba-coding-plan(-cn) were merged onto the CodeWhale provider ids (live refresh normalizes them via ProviderKind aliases; the -cn regional variants stay upstream-id browse rows until Codewhale ships China endpoints).", "pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.", "currency_sweep_2026_08_17": "Rows re-verified against official pages on 2026-08-17 (#5470 follow-up): gpt-5.6-terra 2.00/12.00 (cache read 0.20, write 2.50) and gpt-5.6-luna 0.20/1.20 (0.02/0.25) per developers.openai.com model pages; claude-sonnet-5 2.00/10.00 (0.20/2.50) is now Anthropic's standard price (the 2026-09-01 increase was cancelled) and claude-opus-5 5.00/25.00 (0.50/6.25) was added; kimi-k3 3.00/15.00 (0.30) and kimi-k2.7-code-highspeed 1.90/8.00 (0.38) per platform.kimi.ai; MiniMax-M2.7-highspeed 0.60/2.40 (0.06/0.375) per platform.minimax.io; grok-4.5 (500K) and grok-4.3 (1M) carry limits only because xAI doubles their rates past 200K (same rule as grok-4.6); OpenRouter dots-studio/dots-3-note-preview:free carries limits only (its single free endpoint publishes $0, which this seed does not restate as a price).", - "coverage": "20 providers, 89 model rows (offline seed only)." + "coverage": "20 providers, 90 model rows (offline seed only)." }, "models": { "deepseek-v4-pro": { @@ -61,6 +61,16 @@ "tool_call": true, "modalities": { "input": ["text"], "output": ["text"] }, "limit": { "context": 1000000, "output": 384000 } + }, + "deepseek-v4-flash-vision-exp": { + "id": "deepseek-v4-flash-vision-exp", + "base_model": "deepseek-v4-flash", + "name": "DeepSeek V4 Flash Vision (experimental)", + "family": "deepseek", + "reasoning": true, + "tool_call": true, + "modalities": { "input": ["text", "image"], "output": ["text"] }, + "limit": { "context": 1000000, "output": 384000 } } } }, diff --git a/crates/config/src/auto_model.rs b/crates/config/src/auto_model.rs index 12a5e6fe42..aa5f2aac67 100644 --- a/crates/config/src/auto_model.rs +++ b/crates/config/src/auto_model.rs @@ -249,8 +249,15 @@ pub fn score(prompt: &str) -> i32 { } } - // Length factor: long prompts tend to be more complex - let len = prompt.len(); + // Length factor: long prompts tend to be more complex. + // + // Counted in characters, not bytes, as the doc comment above states. Half + // the keyword lists here are Chinese, so CJK input is a first-class case — + // and every CJK character is three UTF-8 bytes, which made `prompt.len()` + // award the long-prompt bonus at a third of the documented length. A + // 200-character Chinese prompt scored +2 (600 bytes) and classified as + // complex, where the same-length English prompt scored 0. + let len = prompt.chars().count(); if len > 500 { score += 2; } else if len > 200 { @@ -355,6 +362,23 @@ mod tests { assert_eq!(classify("what is the capital of France?"), FLASH_MODEL); } + #[test] + fn length_bonus_counts_characters_not_utf8_bytes() { + // Keyword-free prompts of identical *length* must score identically + // regardless of script. "啊" is three UTF-8 bytes, so a byte-counted + // length factor gave the Chinese prompt a bonus the English one did + // not earn — and at 200 characters it flipped the classification. + let english = "a".repeat(200); + let chinese = "啊".repeat(200); + assert_eq!(score(&chinese), score(&english)); + assert_eq!(classify(&chinese), FLASH_MODEL); + + let english_long = "a".repeat(600); + let chinese_long = "啊".repeat(600); + assert_eq!(score(&chinese_long), score(&english_long)); + assert_eq!(classify(&chinese_long), PRO_MODEL); + } + #[test] fn test_score_never_negative() { // Even for very simple queries, score should be predictable diff --git a/crates/config/src/catalog.rs b/crates/config/src/catalog.rs index 51077e86b3..9d2246203e 100644 --- a/crates/config/src/catalog.rs +++ b/crates/config/src/catalog.rs @@ -711,7 +711,19 @@ fn secret_free_fingerprint_input(base_url: &str) -> String { .unwrap_or_default(); return normalize_base_url(&format!("{scheme}://{authority}{path}")); } - normalize_base_url(trimmed.split(['?', '#']).next().unwrap_or(REDACTED)) + // Scheme-less input still has an authority, and it can still carry + // `user:pass@` userinfo. Strip it exactly as the scheme branch does, so the + // digest input never contains a credential. + let without_query = trimmed.split(['?', '#']).next().unwrap_or_default(); + let authority_end = without_query.find('/').unwrap_or(without_query.len()); + let authority = &without_query[..authority_end]; + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + if authority.is_empty() { + return REDACTED.to_string(); + } + normalize_base_url(&format!("{authority}{}", &without_query[authority_end..])) } fn normalize_base_url(base_url: &str) -> String { diff --git a/crates/config/src/catalog/tests.rs b/crates/config/src/catalog/tests.rs index 1c0628ea8b..5d5c25c215 100644 --- a/crates/config/src/catalog/tests.rs +++ b/crates/config/src/catalog/tests.rs @@ -306,6 +306,73 @@ fn fingerprint_never_hashes_secret_bearing_url_text() { } } +#[test] +fn fingerprint_strips_userinfo_from_a_scheme_less_base_url() { + // A base_url typed without a scheme took the fall-through branch, which + // only split off `?`/`#` — so `user:pass@host` went into SHA-256 verbatim, + // against the documented "userinfo never enters the digest function". + let expected = base_url_fingerprint("api.example.com/v1"); + for url in [ + "user:secret@api.example.com/v1", + "user:other-secret@api.example.com/v1", + "token@api.example.com/v1", + ] { + assert_eq!(base_url_fingerprint(url), expected, "{url}"); + } +} + +#[test] +fn fingerprint_of_an_empty_base_url_is_the_redacted_constant() { + // The fall-through's `unwrap_or(REDACTED)` never fired — `split` always + // yields at least one (possibly empty) piece — so an empty base URL + // fingerprinted the empty string instead of the redacted sentinel. + let redacted = base_url_fingerprint("ftp://api.example.com"); + for url in ["", " ", "?api_key=secret"] { + assert_eq!(base_url_fingerprint(url), redacted, "{url:?}"); + } + // SHA-256("") is what empty/whitespace hashed to before the sentinel + // mapping. That digest is a persisted cache/receipt key, so flipping it + // back would be another undeclared persisted-key change. + const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + assert_ne!(redacted, EMPTY_SHA256); +} + +#[test] +fn changelog_declares_fingerprint_persisted_key_change() { + // `base_url_fingerprint` is serde-serialized (catalog cache, LiveOffering, + // pricing receipts, TurnRecord.routed_usage_source_ids). Empty input and + // scheme-less URLs with `@` hash differently than they did before + // 388125491. Before a release cut, Unreleased must say so; after the + // coordinated version bump, the current-version section owns the same + // declaration. An older release cannot satisfy this check, because stale + // caches would then look like corruption without a note for this build. + let changelog = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../CHANGELOG.md")); + let unreleased = changelog + .split_once("## [Unreleased]") + .expect("CHANGELOG has an Unreleased section") + .1 + .split_once("\n## [") + .expect("Unreleased is followed by a released section") + .0; + let current_heading = format!("## [{}]", env!("CARGO_PKG_VERSION")); + let current_release = changelog + .split_once(¤t_heading) + .map(|(_, tail)| tail.split("\n## [").next().unwrap_or(tail)) + .unwrap_or_default(); + let declared_change = format!("{unreleased}\n{current_release}"); + for needle in [ + "base_url_fingerprint", + "persisted-key", + "scheme-less", + "routed_usage_source_ids", + ] { + assert!( + declared_change.contains(needle), + "Unreleased or the current release section must declare the {needle} persisted-key change:\n{declared_change}" + ); + } +} + #[test] fn ttl_marks_entries_stale_and_excludes_them_from_fresh() { let fp = base_url_fingerprint("https://api.example.com"); diff --git a/crates/config/src/config_document.rs b/crates/config/src/config_document.rs index b7b8831da2..35dfca490d 100644 --- a/crates/config/src/config_document.rs +++ b/crates/config/src/config_document.rs @@ -60,9 +60,19 @@ where /// pass (existing top-level values always win; shadowed duplicates are /// dropped), until no literal `extras` table remains. Bounded passes keep a /// pathological file from looping. +/// +/// An `extras` key that is *not* table-like (a string, array, or number) has +/// nothing to lift, so it is left exactly where it is. Removing it would +/// delete user data this function cannot heal, on every subsequent write. pub fn heal_extras_nesting(document: &mut toml_edit::DocumentMut) -> bool { let mut healed = false; for _ in 0..16 { + if document + .get("extras") + .is_none_or(|item| !item.is_table_like()) + { + break; + } let Some(extras) = document .remove("extras") .and_then(|item| item.into_table().ok()) @@ -444,6 +454,51 @@ fn table_like_at_path_mut<'a>( #[cfg(test)] mod tests { + #[test] + fn healing_keeps_a_non_table_extras_key_it_cannot_lift() { + // `extras` is where the config structs flatten unknown keys, so a + // scalar or array under that exact name round-trips through the typed + // path as ordinary user data. Healing used to `remove()` it before + // discovering it was not a table, dropping it on the very next + // `codewhale config set` — and reporting `healed == false` while doing + // so. + for body in [ + "extras = \"opaque\"\nmodel = \"m\"\n", + "extras = [1, 2]\nmodel = \"m\"\n", + "model = \"m\"\nextras = 7\n", + ] { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("config.toml"); + std::fs::write(&path, body).expect("write fixture"); + + super::mutate_config_document(&path, |doc| { + super::set_config_document_value(doc, &["tui", "low_motion"], true) + }) + .expect("mutate"); + + let saved = std::fs::read_to_string(&path).expect("read"); + let parsed: toml::Value = toml::from_str(&saved).expect("parse"); + assert!( + parsed.get("extras").is_some(), + "non-table `extras` was deleted by an unrelated write: {saved}" + ); + assert!(saved.contains("low_motion = true"), "{saved}"); + } + } + + #[test] + fn healing_still_lifts_an_inline_extras_table() { + // The preservation guard above must not stop the real healing path: + // an inline table is table-like and still gets lifted. + let mut doc = "extras = { trust = true }\nmodel = \"m\"\n" + .parse::() + .expect("parse"); + assert!(super::heal_extras_nesting(&mut doc)); + let rendered = doc.to_string(); + assert!(rendered.contains("trust = true"), "{rendered}"); + assert!(!rendered.contains("extras"), "{rendered}"); + } + #[test] fn healing_lifts_nested_extras_towers_to_the_top_level() { let tmp = tempfile::tempdir().expect("tempdir"); diff --git a/crates/config/src/device_code.rs b/crates/config/src/device_code.rs new file mode 100644 index 0000000000..58b533d908 --- /dev/null +++ b/crates/config/src/device_code.rs @@ -0,0 +1,460 @@ +//! One RFC 8628 device-authorization polling loop, shared by every Codewhale +//! device-code flow (xAI/Grok device login, Codewhale account login). +//! +//! Ported from pi (), MIT licensed, +//! Copyright (c) 2025 Mario Zechner — see +//! `packages/ai/src/auth/oauth/device-code.ts` for the original +//! `pollOAuthDeviceCodeFlow`. The accumulated behaviours carried over from it: +//! +//! * the RFC 8628 §3.2 default of 5 seconds when the server omits `interval`; +//! * `slow_down` handling that **prefers a server-supplied interval** over the +//! client-tracked one. Trusting only the client-tracked value lets WSL/VM +//! clock drift poll early forever; RFC 8628 §3.5's +5s step is the fallback; +//! * a hard deadline derived from `expires_in`, never slept past even after +//! `slow_down` backoff; +//! * a distinct timeout message when at least one `slow_down` was seen, so the +//! clock-drift case is diagnosable instead of looking like a plain timeout. +//! +//! The loop is generic over the poll result and does no I/O of its own: the +//! caller supplies the poll and the sleep. Nothing here ever holds, formats, or +//! logs a token — `T` is opaque to this module and is never `Debug`-printed. + +use std::time::{Duration, Instant}; + +use anyhow::{Result, bail}; + +/// RFC 8628 §3.2: when the authorization server omits `interval`, clients must +/// poll no faster than every 5 seconds. +pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 5; +/// RFC 8628 §3.5: `slow_down` increases the polling interval by 5 seconds. +pub const SLOW_DOWN_STEP_SECS: u64 = 5; +/// Never poll faster than once a second, whatever the server asks for. +const MINIMUM_INTERVAL: Duration = Duration::from_secs(1); + +/// What one poll of the token endpoint told us. +/// +/// A terminal failure is reported by returning `Err` from the poll closure, so +/// each provider keeps its own error text. +pub enum DevicePollOutcome { + /// The user approved; `T` is the provider's parsed token material. + Complete(T), + /// `authorization_pending` — keep the current interval. + Pending, + /// `slow_down` — back off. `interval_seconds` is the server's new minimum + /// when it supplied one (preferred over the client-tracked interval). + SlowDown { interval_seconds: Option }, +} + +/// A configured device-code polling run. Build one, then [`DeviceCodePoll::run`]. +pub struct DeviceCodePoll { + interval: Duration, + max_interval: Option, + lifetime: Duration, + wait_before_first_poll: bool, + timeout_message: String, + slow_down_timeout_message: Option, +} + +impl DeviceCodePoll { + /// Start a run that gives up after `lifetime` with `timeout_message`. + /// + /// The interval starts at the RFC 8628 default of 5 seconds; callers pass + /// the server's `interval` through [`DeviceCodePoll::interval_seconds`]. + #[must_use] + pub fn new(lifetime: Duration, timeout_message: impl Into) -> Self { + Self { + interval: Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS), + max_interval: None, + lifetime, + wait_before_first_poll: false, + timeout_message: timeout_message.into(), + slow_down_timeout_message: None, + } + } + + /// Apply the server-advertised `interval`. `None` (or a zero/absent value, + /// which RFC 8628 permits) keeps the 5-second default. + #[must_use] + pub fn interval_seconds(mut self, seconds: Option) -> Self { + if let Some(seconds) = seconds.filter(|seconds| *seconds > 0) { + self.interval = self.clamp_interval(Duration::from_secs(seconds)); + } + self + } + + /// Cap the interval, including after `slow_down` backoff. + #[must_use] + pub fn max_interval_seconds(mut self, seconds: u64) -> Self { + self.max_interval = Some(Duration::from_secs(seconds.max(1))); + self.interval = self.clamp_interval(self.interval); + self + } + + /// Sleep one interval before the first poll. + /// + /// Device-code endpoints that answer `authorization_pending` (xAI) want + /// this; endpoints whose first response is already meaningful (the + /// Codewhale account service, which returns HTTP 202 while pending) poll + /// immediately and sleep afterwards. + #[must_use] + pub fn wait_before_first_poll(mut self, wait: bool) -> Self { + self.wait_before_first_poll = wait; + self + } + + /// Message used instead of the plain timeout message when the run saw at + /// least one `slow_down`. This is the WSL/VM clock-drift tell. + #[must_use] + pub fn slow_down_timeout_message(mut self, message: impl Into) -> Self { + self.slow_down_timeout_message = Some(message.into()); + self + } + + fn clamp_interval(&self, interval: Duration) -> Duration { + let interval = interval.max(MINIMUM_INTERVAL); + match self.max_interval { + Some(max) => interval.min(max), + None => interval, + } + } + + /// Poll until the flow completes, fails, or the deadline passes. + /// + /// `sleep` is injected so tests never wait in real time. `poll` returns + /// `Err` for any terminal failure (denied, expired, transport error). + pub fn run(self, mut sleep: S, mut poll: P) -> Result + where + S: FnMut(Duration), + P: FnMut() -> Result>, + { + let deadline = Instant::now() + self.lifetime; + let mut interval = self.interval; + let mut saw_slow_down = false; + + if self.wait_before_first_poll { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(self.timed_out(saw_slow_down)); + } + sleep(interval.min(remaining)); + } + + while Instant::now() < deadline { + match poll()? { + DevicePollOutcome::Complete(value) => return Ok(value), + DevicePollOutcome::Pending => {} + DevicePollOutcome::SlowDown { interval_seconds } => { + saw_slow_down = true; + // Prefer the server's new minimum when it gave one: a + // purely client-tracked interval polls early forever when + // the clock drifts (WSL, suspended VMs). + interval = match interval_seconds.filter(|seconds| *seconds > 0) { + Some(seconds) => self.clamp_interval(Duration::from_secs(seconds)), + None => { + self.clamp_interval(interval + Duration::from_secs(SLOW_DOWN_STEP_SECS)) + } + }; + } + } + + // Never sleep past the code's expiry, even after slow_down backoff. + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + sleep(interval.min(remaining)); + } + + Err(self.timed_out(saw_slow_down)) + } + + fn timed_out(&self, saw_slow_down: bool) -> anyhow::Error { + match (saw_slow_down, self.slow_down_timeout_message.as_deref()) { + (true, Some(message)) => anyhow::anyhow!("{message}"), + _ => anyhow::anyhow!("{}", self.timeout_message), + } + } +} + +/// Reject a device-code verification URI that must not be handed to a browser +/// opener. +/// +/// Ported from pi's `validateVerificationUri` +/// (`packages/ai/src/auth/oauth/xai.ts`, MIT, Copyright (c) 2025 Mario +/// Zechner): the URI comes straight off the wire and is passed to the platform +/// "open this" call, so a malicious or compromised response could otherwise +/// launch `file:`, a custom app scheme, or a helper with attacker-chosen +/// arguments. pi requires `https:`; Codewhale additionally allows `http:` on a +/// loopback host, which is what self-hosted issuers and the device-code tests +/// use — matching the loopback allowance the account login already makes. +/// +/// Embedded credentials are rejected in every case. +pub fn validate_browser_verification_uri(raw: &str, context: &str) -> Result { + let trimmed = raw.trim(); + let Ok(url) = url_scheme_and_host(trimmed) else { + bail!("{context} returned an unusable verification URI"); + }; + let (scheme, host, has_credentials) = url; + if has_credentials { + bail!("{context} returned a verification URI with embedded credentials"); + } + let allowed = scheme == "https" || (scheme == "http" && is_loopback_host(&host)); + if !allowed { + bail!("{context} returned an untrusted verification URI"); + } + Ok(trimmed.to_string()) +} + +/// Minimal scheme/host/credential split, so this module stays free of a URL +/// dependency (`codewhale-config` deliberately has no `reqwest`/`url`). +fn url_scheme_and_host(raw: &str) -> Result<(String, String, bool), ()> { + let (scheme, rest) = raw.split_once("://").ok_or(())?; + if scheme.is_empty() + || !scheme + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.') + { + return Err(()); + } + let authority = rest + .split(['/', '?', '#']) + .next() + .filter(|authority| !authority.is_empty()) + .ok_or(())?; + let (credentials, hostport) = match authority.rsplit_once('@') { + Some((credentials, hostport)) => (!credentials.is_empty(), hostport), + None => (false, authority), + }; + let host = match hostport.strip_prefix('[') { + // IPv6 literal: [::1]:8080 + Some(rest) => rest.split_once(']').ok_or(())?.0.to_string(), + None => hostport.split(':').next().ok_or(())?.to_string(), + }; + if host.is_empty() { + return Err(()); + } + Ok(( + scheme.to_ascii_lowercase(), + host.to_ascii_lowercase(), + credentials, + )) +} + +fn is_loopback_host(host: &str) -> bool { + if host == "localhost" || host == "::1" { + return true; + } + host.parse::() + .is_ok_and(|address| address.is_loopback()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + fn recording_sleep(log: &RefCell>) -> impl FnMut(Duration) + '_ { + move |duration| log.borrow_mut().push(duration) + } + + #[test] + fn completes_on_first_poll_without_waiting() { + let slept = RefCell::new(Vec::new()); + let value = DeviceCodePoll::new(Duration::from_secs(60), "timed out") + .run(recording_sleep(&slept), || { + Ok(DevicePollOutcome::Complete("token")) + }) + .expect("first poll completes"); + assert_eq!(value, "token"); + assert!(slept.borrow().is_empty(), "no sleep before the first poll"); + } + + #[test] + fn waits_one_interval_before_the_first_poll_when_asked() { + let slept = RefCell::new(Vec::new()); + DeviceCodePoll::new(Duration::from_secs(60), "timed out") + .interval_seconds(Some(3)) + .wait_before_first_poll(true) + .run(recording_sleep(&slept), || { + Ok(DevicePollOutcome::Complete(())) + }) + .expect("completes after the initial wait"); + assert_eq!(slept.borrow().as_slice(), [Duration::from_secs(3)]); + } + + #[test] + fn omitted_interval_uses_the_rfc_default_of_five_seconds() { + let slept = RefCell::new(Vec::new()); + let mut polls = 0; + DeviceCodePoll::new(Duration::from_secs(600), "timed out") + .interval_seconds(None) + .run(recording_sleep(&slept), || { + polls += 1; + if polls == 1 { + Ok(DevicePollOutcome::Pending) + } else { + Ok(DevicePollOutcome::Complete(())) + } + }) + .expect("completes"); + assert_eq!(slept.borrow().as_slice(), [Duration::from_secs(5)]); + } + + #[test] + fn slow_down_without_an_interval_adds_five_seconds() { + let slept = RefCell::new(Vec::new()); + let mut polls = 0; + DeviceCodePoll::new(Duration::from_secs(600), "timed out") + .interval_seconds(Some(2)) + .run(recording_sleep(&slept), || { + polls += 1; + match polls { + 1 => Ok(DevicePollOutcome::Pending), + 2 => Ok(DevicePollOutcome::SlowDown { + interval_seconds: None, + }), + _ => Ok(DevicePollOutcome::Complete(())), + } + }) + .expect("completes"); + assert_eq!( + slept.borrow().as_slice(), + [Duration::from_secs(2), Duration::from_secs(7)] + ); + } + + #[test] + fn slow_down_prefers_a_server_supplied_interval() { + // The clock-drift fix: the server's new minimum wins over the + // client-tracked interval, in both directions. + let slept = RefCell::new(Vec::new()); + let mut polls = 0; + DeviceCodePoll::new(Duration::from_secs(600), "timed out") + .interval_seconds(Some(2)) + .run(recording_sleep(&slept), || { + polls += 1; + match polls { + 1 => Ok(DevicePollOutcome::SlowDown { + interval_seconds: Some(30), + }), + _ => Ok(DevicePollOutcome::Complete(())), + } + }) + .expect("completes"); + assert_eq!(slept.borrow().as_slice(), [Duration::from_secs(30)]); + } + + #[test] + fn interval_never_drops_below_one_second_or_exceeds_the_cap() { + let slept = RefCell::new(Vec::new()); + let mut polls = 0; + DeviceCodePoll::new(Duration::from_secs(600), "timed out") + .interval_seconds(Some(0)) + .max_interval_seconds(10) + .run(recording_sleep(&slept), || { + polls += 1; + match polls { + 1 => Ok(DevicePollOutcome::SlowDown { + interval_seconds: Some(99), + }), + _ => Ok(DevicePollOutcome::Complete(())), + } + }) + .expect("completes"); + // interval 0 falls back to the RFC default (5s), capped at 10s. + assert_eq!(slept.borrow().as_slice(), [Duration::from_secs(10)]); + } + + #[test] + fn never_sleeps_past_the_deadline() { + let slept = RefCell::new(Vec::new()); + let error = DeviceCodePoll::new(Duration::from_millis(30), "timed out") + .interval_seconds(Some(600)) + .run( + |duration| { + slept.borrow_mut().push(duration); + std::thread::sleep(duration); + }, + || Ok(DevicePollOutcome::<()>::Pending), + ) + .expect_err("deadline stops the loop"); + assert_eq!(error.to_string(), "timed out"); + for duration in slept.borrow().iter() { + assert!( + *duration <= Duration::from_millis(30), + "slept {duration:?} past a 30ms deadline" + ); + } + } + + #[test] + fn a_terminal_poll_error_stops_immediately() { + let slept = RefCell::new(Vec::new()); + let error = DeviceCodePoll::new(Duration::from_secs(600), "timed out") + .run(recording_sleep(&slept), || { + Err::, _>(anyhow::anyhow!("access_denied")) + }) + .expect_err("terminal errors propagate"); + assert_eq!(error.to_string(), "access_denied"); + assert!(slept.borrow().is_empty()); + } + + #[test] + fn timing_out_after_slow_down_reports_the_clock_drift_message() { + let error = DeviceCodePoll::new(Duration::from_millis(5), "plain timeout") + .interval_seconds(Some(1)) + .slow_down_timeout_message("clock drift timeout") + .run(std::thread::sleep, || { + Ok(DevicePollOutcome::<()>::SlowDown { + interval_seconds: None, + }) + }) + .expect_err("deadline stops the loop"); + assert_eq!(error.to_string(), "clock drift timeout"); + } + + #[test] + fn timing_out_without_slow_down_reports_the_plain_message() { + let error = DeviceCodePoll::new(Duration::from_millis(5), "plain timeout") + .interval_seconds(Some(1)) + .slow_down_timeout_message("clock drift timeout") + .run(std::thread::sleep, || Ok(DevicePollOutcome::<()>::Pending)) + .expect_err("deadline stops the loop"); + assert_eq!(error.to_string(), "plain timeout"); + } + + #[test] + fn verification_uri_must_be_https_or_loopback_http() { + assert_eq!( + validate_browser_verification_uri("https://accounts.x.ai/device", "xAI").unwrap(), + "https://accounts.x.ai/device" + ); + assert!(validate_browser_verification_uri("http://127.0.0.1:8080/verify", "xAI").is_ok()); + assert!(validate_browser_verification_uri("http://localhost/verify", "xAI").is_ok()); + assert!(validate_browser_verification_uri("http://[::1]:9/verify", "xAI").is_ok()); + + for hostile in [ + "http://accounts.x.ai/device", + "file:///etc/passwd", + "javascript:alert(1)", + "vscode://attacker/run", + "data:text/html,