Merge branch 'main' into up/task-human-wait-wallclock

This commit is contained in:
Hunter Bown authored and GitHub committed 2026-11-08 03:39:25 +08:00
commit ac9a30319a
305 files changed
+13161 -3426

No files matched your search

+2
View File
@@ -242,6 +242,8 @@ huangxianzhan = huangxianzhan <[email protected]>
[email protected] = huangxianzhan <[email protected]>
zhuowp = zhuowp <[email protected]>
[email protected] = zhuowp <[email protected]>
z27014 = zhuowp <[email protected]>
[email protected] = zhuowp <[email protected]>
[email protected] = 秋月凉梦 <[email protected]>
asto18089 = asto18089 <[email protected]>
+2 -2
View File
@@ -954,12 +954,12 @@ jobs:
run: cargo build -p codewhale-cli --bin codewhale --all-features --locked
env:
RUSTC_WRAPPER: ${{ matrix.os != 'windows-latest' && env.RUSTC_WRAPPER || '' }}
- name: Windows Terminal clipboard acceptance
- name: Windows Terminal clipboard-to-draft acceptance
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
timeout-minutes: 5
shell: pwsh
run: node scripts/release/windows-terminal-clipboard.mjs target/debug/codewhale.exe "${{ runner.temp }}/windows-clipboard"
- name: Retain Windows Terminal clipboard evidence
- name: Retain Windows Terminal clipboard-to-draft evidence
if: ${{ !cancelled() && matrix.os == 'windows-latest' && needs.changes.outputs.heavy == 'true' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1
with:
node-version: '22'
- uses: codewhale-hq/Codewhale@b92540d882b8e7c66389aa35e5fa0c3c178fe4ce
- uses: codewhale-hq/Codewhale@b0b2b9a335cc1d24abe8a087189bed080d1cc3db
id: review
with:
version: 'v0.10.2'
@@ -107,7 +107,7 @@ jobs:
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
permission-contents: read
permission-pull-requests: write
- uses: codewhale-hq/Codewhale/scripts/github-action/publish@b92540d882b8e7c66389aa35e5fa0c3c178fe4ce
- uses: codewhale-hq/Codewhale/scripts/github-action/publish@b0b2b9a335cc1d24abe8a087189bed080d1cc3db
id: publish
with:
findings: findings/findings.json
+1 -1
View File
@@ -86,7 +86,7 @@ jobs:
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
- uses: actions/setup-java@v6.1.0
with:
distribution: temurin
java-version: '17'
+3 -3
View File
@@ -270,7 +270,7 @@ jobs:
mirror: true
- name: Install Java for the OpenHarmony binary signer
if: matrix.platform == 'ohos-arm64'
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4
uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v4
with:
distribution: temurin
java-version: '17'
@@ -359,12 +359,12 @@ jobs:
bin_dir="target/${{ matrix.target }}/dist"
"${bin_dir}/${{ matrix.cli_binary }}" --version
"${bin_dir}/${{ matrix.shim_binary }}" --version
- name: Windows Terminal clipboard acceptance
- name: Windows Terminal clipboard-to-draft acceptance
if: matrix.platform == 'windows-x64'
timeout-minutes: 5
shell: pwsh
run: node scripts/release/windows-terminal-clipboard.mjs "target/${{ matrix.target }}/dist/codewhale.exe" "${{ runner.temp }}/windows-clipboard"
- name: Retain Windows Terminal clipboard evidence
- name: Retain Windows Terminal clipboard-to-draft evidence
if: ${{ !cancelled() && matrix.platform == 'windows-x64' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
+116 -4
View File
@@ -7,17 +7,91 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Fixed
- **Website:** recovered warm paper, clear-blue actions and original drawn water accents; added Computer Use and automatically refreshed terminal/plugin release links; shortened copy, clarified contribution paths and checked setup guidance against published versions.
## [0.10.3] - 2026-11-06
This is the Codewhale v0.10.3 source candidate. It is not a published release
until the matching tag, packages, checksums, and release assets exist.
### Work in progress and known limits
- **Codex App Server compatibility:** Codewhale is working toward full Codex
wire parity on the existing Engine. The current HTTP/SSE Runtime remains
available; full-tool turns, subscriptions, bidirectional interactions, durable
settlements, TCP WebSocket and the complete SDK remain unfinished.
- **DSH plugins and MCP lifecycle:** compatibility with unmodified DeepSeek
Harness plugins remains in progress. The source candidate restores missing
runtime snapshots only from canonical bytes matching the current review,
generation and policy. Existing mismatched or tampered snapshots refuse
and require review. Native lifecycle qualification, authenticated DSH UI
transport and revocation in Electron, and an end-to-end published-plugin
journey remain unfinished.
Imported DSH client UIs refuse descriptor and asset grants; their supported
Native and MCP contributions retain their existing lifecycle.
Downgrading a newly saved plugin marketplace catalog remains unqualified.
- **ChatGPT sign-in on Windows:** the reported 0.10.2 post-browser identity
verification failure has not been confirmed resolved in the reporter's
environment. More specific errors and clock-skew handling are not a
successful Windows sign-in receipt. ([#7203](https://github.com/codewhale-hq/Codewhale/issues/7203))
- **Conversation recovery on Deepin:** scoped interrupted-reply recovery and
persistence checks do not establish a fix for the original whole-history
disappearance on Deepin 25.2.2. ([#7095](https://github.com/codewhale-hq/Codewhale/issues/7095))
- **Windows ACP and undo:** the original Paseo approval/sandbox journey and
ordinary multi-turn `/undo` through Windows ConPTY remain unqualified on the
final candidate. macOS and simulated checks do not establish those Windows
results. ([#6992](https://github.com/codewhale-hq/Codewhale/issues/6992), [#6970](https://github.com/codewhale-hq/Codewhale/issues/6970))
- **Install and update routes:** final-candidate Windows and Linux package,
install, update, rollback and uninstall coverage is incomplete. Earlier
scoped platform receipts do not qualify the final package matrix or ordinary
Windows user journeys. ([#7091](https://github.com/codewhale-hq/Codewhale/issues/7091))
- **Workspace downgrade and rollback:** compatibility with older binaries after
a 0.10.3 workspace migration remains unqualified. The CLI self-updater guard
refuses recorded targets below 0.10.3 in `codewhale update --rollback` when
running 0.10.3 or newer, before replacing the executable. Five isolated
macOS development-candidate refusal/control cases passed with fixture trees
unchanged. This does not provide backward-compatible rollback or qualify
actual workspace migration or an allowed rollback. Manual or package-manager
downgrades and older builds reporting the same version remain outside the guard.
- **Long-running ZAI sessions:** scoped watchdog and recovery checks do not
establish a fix for the original hours-long, tool-heavy silent stall.
([#6184](https://github.com/codewhale-hq/Codewhale/issues/6184))
### Added
- **Task wall clock pauses for answerable human waits:** a durable task can opt into moving its wall deadline while a prompt on that run awaits a decision, bounded by a 24-hour aggregate cap and a distinct `human_wait_timeout` reason. The app-server opts in; the TUI default is unchanged. (#5898)
- **Inspect and repair plugin snapshots:** `codew plugins status [name]
[--json]` reports bundle state and diagnostics without changing plugin
stores. Runtime loading restores a missing snapshot only from bytes matching
its enabled review and policy; `codew plugins restage <name>` or
`/plugin restage <name>` retries explicitly. Recovery preserves review and
generation and refuses an existing changed snapshot. Isolated macOS checks
exercised the published Code Review 1.0.8 Native status lifecycle and official
filesystem MCP reconnect; live review execution and other plugins remain
unqualified.
- **Shared optional account sign-in:** `/connect` offers Codewhale sign-in or
on-computer provider setup. Desktop and terminal use the same profile-scoped
Rust account session; sign-in preserves the selected model and local keys.
Ordinary logout removes the account session, while `logout --all` explicitly
clears saved model credentials. Startup prompts keep missing-model setup
visible instead of covering it with the optional working-agreement checkpoint.
Real sign-in acceptance remains open.
- **Refresh model catalogs during use:** Runtime provider refresh now checks
public Models.dev metadata alongside the exact provider's model list;
`force_catalog=true` bypasses the public cache TTL and retry backoff.
`GET /v1/providers` exposes catalog revision and freshness so clients can
pick up new models without repeatedly loading an unchanged catalog. The
runtime rechecks in the background, retains the last good snapshot on
failure, and preserves explicit model selections and account/route scope.
- **Tinker beta route:** configure Thinking Machines Tinker's OpenAI-compatible
endpoint and a serverless model or sampler checkpoint ID. Model-list discovery,
key verification through that list, and cost estimates remain unavailable;
live conversations, tools and image input have not been qualified in
Codewhale. See [provider setup](docs/PROVIDERS.md).
- **Worktrees for parallel workspaces:** `GET /v1/worktrees` lists a repository's worktrees, `POST /v1/worktrees` creates a branch and its own checkout under `<repository>/.codewhale/worktrees/` through the existing lane provisioning, and `POST /v1/worktrees/remove` archives a clean managed one without `--force`, keeping the branch. A client starts a thread in the new folder, so several agents can work on one project without sharing files. (codewhale-hq/codewhale-app#3500)
- **Thread-scoped snapshots:** `GET /v1/snapshots` and `POST /v1/snapshots/{id}/restore` accept `?thread_id=` and act on that thread's workspace under the same restore admission rule. (codewhale-hq/codewhale-app#3500)
- **DSH plugins can carry their own client UI:** a reviewed DSH plugin closure may now ship its own Node/Bun dependency tree, CommonJS/CTS entries and UI-only source bundles. The Engine exposes a trusted and enabled plugin's client entry and assets through two loopback-only, read-only routes with bounded sizes, confined paths and safe MIME/no-store responses; no other plugin route gains an unauthenticated path. (#2, #3)
- **Reviewed plugin UI assets (preview):** native plugin bundles with supported client entries can expose reviewed assets through the Engine's loopback descriptor and asset routes. Runtime and workspace authorization are required; exact asset bytes also require the current opaque grant. Responses keep bounded sizes, confined paths and safe MIME/no-store behavior. The desktop host binds grants to individual plugin frames; real Electron transport and teardown qualification remains open. Imported DSH client halves remain unsupported and refuse UI grants. (#2, #3)
- **Present final files:** the read-only `present` tool takes 1–32 existing
workspace files, streams their SHA-256 and byte counts, and returns an
auditable handoff receipt without copying, moving or uploading them. It is
@@ -79,7 +153,7 @@ until the matching tag, packages, checksums, and release assets exist.
- **Change mode and permissions while the agent works:** Tab, Shift+Tab, `/mode`, the hotbar and `/config mode` no longer refuse while a turn is running. A response that is already streaming is not restarted; the change applies at the agent's next step in the same turn (before its next tool batch, at every approval gate, and before its next model request, which carries the new mode notice). The status line says so until it applies. A call still waiting for approval goes back to the model under the new posture, and a call you already approved runs only if the new posture is equal or broader. Model, thinking level and provider stay locked until the turn ends.
- **Steering interrupts thinking:** a steer (Ctrl+Enter, or Enter twice after queueing) now closes the in-flight model request at once, whether the model is waiting on headers, reasoning, writing text or emitting a tool call, and continues the same turn with your message. Text already shown is kept as interrupted output; unfinished reasoning and partial tool calls are discarded, never run.
- **Modern MCP client:** MCP servers now connect through the official MCP SDK (2.4) behind Codewhale's Rust broker by default (`mcp_backend = "host"`; `"rust"` stays available for one release). Servers that speak the 2026-07-28 revision are discovered with `server/discover`; other stdio and HTTP servers fall back to the legacy `initialize` handshake, and configured SSE servers keep theirs. Rust keeps process, network, credential, catalog, approval and cancellation authority.
- **DeepSeek Harness plugins:** reviewed DSH plugins (definition API pinned to DSH 0.2.1-rc.5 in a shared compatibility table) run in the Native extension host. Tool output is validated against its declared schema, unsupported options are refused instead of ignored, and plugin commands can submit a prompt with visible plugin attribution. Terminal command receipts are kept in thread snapshots without entering model history. Changing the compatibility table invalidates earlier trust. A newly enabled plugin's tools are offered on the first turn: the turn waits up to 10 seconds for the plugin host to finish loading instead of adding them only from the next turn. Plugin UI views stay disabled, and the import receipt says why: WebKit's network restrictions do not stop WebRTC, so plugin UI waits for an isolated renderer. A plugin tool can call `ctx.sessions.flush(exec.agent.session)` to have the Engine save the conversation so far before it continues (Runtime, terminal and `exec` sessions); it reports failure instead of claiming a save. Installing a plugin whose bytes changed after review now returns a 409 conflict instead of a server error. A plugin tool can ask you a question with `ctx.userQuestions.ask` and gets back the options you picked, your own text, or a skip; the question shows like any other Codewhale question in the terminal and through the Runtime API. A plugin tool can start a child agent with `ctx.subagents.startActivation` (the `spawn` provider): the child runs through Codewhale's own sub-agent manager, needs the same approval as the model's `agent` tool, can carry an extra persona section and a narrower tool list set in its `agent/created` handler, and returns its result to the plugin.
- **DeepSeek Harness plugins:** reviewed DSH plugins (definition API pinned to DSH 0.2.1-rc.5 in a shared compatibility table) run in the Native extension host. Tool output is validated against its declared schema, unsupported options are refused instead of ignored, and plugin commands can submit a prompt with visible plugin attribution. Terminal command receipts are kept in thread snapshots without entering model history. Changing the compatibility table invalidates earlier trust. A newly enabled plugin's tools are offered on the first turn: the turn waits up to 10 seconds for the plugin host to finish loading instead of adding them only from the next turn. The importer reports that it does not convert `dsh.client` UI; Engine routes and a desktop slot exist, but isolated end-to-end plugin UI remains unqualified, including network containment. A plugin tool can call `ctx.sessions.flush(exec.agent.session)` to have the Engine save the conversation so far before it continues (Runtime, terminal and `exec` sessions); it reports failure instead of claiming a save. Installing a plugin whose bytes changed after review now returns a 409 conflict instead of a server error. A plugin tool can ask you a question with `ctx.userQuestions.ask` and gets back the options you picked, your own text, or a skip; the question shows like any other Codewhale question in the terminal and through the Runtime API. A plugin tool can start a child agent with `ctx.subagents.startActivation` (the `spawn` provider): the child runs through Codewhale's own sub-agent manager, needs the same approval as the model's `agent` tool, can carry an extra persona section and a narrower tool list set in its `agent/created` handler, and returns its result to the plugin.
- **MCP catalogs stay fresh:** a server's tools, resources, prompts and skills are rediscovered on their own connection when an advertised `ttlMs` expires or the server sends a list-changed notice. A tool resolved against the old catalog is refused with "retry the call", a removed tool cannot run, and a failed refresh reconnects instead of serving stale entries. Catalogs are never shared across servers, credentials or reconnects.
- **MCP Skills extension:** servers that advertise the published Skills extension have their skills listed in `list_mcp_resources`. Listing never activates a skill, and reading a skill file is refused unless its size and sha256 digest match the listing. MCP skills do not yet join `/skills`.
- **Updates install themselves:** release-binary installs check for a new release every 6 hours (was 1), download and checksum-verify it in the background, and apply it the next time `codewhale` starts, never during a session. One line says `Updated to v… · What's new: … · Undo: codewhale update --rollback`. Until releases are signed, the download waits for your OK (one `[y/N]` question at the next start, or `/update install`); a release with a valid Ed25519 manifest signature applies on its own. `codewhale update --rollback` puts back the version the last update replaced and keeps the rolled-back version from returning automatically. Turn it down with `codewhale config set update.mode notify` (or `off`, or `CODEWHALE_UPDATE_MODE`); an organization's root-owned `policy.toml` overrides both. Offline and rate-limited checks retry about an hour later instead of on every launch. Package-manager and desktop-app copies only get their manager's command. Builds with the release key refuse a missing or invalid signature, and re-check the staged binary against the signed manifest at start. The checksum manifest now always comes from GitHub Releases; on Linux x64 the CNB mirror can still serve the binary, but only bytes that GitHub's manifest lists.
@@ -108,6 +182,10 @@ until the matching tag, packages, checksums, and release assets exist.
### Changed
- **Brighter terminal working states:** the Ocean palette has stronger accents,
and active work uses a restrained left-to-right text sweep. Quiet and
reduced-motion settings keep stable working indicators. The shared
`codewhale-ratatui` kit carries the same palette and motion updates.
- **Managed ACP requirements outrank Full Access:** when organization
requirements manage approval policy, ACP no longer lets `--full-access`
override them. The TUI already locked YOLO this way; ACP now matches.
@@ -139,7 +217,7 @@ until the matching tag, packages, checksums, and release assets exist.
- **`codewhale-tui-kit` crate (internal):** terminal rendering helpers (markdown, diff, text width, paste burst, OSC-8, streaming) move out of `codewhale-tui` into a new crate, and `syntect` is no longer a direct `codewhale-tui` dependency. No behavior change. (S52, #5316)
- **Terminal kit on design tokens 2.3.0:** the vendored `codewhale-ratatui` now follows design tokens 2.3.0: an `Accent` role carries the one blue for links, focus and selection, and `Primary` is the ink of filled actions (upstream codewhale-hq/codewhale-ratatui 528a9c4). The built-in TUI themes keep their colours; only surfaces drawn in the kit's own roles take the 2.3.0 values.
- **Terminal kit on design tokens 5.0.0:** the vendored `codewhale-ratatui` reuses the current shared blue, Ocean grounds and contrast-checked state colors (upstream codewhale-hq/codewhale-ratatui 8311b65). Primary actions use blue fill with white text; raised panels keep a visible edge where 256-color backgrounds collapse. Built-in TUI palettes retain their own overrides, and existing working-motion and reduced-motion guards remain in place.
- **Fleet workers run the same Codewhale:** `codewhale fleet run` and Runtime API Fleet runs (`POST /v1/fleet/runs`) now start workers from the running `codewhale` binary instead of whichever `codewhale` is first on `PATH`, so they work with a minimal `PATH` and never pick up a stale copy. `CODEWHALE_FLEET_CODEWHALE_BINARY` still overrides it. (#7119)
- **Switching provider lands on its current model:** `/provider stepfun` now selects Step 5 Preview instead of the older Step 3.7 Flash; a model you saved for a provider is still kept. `/model` now lists the current provider's own models (bundled and live, newest first, up to 20) instead of only the selected one. Search also matches ids typed without separators (`step5`). While you search, Shift+P and Shift+F type their letters rather than pinning or adding to Fleet, so `Step-5-Preview` can be typed in full; Ctrl+P and Ctrl+F pin or add the highlighted row at any time.
- **First run without a key:** `/provider` now opens on the full provider list (DeepSeek and the other cloud providers) when only a keyless local runtime is set up, no longer shows a disabled Antigravity row that was never configured, and Shift+A toggles the view like `a`. A local Ollama model that is not loaded yet is loaded before the first turn, so the route uses its real context window (for example 32K for `qwen3:4b`) instead of an 8K fallback that refused the second message with "~5120 tokens usable"; the footer's context reading follows the same window. The TUI resolves an unknown Ollama default to an installed chat model on startup. If the daemon or chat model appears later, selecting `/provider ollama` retries discovery. Embedding-only tags keep the model unresolved. An installed Ollama model is listed in `/model` and `GET /v1/providers/ollama/models` once its own catalog has answered, even when another provider is active. (#7113, #7114, #7116)
@@ -197,6 +275,39 @@ until the matching tag, packages, checksums, and release assets exist.
### Fixed
- **Host-provided model clients:** keep configured session isolation and
approval deadlines when optional credential preflight fails.
- **Runtime SDK exports:** include the existing thread-scoped Git, snapshot,
worktree and job-operation capabilities in the committed schema and types.
- **Plugin enablement revalidates its review:** enabling rechecks the staged
snapshot and, under the existing state lock, refuses a changed review
generation or trust receipt. The macOS stale-enable check passed (1/1);
a separate Linux run reported 202 passed and one ignored on an earlier base
with matching plugin files and lockfile. Snapshot checking is point-in-time;
missing-stage recovery reuses the current receipt and policy. Filesystem
race coverage and final-candidate native qualification remain open.
- **Plugin status and UI admission:** an enabled, trusted plugin whose reviewed
runtime snapshot is missing or invalid now reports
`runtime-snapshot-unavailable`. Both plugin UI metadata and assets require a
declared DSH client; ordinary plugins cannot expose staged files through the
client UI route. Missing snapshots recover only from receipt-matching
canonical source; authenticated assets require an exact current grant.
Native and Electron qualification remains open; existing modified snapshots
require review.
- **DSH package-directory imports:** the existing offline reviewer uses npm's
version-range parser with the pinned DeepSeek host's prerelease policy, and
bare self-package exports resolve inside the selected package directory.
Invalid or incompatible ranges and paths outside the reviewed package still
refuse. Real published-plugin activation remains unqualified.
- **Coordination lock diagnostics (#7232):** stale lock-file contents no longer
identify an owner PID. A refused legacy admission keeps its reason in Work
and shows one notice per cause instead of repeating cleanup warnings on
every sidebar refresh. Legacy migration still requires the actual holder to
exit and the refused owning Runtime to restart; locks and retained state
remain protected.
- **A local proxy that stops mid-session no longer strands the session (#7231):** the model, catalog, search, vision, finance, MCP-registry, profile and remote-control clients watch a loopback proxy port and go direct while it refuses connections, then resume proxying when it returns. Only a definite refusal from every resolved address changes the route; remote proxies, `NO_PROXY` matches, credentials and the existing precedence are unchanged. Set `CODEWHALE_LOCAL_PROXY_FALLBACK=0` to keep the old build-time snapshot.
- **Proxy diagnostic privacy ([#7233](https://github.com/codewhale-hq/codewhale/issues/7233)):** malformed MCP proxy warnings omit configured values; local-proxy log labels mask userinfo and omit URL paths, queries and fragments. Routing is unchanged. All 23 existing proxy tests passed on macOS; runtime log capture and other platforms remain unqualified. Thanks [@SparkofSpike](https://github.com/SparkofSpike) for the report.
- **Fork, undo and retry work after a workspace drift notice (#7234):** the history-boundary walks now treat a whole `<context_update>` envelope as engine-owned, so a session that received one can be cut at every saved turn. A person quoting a notice or a truncated envelope still counts as their own prompt.
- **Git writes land in the right workspace:** stage, unstage, discard, commit, push and branch take `?thread_id=` and run in that thread's recorded workspace, matching the read routes. Before, they always ran in the server's own folder, so a commit asked for a thread in another folder could land in the wrong tree.
- **ChatGPT sign-in tolerates a small device clock skew:** identity-token expiry and not-before checks allow the same 60-second tolerance already used for ordinary network time drift, while signature, issuer, audience, subject and nonce checks remain exact. The diagnostic text still names the exact failed check when a token is genuinely invalid.
- **Remaining long-blocking work moved off async callers (#6553):** route
@@ -468,6 +579,7 @@ until the matching tag, packages, checksums, and release assets exist.
### Contributors
- **[@zhuowp](https://github.com/zhuowp)** (git author `z27014`) — added bounded HTTP rate-limit recovery and corrected retry deadlines, quota guidance and recovery receipts ([#7220](https://github.com/codewhale-hq/Codewhale/pull/7220)).
- **[@printf033](https://github.com/printf033)** — enabled exclusive rename on Android so Fleet publication does not fall back to SELinux-denied hard links (`fbe9f24ee`).
- **[@liuyy2](https://github.com/liuyy2)** — diagnosed case-sensitive model selection collisions in the provider picker ([#7214](https://github.com/codewhale-hq/Codewhale/issues/7214)).
- **[@isaacs](https://github.com/isaacs)** — upstream minimatch preprocessing and glob cancellation fixes reused in compatible web build dependency backports.
Generated
+35 -63
View File
@@ -116,7 +116,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -127,7 +127,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -845,6 +845,7 @@ name = "codewhale-account-cli"
version = "0.10.3"
dependencies = [
"anyhow",
"chrono",
"clap",
"codewhale-config",
"codewhale-release",
@@ -888,6 +889,7 @@ dependencies = [
"base64 0.23.1",
"chrono",
"clap",
"codewhale-account-cli",
"codewhale-agent",
"codewhale-agent-contract",
"codewhale-app-server-protocol",
@@ -1027,7 +1029,6 @@ dependencies = [
"codewhale-release",
"libc",
"reqwest",
"semver",
"serde",
"serde_json",
"tempfile",
@@ -1166,7 +1167,6 @@ dependencies = [
"codewhale-util",
"codewhale-workflow",
"codewhale-workflow-js",
"crossterm",
"cucumber",
"dirs 7.0.0",
"encoding_rs",
@@ -1188,17 +1188,14 @@ dependencies = [
"portable-pty",
"pretty_assertions",
"proc-macro2",
"ratatui",
"regex",
"reqwest",
"ring",
"rio-vt",
"rmcp",
"rquickjs",
"rusqlite",
"rustls",
"schemars",
"semver",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -1216,7 +1213,6 @@ dependencies = [
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
"unicode-normalization",
@@ -1443,7 +1439,6 @@ dependencies = [
"thiserror 2.0.21",
"tokio",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
"tracing",
"uuid",
"webbrowser",
@@ -1646,6 +1641,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.21",
"tracing",
"uuid",
]
[[package]]
@@ -1708,7 +1704,6 @@ dependencies = [
"sha2 0.11.1",
"tempfile",
"tracing",
"wait-timeout",
]
[[package]]
@@ -1764,7 +1759,6 @@ dependencies = [
name = "codewhale-tui"
version = "0.10.3"
dependencies = [
"ahash",
"ansi-to-tui",
"anyhow",
"arboard",
@@ -1774,10 +1768,9 @@ dependencies = [
"base64 0.23.1",
"chrono",
"clap",
"codewhale-agent",
"codewhale-account-cli",
"codewhale-agent-contract",
"codewhale-app-server",
"codewhale-app-server-protocol",
"codewhale-build-support",
"codewhale-cloud-facts",
"codewhale-command-contract",
@@ -1793,7 +1786,6 @@ dependencies = [
"codewhale-localization",
"codewhale-lsp",
"codewhale-mcp",
"codewhale-memory",
"codewhale-models",
"codewhale-net",
"codewhale-notify",
@@ -1803,7 +1795,6 @@ dependencies = [
"codewhale-protocol",
"codewhale-ratatui",
"codewhale-release",
"codewhale-repl",
"codewhale-runtime",
"codewhale-sandbox",
"codewhale-sanitize",
@@ -1816,38 +1807,27 @@ dependencies = [
"codewhale-tui-kit",
"codewhale-util",
"codewhale-workflow",
"codewhale-workflow-js",
"colored",
"crossterm",
"cucumber",
"dirs 7.0.0",
"dotenvy",
"encoding_rs",
"fd-lock",
"flate2",
"futures-util",
"globset",
"htmd",
"ignore",
"image",
"jsonschema",
"libc",
"liblzma",
"lru",
"oauth2",
"objc2",
"objc2-foundation",
"parking_lot",
"portable-pty",
"pretty_assertions",
"proc-macro2",
"qrcode",
"ratatui",
"regex",
"reqwest",
"ring",
"rio-vt",
"rmcp",
"rquickjs",
"rusqlite",
"rustls",
@@ -1856,24 +1836,17 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.1",
"shell-words",
"shellexpand",
"shlex 2.0.1",
"similar",
"syn 3.0.9",
"tar",
"tempfile",
"thiserror 2.0.21",
"tiny_http",
"tokio",
"tokio-tungstenite 0.30.0",
"tokio-util",
"toml 1.1.6+spec-1.1.0",
"toml_edit 0.25.15+spec-1.1.0",
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
"unicode-normalization",
"unicode-segmentation",
"unicode-width",
"url",
@@ -1884,7 +1857,6 @@ dependencies = [
"windows",
"windows-sys 0.61.2",
"wiremock",
"yaml-rust2",
]
[[package]]
@@ -1974,7 +1946,7 @@ version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2498,7 +2470,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -2639,7 +2611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -2742,7 +2714,7 @@ checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4594,9 +4566,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
@@ -4822,7 +4794,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "536bfad37a309d62069485248eeaba1e8d9853aaf951caaeaed0585a95346f08"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4935,7 +4907,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -5216,7 +5188,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6101,9 +6073,9 @@ dependencies = [
[[package]]
name = "rmcp"
version = "3.7.0"
version = "3.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ece64e19631fc38758181e637c15509a2c2709ee53bfde1e1d58f9dc991f919e"
checksum = "f8be15815186873ea063bf005011d16c356804973489e40c39116ae3a2878d1f"
dependencies = [
"async-trait",
"chrono",
@@ -6185,9 +6157,9 @@ dependencies = [
[[package]]
name = "rusqlite"
version = "0.40.2"
version = "0.41.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
dependencies = [
"bitflags 2.13.1",
"fallible-iterator",
@@ -6288,7 +6260,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6344,7 +6316,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -6534,7 +6506,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8"
dependencies = [
"annotate-snippets",
"base64 0.22.1",
"base64 0.23.1",
"encoding_rs_io",
"granit-parser",
"nohash-hasher",
@@ -6833,9 +6805,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "similar"
version = "3.2.0"
version = "3.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987"
checksum = "d2f8bec09ae8fae0f4f265a4559a9334be3929d8504e9b536df0a41302bd1733"
dependencies = [
"bstr",
"unicode-segmentation",
@@ -6883,7 +6855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7106,10 +7078,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7131,7 +7103,7 @@ dependencies = [
"parking_lot",
"rustix",
"signal-hook 0.3.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7141,7 +7113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874"
dependencies = [
"rustix",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7741,7 +7713,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -7770,9 +7742,9 @@ checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-normalization"
version = "0.1.25"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
@@ -8142,9 +8114,9 @@ dependencies = [
[[package]]
name = "webpki-roots"
version = "1.0.10"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
@@ -8249,7 +8221,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
+1 -2
View File
@@ -91,7 +91,7 @@ reqwest = { version = "0.13.1", default-features = false, features = ["json", "r
# multi-thread engine over channels (see crates/workflow-js).
rquickjs = { version = "0.14", features = ["futures"] }
rustls = { version = "0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.40.2", features = ["bundled"] }
rusqlite = { version = "0.41.1", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
# DSH bundle import: event-level parsing so aliases and explicit tags can be refused.
@@ -106,7 +106,6 @@ sha2 = "0.11"
tower-http = { version = "0.7", features = ["cors"] }
tracing = "0.1"
gix = { version = "=0.90.0", default-features = false, features = ["sha1", "sha256"] }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1.11", features = ["v4"] }
mimalloc = { version = "0.1", default-features = false }
+21
View File
@@ -933,6 +933,27 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
### `semver` 7.8.5
```text
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
```
## Optional compiled extension host
Node remains the default extension-host runtime. An explicitly installed
+1
View File
@@ -898,6 +898,7 @@ base_url = "https://api.deepseek.com/beta"
# OpenAI-compatible Chat Completions route.
# Provider aliases: mistral, mistral-ai, mistralai, la-plateforme
# Env var aliases: MISTRAL_API_KEY, MISTRAL_BASE_URL, MISTRAL_MODEL
# Vibe Code subscription setup: docs/MISTRAL_VIBE.md
[providers.mistral]
# api_key = "YOUR_MISTRAL_API_KEY"
# base_url = "https://api.mistral.ai/v1"
+1
View File
@@ -13,6 +13,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
clap.workspace = true
chrono.workspace = true
codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-release = { path = "../release", version = "0.10.3" }
codewhale-runtime = { path = "../runtime", version = "0.10.3" }
+139 -81
View File
@@ -31,6 +31,12 @@ use serde::{Deserialize, Serialize, de::DeserializeOwned};
mod computer;
pub mod dispatch;
pub mod machine;
mod session;
pub use session::{
AccountAuthLease, AccountClient, AccountLogin, AccountLoginCancellation, AccountLoginPoll,
AccountLoginPrompt, AccountModel, AccountModels, AccountStatus, account_api_base,
};
mod work;
const MAX_RESPONSE_BYTES: u64 = 256 * 1024;
@@ -844,6 +850,7 @@ pub(crate) trait CloudTransport {
fn execute(&self, request: CloudRequest) -> Result<CloudResponse>;
}
#[derive(Clone)]
struct ReqwestTransport {
base: Url,
client: reqwest::blocking::Client,
@@ -877,7 +884,13 @@ impl CloudTransport for ReqwestTransport {
.strip_prefix("/v1/files/")
.and_then(|path| path.strip_suffix("/content"))
.is_some_and(|id| validate_account_file_id(id).is_ok());
let max_response_bytes = if file_download {
let max_response_bytes = if request.method == HttpMethod::Get
&& matches!(
request.path.as_str(),
"/api/account/model-api" | "/v1/models"
) {
4 * 1024 * 1024
} else if file_download {
MAX_FILE_DOWNLOAD_BYTES
} else if matches!(request.method, HttpMethod::Get | HttpMethod::Post)
&& request
@@ -1009,6 +1022,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
fn poll_device(
&self,
device: &DeviceStart,
snapshot: &AccountSessionSnapshot,
timeout: Duration,
sleep: &mut dyn FnMut(Duration),
) -> Result<AuthBundle> {
@@ -1024,35 +1038,82 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
)
.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(),
bearer: None,
body: Some(json_body(&DeviceTokenRequest {
device_code: &device.device_code,
})?),
})?;
match response.status {
200 => {
let bundle: AuthBundle = parse_json_body(&response.body)?;
validate_auth_bundle(&bundle)?;
Ok(DevicePollOutcome::Complete(bundle))
}
202 => Ok(DevicePollOutcome::Pending),
_ => Err(response_error(&response)),
}
})?;
self.save_auth(bundle.clone())?;
.run(sleep, || self.poll_device_once(device))?;
if self
.account_store
.save_if_unchanged(snapshot, bundle.clone())?
.is_none()
{
bail!("The signed-in account changed. Refresh and try again.");
}
let _ = self.revoke_previous_session(snapshot, &bundle);
Ok(bundle)
}
fn revoke_previous_session(
&self,
previous: &AccountSessionSnapshot,
next: &AuthBundle,
) -> bool {
let previous = match previous.load() {
Ok(Some(previous)) => previous.bundle,
Ok(None) => return true,
Err(_) => return false,
};
if previous
.session
.as_ref()
.zip(next.session.as_ref())
.is_some_and(|(old, new)| old.id == new.id)
{
return true;
}
self.revoke_session_bundle(&previous)
}
fn revoke_session_bundle(&self, bundle: &AuthBundle) -> bool {
let Ok(body) = json_body(&RefreshRequest {
refresh_token: &bundle.refresh_token,
}) else {
return false;
};
self.transport
.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/auth/logout".into(),
bearer: None,
body: Some(body),
})
.is_ok_and(|response| (200..300).contains(&response.status))
}
fn poll_device_once(&self, device: &DeviceStart) -> Result<DevicePollOutcome<AuthBundle>> {
let response = self.transport.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/cli/device/token".to_string(),
bearer: None,
body: Some(json_body(&DeviceTokenRequest {
device_code: &device.device_code,
})?),
})?;
match response.status {
200 => {
let bundle: AuthBundle = parse_json_body(&response.body)?;
validate_auth_bundle(&bundle)?;
Ok(DevicePollOutcome::Complete(bundle))
}
202 => Ok(DevicePollOutcome::Pending),
_ => Err(response_error(&response)),
}
}
fn load_auth(&self) -> Result<Option<StoredCloudAuth>> {
self.account_store.load().context(
"the local Codewhale account session is unreadable; run `codewhale account logout` and sign in again",
)
}
#[cfg(test)]
fn save_auth(&self, bundle: AuthBundle) -> Result<()> {
self.account_store
.save(bundle)
@@ -1569,9 +1630,13 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
fn logout(&self) -> Result<bool> {
let snapshot = self.account_store.snapshot()?;
self.logout_from_snapshot(&snapshot)
}
fn logout_from_snapshot(&self, snapshot: &AccountSessionSnapshot) -> Result<bool> {
self.account_store
.with_transaction(|transaction| -> Result<bool> {
if !transaction.matches(&snapshot) {
if !transaction.matches(snapshot) {
bail!("The signed-in account changed. Refresh and try again.");
}
let stored = match transaction.load() {
@@ -1626,7 +1691,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
body: Option<Vec<u8>>,
snapshot: AccountSessionSnapshot,
) -> Result<(CloudResponse, AccountSessionSnapshot)> {
let Some(mut stored) = snapshot.load()? else {
let Some(stored) = snapshot.load()? else {
bail!("Not signed in. Run `codewhale login` first");
};
let first = self.transport.execute(CloudRequest {
@@ -1638,12 +1703,34 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
if first.status != 401 {
return Ok((first, snapshot));
}
// Serialize the refresh HTTP request itself with native/CLI writers:
// two processes must not spend the same rotating refresh token.
let renewed = self
.account_store
let renewed = self.refresh_snapshot(&snapshot)?;
let Some((next, next_snapshot)) = renewed else {
bail!("The Codewhale account session expired. Run `codewhale login` again");
};
// The rotated token is durable before a potentially failing retry.
let retried = self.transport.execute(CloudRequest {
method,
path: path.into(),
bearer: Some(next.access_token),
body,
})?;
if retried.status == 401 {
self.account_store.clear_if_unchanged(&next_snapshot)?;
bail!("The Codewhale account session expired. Run `codewhale login` again");
}
Ok((retried, next_snapshot))
}
fn refresh_snapshot(
&self,
snapshot: &AccountSessionSnapshot,
) -> Result<Option<(AuthBundle, AccountSessionSnapshot)>> {
let Some(mut stored) = snapshot.load()? else {
return Ok(None);
};
self.account_store
.with_transaction(|transaction| -> Result<_> {
if !transaction.matches(&snapshot) {
if !transaction.matches(snapshot) {
bail!("The signed-in account changed. Refresh and try again.");
}
let refresh = self.transport.execute(CloudRequest {
@@ -1685,22 +1772,7 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
}
transaction.replace(next.clone())?;
Ok(Some((next, transaction.snapshot())))
})?;
let Some((next, next_snapshot)) = renewed else {
bail!("The Codewhale account session expired. Run `codewhale login` again");
};
// The rotated token is durable before a potentially failing retry.
let retried = self.transport.execute(CloudRequest {
method,
path: path.into(),
bearer: Some(next.access_token),
body,
})?;
if retried.status == 401 {
self.account_store.clear_if_unchanged(&next_snapshot)?;
bail!("The Codewhale account session expired. Run `codewhale login` again");
}
Ok((retried, next_snapshot))
})
}
/// Whether an interactive session exists for this profile and origin.
@@ -3113,6 +3185,17 @@ pub fn run_account_login(
)
}
pub fn run_account_logout(profile: Option<&str>, config: &mut ConfigStore) -> Result<()> {
run(
CloudArgs {
api_base: None,
command: CloudCommand::Logout,
},
profile,
config,
)
}
pub fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> {
if api_key.is_some() {
bail!(
@@ -3122,40 +3205,6 @@ pub fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> {
Ok(())
}
/// On account sign-in, point a never-configured local route at the managed
/// Codewhale provider so chat works immediately. A provider the user chose
/// explicitly, or one that already has a local key (config, secret store or
/// environment), is left alone: the default provider is DeepSeek, so equality
/// with the default cannot tell "never configured" from "chose DeepSeek".
fn select_managed_route_on_login<W: Write>(
config: &mut ConfigStore,
provider_secrets: &Secrets,
out: &mut W,
) -> Result<()> {
let explicit_provider = config
.original_body()
.and_then(|body| body.parse::<toml::Table>().ok())
.is_some_and(|table| table.contains_key("provider"));
let has_local_key =
resolve_local_key(config, provider_secrets, config.config.provider)?.is_some();
if config.config.provider == ProviderKind::default() && !explicit_provider && !has_local_key {
config.config.provider = ProviderKind::Codewhale;
config.config.model = Some("auto".to_string());
config.save()?;
writeln!(
out,
"Using your Codewhale account route (provider codewhale, model auto)."
)?;
} else {
writeln!(
out,
"Keeping your configured {} route.",
config.config.provider.as_str()
)?;
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_with<T: CloudTransport, W: Write>(
command: CloudCommand,
@@ -3174,6 +3223,7 @@ fn run_with<T: CloudTransport, W: Write>(
let client = CloudClient::new(transport, cloud_secrets, profile, api_base);
match command {
CloudCommand::Login(login) => {
let snapshot = client.account_store.begin_login()?;
let device = client.start_device()?;
validate_user_code(&device.user_code)?;
validate_verification_url(
@@ -3198,11 +3248,19 @@ fn run_with<T: CloudTransport, W: Write>(
"Browser could not be opened; use the URL and code above."
)?;
}
let _ =
client.poll_device(&device, Duration::from_secs(login.timeout_seconds), sleeper)?;
let _ = client.poll_device(
&device,
&snapshot,
Duration::from_secs(login.timeout_seconds),
sleeper,
)?;
let user = client.me()?;
write_account(out, "Signed in to Codewhale.", profile, api_base, &user)?;
select_managed_route_on_login(config, provider_secrets, out)?;
writeln!(
out,
"Keeping your configured {} route.",
config.config.provider.as_str()
)?;
Ok(())
}
CloudCommand::Status => match client.load_auth()? {
+688
View File
@@ -0,0 +1,688 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use chrono::{DateTime, Utc};
use codewhale_secrets::account::{AccountDeviceAccess, AccountSessionState, RuntimeAccountInfo};
use super::*;
pub fn account_api_base(api_base: Option<&str>) -> Result<String> {
let requested_base = machine::resolve_api_base(
api_base,
std::env::var(machine::MACHINE_API_BASE_ENV).ok().as_deref(),
std::env::var(CLOUD_API_BASE_ENV).ok().as_deref(),
DEFAULT_API_BASE,
);
Ok(validate_api_base(&requested_base)?.display)
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountStatus {
pub profile: String,
pub api_base: String,
pub account: RuntimeAccountInfo,
pub user: Option<CloudUser>,
pub revision: String,
pub import_allowed: bool,
}
pub struct AccountAuthLease {
pub bundle: AuthBundle,
pub snapshot: AccountSessionSnapshot,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountModel {
pub id: String,
pub label: String,
pub provider: String,
pub model: String,
pub protocol: String,
pub billing_mode: String,
pub is_default: bool,
}
pub struct AccountModels {
pub api_base: String,
pub account_id: String,
pub session_id: String,
pub expires_at: i64,
pub default_model: Option<String>,
pub models: Vec<AccountModel>,
pub credential: Option<String>,
pub snapshot: AccountSessionSnapshot,
}
#[derive(Clone)]
pub struct AccountClient {
transport: ReqwestTransport,
store: AccountSessionStore,
profile: String,
api_base: String,
}
impl AccountClient {
pub fn new(profile: Option<&str>, api_base: Option<&str>) -> Result<Self> {
let requested_base = account_api_base(api_base)?;
let base = validate_api_base(&requested_base)?;
let profile = normalized_profile(profile);
let secrets = cloud_session_secrets()?;
Ok(Self {
transport: ReqwestTransport::new(base.url)?,
store: AccountSessionStore::new(secrets, Some(&profile), &base.display),
profile,
api_base: base.display,
})
}
fn client(&self) -> CloudClient<'_, ReqwestTransport> {
CloudClient {
transport: &self.transport,
account_store: self.store.clone(),
}
}
fn revoke_session_later(&self, previous: Option<AccountSessionSnapshot>, bundle: AuthBundle) {
let client = self.clone();
let _ = std::thread::Builder::new()
.name("cw-account-revoke".into())
.spawn(move || {
let _ = match previous {
Some(previous) => client.client().revoke_previous_session(&previous, &bundle),
None => client.client().revoke_session_bundle(&bundle),
};
});
}
pub fn snapshot(&self) -> Result<AccountSessionSnapshot> {
Ok(self.store.snapshot()?)
}
pub fn status(&self) -> Result<AccountStatus> {
let snapshot = self.snapshot()?;
let user = snapshot.load()?.and_then(|stored| stored.bundle.user);
Ok(AccountStatus {
profile: self.profile.clone(),
api_base: self.api_base.clone(),
account: snapshot.runtime_info_at(Utc::now())?,
user,
revision: snapshot.fingerprint(),
import_allowed: snapshot.is_pristine(),
})
}
pub fn claim(&self) -> Result<AccountSessionSnapshot> {
Ok(self.store.begin_login()?)
}
pub fn claim_if_unchanged(
&self,
expected_revision: &str,
) -> Result<Option<AccountSessionSnapshot>> {
self.store.with_transaction(|transaction| {
if transaction.snapshot().fingerprint() != expected_revision {
return Ok(None);
}
Ok(Some(transaction.renew_revision()?))
})
}
pub fn cancel_claim(&self, expected_revision: &str) -> Result<bool> {
self.store.with_transaction(|transaction| {
if transaction.snapshot().fingerprint() != expected_revision {
return Ok(false);
}
transaction.renew_revision()?;
Ok(true)
})
}
pub fn import(
&self,
bundle: AuthBundle,
expected_revision: &str,
replace_existing: bool,
) -> Result<bool> {
self.import_with_receipt(bundle, expected_revision, replace_existing)
.map(|receipt| receipt.is_some())
}
pub fn import_with_receipt(
&self,
bundle: AuthBundle,
expected_revision: &str,
replace_existing: bool,
) -> Result<Option<AccountSessionSnapshot>> {
validate_auth_bundle(&bundle)?;
let expected = self.snapshot()?;
if expected.fingerprint() != expected_revision {
return Ok(None);
}
if !replace_existing && !expected.is_pristine() {
return Ok(None);
}
let Some(committed) = self.store.save_if_unchanged(&expected, bundle.clone())? else {
return Ok(None);
};
self.revoke_session_later(Some(expected), bundle);
Ok(Some(committed))
}
pub fn fresh_auth(&self, rejected_token: Option<&str>) -> Result<Option<AccountAuthLease>> {
let snapshot = self.snapshot()?;
let Some(stored) = snapshot.load()? else {
return Ok(None);
};
if matches!(
snapshot.runtime_info_at(Utc::now())?.state,
AccountSessionState::Expired | AccountSessionState::Revoked
) {
return Ok(None);
}
let refresh = rejected_token == Some(stored.bundle.access_token.as_str())
|| !stored
.bundle
.session
.as_ref()
.and_then(|session| DateTime::parse_from_rfc3339(&session.expires_at).ok())
.is_some_and(|expires| expires.timestamp() > Utc::now().timestamp() + 120);
let (bundle, snapshot) = if refresh {
let Some(renewed) = self.client().refresh_snapshot(&snapshot)? else {
return Ok(None);
};
renewed
} else {
(stored.bundle, snapshot)
};
let current = self.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&snapshot))
})?;
Ok(current.then_some(AccountAuthLease { bundle, snapshot }))
}
pub fn logout(&self) -> Result<bool> {
self.client().logout()
}
pub fn logout_if_unchanged(&self, snapshot: &AccountSessionSnapshot) -> Result<bool> {
self.client().logout_from_snapshot(snapshot)
}
pub fn connect_models(&self) -> Result<AccountModels> {
self.connect_models_with_cancel(&AtomicBool::new(false))
}
pub fn connect_models_with_cancel(&self, cancelled: &AtomicBool) -> Result<AccountModels> {
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let lease = self
.fresh_auth(None)?
.ok_or_else(|| anyhow!("Sign in to Codewhale before connecting account models"))?;
let (response, snapshot) = self.client().execute_from_snapshot(
HttpMethod::Get,
"/api/account/model-api",
None,
lease.snapshot,
)?;
let catalog: serde_json::Value = expect_json(response, &[200])?;
let data = catalog
.get("data")
.and_then(serde_json::Value::as_array)
.filter(|rows| rows.len() <= 8192)
.ok_or_else(|| anyhow!("The Codewhale account model catalog was invalid"))?;
let mut models = Vec::new();
for row in data {
let facts = &row["codewhale"];
if facts["usable"].as_bool() != Some(true) {
continue;
}
if facts["protocol"].as_str() != Some("chat-completions")
|| facts["billingMode"].as_str() != Some("byok_external")
{
continue;
}
let id = model_text(row, "id")?;
let provider = model_text(facts, "provider")?;
let model = model_text(facts, "model")?;
if id != format!("{provider}/{model}") {
bail!("The Codewhale account model catalog was invalid");
}
models.push(AccountModel {
id,
label: facts["label"]
.as_str()
.map(|label| printable_max(label, 200))
.filter(|label| !label.is_empty())
.unwrap_or_else(|| model.clone()),
provider,
model,
protocol: model_text(facts, "protocol")?,
billing_mode: model_text(facts, "billingMode")?,
is_default: facts["default"].as_bool() == Some(true),
});
}
let stored = snapshot
.load()?
.ok_or_else(|| anyhow!("The signed-in account changed. Refresh and try again."))?;
let account_id = stored
.bundle
.user
.as_ref()
.map(|user| user.id.clone())
.filter(|id| !id.is_empty())
.ok_or_else(|| anyhow!("Sign in again before connecting account models"))?;
let session = stored
.bundle
.session
.as_ref()
.filter(|session| !session.id.is_empty())
.ok_or_else(|| anyhow!("Sign in again before connecting account models"))?;
let session_id = session.id.clone();
let access_expires = DateTime::parse_from_rfc3339(&session.expires_at)
.context("The account session expiry was invalid")?
.timestamp();
let refresh_expires = DateTime::parse_from_rfc3339(&session.refresh_expires_at)
.context("The account session expiry was invalid")?
.timestamp();
let default_model = catalog["codewhale"]["defaultModel"]
.as_str()
.filter(|id| models.iter().any(|model| model.id == *id))
.map(str::to_string)
.or_else(|| {
models
.iter()
.find(|model| model.is_default)
.map(|model| model.id.clone())
});
let mut result = AccountModels {
api_base: format!("{}/v1", self.api_base),
account_id: account_id.clone(),
session_id: session_id.clone(),
expires_at: 0,
default_model,
models,
credential: None,
snapshot,
};
if result.models.is_empty() {
let current = self.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&result.snapshot))
})?;
if !current {
bail!("The signed-in account changed. Refresh and try again.");
}
return Ok(result);
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let device_id = self.store.device_id()?;
let mut issued_id: Option<String> = None;
let connected = self.store.with_transaction(|transaction| {
if !transaction.matches(&result.snapshot) || access_expires <= Utc::now().timestamp() {
bail!("The signed-in account changed. Refresh and try again.");
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let current = transaction.snapshot();
if let Some(access) = current.device_access()? {
if access.device_id != device_id {
bail!("The Codewhale device connection changed. Reconnect your account.");
}
machine::MachineKey::parse(&access.credential)?;
if access.expires_at > Utc::now().timestamp() {
let checked = self.transport.execute(CloudRequest {
method: HttpMethod::Get,
path: "/v1/models".into(),
bearer: Some(access.credential.clone()),
body: None,
})?;
match checked.status {
200 => return Ok((access, current)),
401 => {}
_ => return Err(response_error(&checked)),
}
}
}
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account model connection cancelled");
}
let response = self.transport.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/account/device-link".into(),
bearer: Some(stored.bundle.access_token.clone()),
body: Some(serde_json::to_vec(&serde_json::json!({
"deviceId": device_id,
"deviceName": "Codewhale",
"expectedAccountId": account_id,
}))?),
})?;
let linked: LinkedDevice = expect_json(response, &[201])?;
if linked.api_key.id.len() == 24
&& linked
.api_key
.id
.bytes()
.all(|byte| byte.is_ascii_hexdigit())
{
issued_id = Some(linked.api_key.id.clone());
}
let key = machine::MachineKey::parse(&linked.secret)?;
let expires_at = DateTime::parse_from_rfc3339(&linked.api_key.expires_at)
.context("The account device expiry was invalid")?
.timestamp();
if !linked.session_bound
|| linked.api_key.scopes.len() != 1
|| linked.api_key.scopes[0] != "models:infer"
|| linked.api_key.revoked_at.is_some()
|| linked.api_key.context.kind != "device"
|| linked.api_key.context.device_id != device_id
|| linked.api_key.context.session_id != session_id
|| key.head().strip_prefix("cwc_key_") != Some(linked.api_key.id.as_str())
|| expires_at <= Utc::now().timestamp()
|| expires_at > refresh_expires
{
bail!("The Codewhale device connection could not be verified");
}
let access = AccountDeviceAccess {
device_id: device_id.clone(),
credential: linked.secret,
api_key_id: linked.api_key.id,
account_id: account_id.clone(),
session_id: session_id.clone(),
expires_at,
};
transaction.set_device_access(access.clone())?;
Ok((access, transaction.snapshot()))
});
let (access, snapshot) = match connected {
Ok(connected) => connected,
Err(error) => {
if let Some(id) = issued_id {
let _ = self.transport.execute(CloudRequest {
method: HttpMethod::Delete,
path: format!("/api/account/api-keys/{id}"),
bearer: Some(stored.bundle.access_token),
body: None,
});
}
return Err(error);
}
};
result.expires_at = access.expires_at.min(access_expires);
result.credential = Some(access.credential);
result.snapshot = snapshot;
Ok(result)
}
pub fn start_login(&self, timeout: Duration) -> Result<AccountLogin> {
self.start_login_with_cancel(timeout, Arc::new(AtomicBool::new(false)))
}
pub fn start_login_with_cancel(
&self,
timeout: Duration,
cancelled: Arc<AtomicBool>,
) -> Result<AccountLogin> {
if timeout.is_zero() || timeout > Duration::from_secs(MAX_LOGIN_TIMEOUT_SECONDS) {
bail!("Invalid Codewhale account login timeout");
}
let expected = self.store.with_transaction(|transaction| {
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account login cancelled");
}
Ok::<_, anyhow::Error>(transaction.renew_revision()?)
})?;
let started = Instant::now();
if cancelled.load(Ordering::Acquire) {
bail!("Codewhale account login cancelled");
}
let device = self.client().start_device()?;
validate_device_code(&device.device_code)?;
validate_user_code(&device.user_code)?;
let verification_uri = validate_verification_url(
&device.verification_uri,
&self.api_base,
&device.user_code,
false,
)?;
let verification_uri_complete = validate_verification_url(
&device.verification_uri_complete,
&self.api_base,
&device.user_code,
true,
)?;
let lifetime = timeout.min(Duration::from_secs(
device.expires_in.clamp(1, MAX_LOGIN_TIMEOUT_SECONDS),
));
let interval = if device.interval == 0 {
codewhale_config::device_code::DEFAULT_POLL_INTERVAL_SECS
} else {
device.interval.min(10)
};
Ok(AccountLogin {
client: self.clone(),
expected,
prompt: AccountLoginPrompt {
user_code: device.user_code.clone(),
verification_uri,
verification_uri_complete,
expires_in: lifetime.as_secs(),
interval,
},
device,
cancelled: AccountLoginCancellation(cancelled),
deadline: started + lifetime,
next_poll: started,
pending_bundle: None,
finished: false,
})
}
}
fn model_text(value: &serde_json::Value, key: &str) -> Result<String> {
value[key]
.as_str()
.filter(|text| {
!text.is_empty()
&& text.len() <= 512
&& !text.chars().any(char::is_control)
&& !text.chars().any(char::is_whitespace)
})
.map(str::to_string)
.ok_or_else(|| anyhow!("The Codewhale account model catalog was invalid"))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDevice {
secret: String,
api_key: LinkedDeviceKey,
session_bound: bool,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDeviceKey {
id: String,
scopes: Vec<String>,
expires_at: String,
revoked_at: Option<String>,
context: LinkedDeviceContext,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LinkedDeviceContext {
kind: String,
device_id: String,
session_id: String,
}
#[derive(Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AccountLoginPrompt {
pub user_code: String,
pub verification_uri: String,
pub verification_uri_complete: String,
pub expires_in: u64,
pub interval: u64,
}
#[derive(Clone)]
pub struct AccountLoginCancellation(Arc<AtomicBool>);
impl AccountLoginCancellation {
pub fn cancel(&self) {
self.0.store(true, Ordering::Release);
}
}
pub enum AccountLoginPoll {
Pending { retry_after: Duration },
Complete { user: CloudUser },
Cancelled,
Expired,
}
pub struct AccountLogin {
client: AccountClient,
expected: AccountSessionSnapshot,
device: DeviceStart,
prompt: AccountLoginPrompt,
cancelled: AccountLoginCancellation,
deadline: Instant,
next_poll: Instant,
pending_bundle: Option<AuthBundle>,
finished: bool,
}
impl AccountLogin {
pub fn prompt(&self) -> &AccountLoginPrompt {
&self.prompt
}
pub fn cancellation(&self) -> AccountLoginCancellation {
self.cancelled.clone()
}
pub fn poll(&mut self) -> Result<AccountLoginPoll> {
let result = self.poll_once();
if !matches!(result, Ok(AccountLoginPoll::Pending { .. })) {
self.finished = true;
}
if matches!(
result,
Ok(AccountLoginPoll::Cancelled | AccountLoginPoll::Expired)
) {
self.revoke_pending();
}
result
}
fn revoke_pending(&mut self) {
if let Some(bundle) = self.pending_bundle.take() {
self.client.revoke_session_later(None, bundle);
}
}
fn poll_once(&mut self) -> Result<AccountLoginPoll> {
if self.finished || self.cancelled.0.load(Ordering::Acquire) {
return Ok(AccountLoginPoll::Cancelled);
}
let now = Instant::now();
if now >= self.deadline {
return Ok(AccountLoginPoll::Expired);
}
if now < self.next_poll {
return Ok(AccountLoginPoll::Pending {
retry_after: (self.next_poll - now).min(self.deadline - now),
});
}
if !self.client.store.with_transaction(|transaction| {
Ok::<_, anyhow::Error>(transaction.matches(&self.expected))
})? {
return Ok(AccountLoginPoll::Cancelled);
}
if let Some(bundle) = self.pending_bundle.as_mut() {
if bundle.user.is_none() {
let response = self.client.transport.execute(CloudRequest {
method: HttpMethod::Get,
path: "/api/me".into(),
bearer: Some(bundle.access_token.clone()),
body: None,
})?;
bundle.user = Some(expect_json::<MeResponse>(response, &[200])?.user);
}
} else {
match self.client.client().poll_device_once(&self.device)? {
DevicePollOutcome::Complete(bundle) => self.pending_bundle = Some(bundle),
DevicePollOutcome::Pending | DevicePollOutcome::SlowDown { .. } => {
self.next_poll = Instant::now() + Duration::from_secs(self.prompt.interval);
return Ok(AccountLoginPoll::Pending {
retry_after: self
.next_poll
.min(self.deadline)
.saturating_duration_since(Instant::now()),
});
}
}
}
let bundle = self
.pending_bundle
.as_ref()
.ok_or_else(|| anyhow!("The Codewhale account authorization was unavailable"))?;
if bundle.user.is_none() {
return Ok(AccountLoginPoll::Pending {
retry_after: Duration::ZERO,
});
}
let Some(user) = bundle
.user
.as_ref()
.filter(|user| !user.id.trim().is_empty())
else {
bail!("The Codewhale service returned an account without an ID");
};
let user = user.clone();
validate_auth_bundle(bundle)?;
if Instant::now() >= self.deadline {
return Ok(AccountLoginPoll::Expired);
}
let bundle = self
.pending_bundle
.take()
.ok_or_else(|| anyhow!("The Codewhale account authorization was unavailable"))?;
let committed = self.client.store.with_transaction(|transaction| {
if self.cancelled.0.load(Ordering::Acquire)
|| !transaction.matches(&self.expected)
|| Instant::now() >= self.deadline
{
return Ok::<_, anyhow::Error>(false);
}
transaction.replace(bundle.clone())?;
Ok(true)
})?;
if !committed {
self.client.revoke_session_later(None, bundle);
return Ok(AccountLoginPoll::Cancelled);
}
self.client
.revoke_session_later(Some(self.expected.clone()), bundle);
Ok(AccountLoginPoll::Complete { user })
}
}
impl Drop for AccountLogin {
fn drop(&mut self) {
if !self.finished {
self.cancelled.cancel();
}
self.revoke_pending();
}
}
+12 -9
View File
@@ -618,18 +618,18 @@ fn run_login(config: &mut ConfigStore, secrets: &Secrets) -> String {
}
#[test]
fn login_selects_managed_route_when_provider_is_default() {
fn login_keeps_the_default_route_when_no_model_is_connected() {
let (temp, mut config) = test_config();
assert_eq!(config.config.provider, ProviderKind::default());
let (secrets, _) = test_secrets();
let output = run_login(&mut config, &secrets);
assert!(
output.contains("Using your Codewhale account route"),
output.contains("Keeping your configured deepseek route."),
"{output}"
);
let saved = ConfigStore::load(Some(temp.path().join("config.toml"))).unwrap();
assert_eq!(saved.config.provider, ProviderKind::Codewhale);
assert_eq!(saved.config.model.as_deref(), Some("auto"));
assert_eq!(saved.config.provider, ProviderKind::default());
assert_eq!(saved.config.model, None);
}
#[test]
@@ -1348,7 +1348,10 @@ fn logout_recovers_from_a_corrupt_local_session_record() {
&mut sleeper,
)
.unwrap();
assert!(store.get(&slot).unwrap().is_none());
let cleared: serde_json::Value =
serde_json::from_str(&store.get(&slot).unwrap().unwrap()).unwrap();
assert_eq!(cleared["signedOut"], true);
assert!(cleared.get("bundle").is_none());
assert!(
!String::from_utf8(output)
.unwrap()
@@ -1388,10 +1391,10 @@ fn logout_clears_obsolete_or_wrong_origin_session_records() {
"{case} must continue to load as signed out"
);
assert!(!client.logout().unwrap());
assert!(
store.get(&slot).unwrap().is_none(),
"logout must scrub the {case} record"
);
let cleared: serde_json::Value =
serde_json::from_str(&store.get(&slot).unwrap().unwrap()).unwrap();
assert_eq!(cleared["signedOut"], true, "logout must scrub {case}");
assert!(cleared.get("bundle").is_none());
assert!(transport.requests().is_empty());
}
}
@@ -289,6 +289,25 @@
"type": "boolean",
"default": false
},
"thread_scoped_git_writes": {
"type": "boolean",
"description": "Git stage, unstage, discard, commit, push and branch accept\n`?thread_id=` and act on that thread's workspace.",
"default": false
},
"thread_scoped_snapshots": {
"type": "boolean",
"description": "`GET /v1/snapshots` and `POST /v1/snapshots/{id}/restore` accept\n`?thread_id=` and act on that thread's workspace.",
"default": false
},
"worktrees": {
"type": "boolean",
"description": "`GET`/`POST /v1/worktrees` and `POST /v1/worktrees/remove`.",
"default": false
},
"job_operations": {
"type": "boolean",
"default": false
},
"thread_shell_consent": {
"type": "boolean",
"description": "Explicit per-thread shell opt-in is checked against loaded policy and\ncannot broaden a conversation while it has an active turn.",
@@ -454,6 +473,10 @@
"thread_idle_precondition",
"thread_patch_undo_turn_id",
"thread_scoped_reads",
"thread_scoped_git_writes",
"thread_scoped_snapshots",
"worktrees",
"job_operations",
"thread_shell_consent",
"turns",
"turn_operation_idempotency",
@@ -62,6 +62,10 @@ export type RuntimeCapabilities = {
thread_idle_precondition: boolean
thread_patch_undo_turn_id: boolean
thread_scoped_reads: boolean
thread_scoped_git_writes: boolean
thread_scoped_snapshots: boolean
worktrees: boolean
job_operations: boolean
thread_shell_consent: boolean
turns: boolean
turn_operation_idempotency: boolean
@@ -123,6 +123,8 @@ pub struct RuntimeCapabilities {
/// `GET`/`POST /v1/worktrees` and `POST /v1/worktrees/remove`.
#[serde(default)]
pub worktrees: bool,
#[serde(default)]
pub job_operations: bool,
/// Explicit per-thread shell opt-in is checked against loaded policy and
/// cannot broaden a conversation while it has an active turn.
#[serde(default)]
@@ -537,6 +539,7 @@ mod tests {
thread_scoped_git_writes: true,
thread_scoped_snapshots: true,
worktrees: true,
job_operations: true,
thread_shell_consent: true,
turns: true,
turn_operation_idempotency: true,
+1
View File
@@ -26,6 +26,7 @@ default = []
test-support = ["codewhale-engine/test-support"]
[dependencies]
codewhale-account-cli = { path = "../account-cli", version = "0.10.3" }
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
anyhow.workspace = true
axum.workspace = true
+28 -2
View File
@@ -41,6 +41,32 @@ mod platform {
tokio::io::WriteHalf<Connection>,
>;
fn same_socket_selection(left: &std::path::Path, right: &std::path::Path) -> bool {
if left == right {
return true;
}
#[cfg(unix)]
{
if left.file_name().is_none() || left.file_name() != right.file_name() {
return false;
}
match (
left.parent()
.map(codewhale_paths::resolve_root_owned_ancestors),
right
.parent()
.map(codewhale_paths::resolve_root_owned_ancestors),
) {
(Some(left), Some(right)) => left == right,
_ => false,
}
}
#[cfg(windows)]
{
false
}
}
pub struct OwnerClient {
read: BoundedLines<BufReader<tokio::io::ReadHalf<OwnerTransport>>>,
write: tokio::io::WriteHalf<OwnerTransport>,
@@ -194,7 +220,7 @@ mod platform {
old.version == owner.version
&& old.data_dir == owner.data_dir
&& old.execution_scope == owner.execution_scope
&& old.socket_path == endpoint
&& same_socket_selection(&old.socket_path, endpoint)
&& old.config_path == owner.config_path
&& old.principal == owner.principal,
"captured stale owner belongs to another selected store/config/principal"
@@ -278,7 +304,7 @@ mod platform {
anyhow::ensure!(
receipt.version == 1
&& receipt.pid > 0
&& receipt.socket_path == socket_path
&& same_socket_selection(&receipt.socket_path, &socket_path)
&& !receipt.lease_generation.is_empty(),
"selected owner receipt is invalid"
);
+7 -7
View File
@@ -1663,7 +1663,7 @@ mod tests {
explicit: None,
codewhale_home_override: None,
xdg_runtime_dir: None,
user_home: Some(PathBuf::from("/home/whale")),
user_home: Some(PathBuf::from("/cw-test-home/whale")),
macos: false,
}
}
@@ -1724,7 +1724,7 @@ mod tests {
let resolved = resolve_socket_path(&inputs()).expect("resolve");
assert_eq!(
resolved,
PathBuf::from("/home/whale/.codewhale/run/daemon.sock")
PathBuf::from("/cw-test-home/whale/.codewhale/run/daemon.sock")
);
}
@@ -1734,7 +1734,7 @@ mod tests {
let resolved = store_selected_socket_path(None, &inputs()).expect("resolve");
assert_eq!(
resolved,
PathBuf::from("/home/whale/.codewhale/run/daemon.sock")
PathBuf::from("/cw-test-home/whale/.codewhale/run/daemon.sock")
);
}
@@ -1742,18 +1742,18 @@ mod tests {
#[test]
fn distinct_stores_derive_distinct_sibling_sockets() {
let one = store_selected_socket_path(
Some(Path::new("/home/whale/.local/state/one/runtime")),
Some(Path::new("/cw-test-home/whale/.local/state/one/runtime")),
&inputs(),
)
.expect("resolve");
let two = store_selected_socket_path(
Some(Path::new("/home/whale/.local/state/two/runtime")),
Some(Path::new("/cw-test-home/whale/.local/state/two/runtime")),
&inputs(),
)
.expect("resolve");
assert_eq!(
one.parent().unwrap(),
Path::new("/home/whale/.codewhale/run")
Path::new("/cw-test-home/whale/.codewhale/run")
);
assert_eq!(one.parent().unwrap(), two.parent().unwrap());
assert_ne!(one, two);
@@ -1766,7 +1766,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn the_derived_socket_is_a_pure_function_of_the_store_root() {
let store = Path::new("/home/whale/.local/state/one/runtime");
let store = Path::new("/cw-test-home/whale/.local/state/one/runtime");
let first = store_selected_socket_path(Some(store), &inputs()).expect("resolve");
let second = store_selected_socket_path(Some(store), &inputs()).expect("resolve");
assert_eq!(first, second);
+8 -6
View File
@@ -3456,12 +3456,14 @@ async fn relay_worker(
phase: &mut RelayPhase,
) -> Result<(), String> {
let base = runner_control_plane_base()?;
let client = codewhale_release::tls::reqwest_client_builder()
.https_only(!cfg!(debug_assertions))
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(20))
.build()
.map_err(|_| "Remote control could not initialize secure networking.".to_string())?;
let client = codewhale_net::proxy::install_loopback_proxy(
codewhale_release::tls::reqwest_client_builder(),
)
.https_only(!cfg!(debug_assertions))
.redirect(reqwest::redirect::Policy::none())
.timeout(Duration::from_secs(20))
.build()
.map_err(|_| "Remote control could not initialize secure networking.".to_string())?;
let saved_enrollment = load_persisted_enrollment()?;
// The device id is stable across enrollments: it is what lets the control
+109 -13
View File
@@ -110,6 +110,7 @@ use codewhale_protocol::fleet::{
};
use codewhale_runtime::skill_state::SkillStateStore;
mod account;
mod auth;
mod avatars;
mod computer_display;
@@ -127,6 +128,7 @@ mod plans;
mod plugins;
pub(crate) mod review;
mod secrets;
pub use self::secrets::{SetAccountModelAccessRequest, install_account_model_access};
pub(crate) mod sessions;
mod targets;
mod terminal;
@@ -201,6 +203,7 @@ pub struct RuntimeApiState {
config: Arc<parking_lot::RwLock<Config>>,
workspace: PathBuf,
plugin_discovery: Arc<codewhale_plugins::PluginDiscoveryContext>,
plugin_ui_grants: Arc<plugins::PluginUiGrants>,
task_manager: SharedTaskManager,
runtime_threads: SharedRuntimeThreadManager,
cors_origins: Vec<String>,
@@ -1555,6 +1558,7 @@ struct RuntimeInfoResponse {
auth_required: bool,
transports: Vec<&'static str>,
capabilities: RuntimeCapabilities,
job_operation_store_id: Option<String>,
account: RuntimeAccountInfo,
experimental: RuntimeExperimentalCapabilities,
// Backward-compatible alias kept for existing clients.
@@ -1575,6 +1579,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities {
thread_scoped_git_writes: true,
thread_scoped_snapshots: true,
worktrees: true,
job_operations: true,
thread_shell_consent: true,
turns: true,
turn_operation_idempotency: true,
@@ -1637,6 +1642,7 @@ struct McpServerEntry {
command: Option<String>,
url: Option<String>,
connected: bool,
has_connection_backoff: Option<bool>,
enabled_tools: Vec<String>,
disabled_tools: Vec<String>,
always_allow_tools: Vec<String>,
@@ -2351,6 +2357,7 @@ pub async fn run_http_server(
config: Arc::new(parking_lot::RwLock::new(config.clone())),
workspace,
plugin_discovery,
plugin_ui_grants: Arc::new(plugins::PluginUiGrants::default()),
task_manager: task_manager.clone(),
runtime_threads,
cors_origins: options.cors_origins.clone(),
@@ -2561,7 +2568,8 @@ async fn store_selected_control_socket(
let selected_store = selected_store.to_path_buf();
codewhale_runtime::runtime_control::owner_work(move || {
let store_override = codewhale_engine::runtime_threads::canonical_runtime_dir_override()?;
let inputs = codewhale_paths::daemon_socket::SocketPathInputs::from_environment(None)?;
let inputs =
codewhale_paths::daemon_socket::SocketPathInputs::from_environment(explicit.clone())?;
store_selected_control_socket_with(
explicit,
&selected_store,
@@ -3050,6 +3058,15 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/threads/{id}/jobs",
get(jobs::list_thread_jobs).post(jobs::create_thread_job),
)
.route("/v1/job-operations", get(jobs::list_job_operations))
.route(
"/v1/threads/{id}/job-operations",
get(jobs::list_thread_job_operations),
)
.route(
"/v1/threads/{id}/job-operations/{operation_key}",
get(jobs::get_job_operation),
)
.route("/v1/threads/{id}/jobs/{job_id}", get(jobs::get_thread_job))
.route(
"/v1/threads/{id}/jobs/{job_id}/output",
@@ -3286,6 +3303,14 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/apps/plugins/{selector}",
get(plugins::get_plugin).delete(plugins::uninstall_plugin_api),
)
.route(
"/v1/apps/plugins/{selector}/ui",
get(plugins::get_plugin_ui),
)
.route(
"/v1/apps/plugins/{selector}/ui/{*path}",
get(plugins::get_plugin_ui_asset),
)
.route(
"/v1/apps/plugins/{selector}/update",
post(plugins::update_plugin_api),
@@ -3302,6 +3327,10 @@ pub fn build_router(state: RuntimeApiState) -> Router {
"/v1/apps/plugins/{selector}/disable",
post(plugins::disable_plugin_api),
)
.route(
"/v1/apps/plugins/{selector}/restage",
post(plugins::restage_plugin_api),
)
.route(
"/v1/apps/plugins/{selector}/revoke",
post(plugins::revoke_plugin_api),
@@ -3448,14 +3477,6 @@ pub fn build_router(state: RuntimeApiState) -> Router {
// listener here. The response already redacts account detail for
// unauthorized requests; it never mutates state.
.route("/v1/runtime/info", get(runtime_info))
.route(
"/v1/apps/plugins/{selector}/ui",
get(plugins::get_plugin_ui),
)
.route(
"/v1/apps/plugins/{selector}/ui/{*path}",
get(plugins::get_plugin_ui_asset),
)
// Authenticates per handler: the display WS also takes a single-use
// ticket, and client-token minting is master-token only.
.merge(computer_display::router(
@@ -3463,6 +3484,7 @@ pub fn build_router(state: RuntimeApiState) -> Router {
state.runtime_token.clone(),
))
.merge(api_routes)
.merge(account::routes(&state))
.layer(cors_layer(&state.cors_origins))
.with_state(state)
}
@@ -6955,6 +6977,8 @@ async fn runtime_info(
auth_required: state.auth_required,
transports: vec!["http", "sse"],
capabilities,
job_operation_store_id: runtime_request_is_authorized(&request, &state)
.then(|| state.runtime_threads.job_operation_store_id().to_string()),
account,
experimental: RuntimeExperimentalCapabilities::default(),
version,
@@ -7353,6 +7377,9 @@ async fn list_mcp_servers(
connected: pool
.as_ref()
.is_some_and(|pool| pool.connected_servers().contains(&name.as_str())),
has_connection_backoff: pool
.as_ref()
.and_then(|pool| pool.has_connection_backoff(&name, config.timeouts, &server_cfg)),
enabled_tools: server_cfg.enabled_tools.clone(),
disabled_tools: server_cfg.disabled_tools.clone(),
always_allow_tools: server_cfg.always_allow_tools.clone(),
@@ -7377,6 +7404,7 @@ async fn list_mcp_servers(
connected: pool
.as_ref()
.is_some_and(|pool| pool.connected_servers().contains(&name.as_str())),
has_connection_backoff: None,
enabled_tools: server_cfg.enabled_tools.clone(),
disabled_tools: server_cfg.disabled_tools.clone(),
always_allow_tools: server_cfg.always_allow_tools.clone(),
@@ -12054,6 +12082,37 @@ struct ProvidersResponse {
/// whichever entry happens to share the generic kind.
current_provider_id: Option<String>,
providers: Vec<ProviderEntry>,
catalog_revision: String,
public_catalog: PublicCatalogStatus,
}
#[derive(Debug, Clone, Serialize)]
struct PublicCatalogStatus {
freshness: codewhale_engine::models_dev_live::ModelsDevFreshness,
fetched_at: Option<u64>,
outcome: &'static str,
error: Option<&'static str>,
}
fn public_catalog_status() -> PublicCatalogStatus {
let snapshot = codewhale_engine::models_dev_live::status();
PublicCatalogStatus {
freshness: snapshot.freshness,
fetched_at: snapshot.fetched_at,
outcome: if snapshot.last_error.is_some() {
"failed"
} else {
"cached"
},
error: snapshot.last_error.is_some().then_some("refresh_failed"),
}
}
#[derive(Serialize)]
struct RefreshProviderModelsResponse {
#[serde(flatten)]
provider: codewhale_engine::provider_lake::CatalogUpdateReceipt,
public_catalog: PublicCatalogStatus,
}
/// Entry in `GET /v1/providers/{id}/models`.
@@ -12739,6 +12798,7 @@ async fn list_providers(
#[cfg(any(test, feature = "test-support"))]
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let config = state.config.read().clone();
let (live_generation, cloud_generation) = codewhale_engine::provider_lake::catalog_revision();
secrets::invalidate_stale_account_catalog(&config);
let active_identity = config.active_provider_identity().ok();
let current = active_identity.as_ref().map_or_else(|| config.provider.clone().unwrap_or_else(|| "unavailable".into()), |identity| identity.persisted_kind().to_string());
@@ -12754,6 +12814,8 @@ async fn list_providers(
current_provider_id: active_identity.as_ref().and_then(|identity| identity.persisted_id()).map(str::to_string),
current,
providers,
catalog_revision: format!("{live_generation}:{cloud_generation}"),
public_catalog: public_catalog_status(),
}))
})
.await
@@ -12846,13 +12908,15 @@ async fn list_provider_models(
#[serde(deny_unknown_fields)]
struct RefreshProviderModelsParams {
model_provider_id: Option<String>,
#[serde(default)]
force_catalog: bool,
}
async fn refresh_provider_models(
State(state): State<RuntimeApiState>,
Path(id): Path<String>,
Query(params): Query<RefreshProviderModelsParams>,
) -> Result<Json<codewhale_engine::provider_lake::CatalogUpdateReceipt>, ApiError> {
) -> Result<Json<RefreshProviderModelsResponse>, ApiError> {
let runtime = tokio::runtime::Handle::current();
#[cfg(any(test, feature = "test-support"))]
let env_ticket = codewhale_engine::test_support::env_scope_ticket();
@@ -12861,9 +12925,40 @@ async fn refresh_provider_models(
let _membership = codewhale_engine::test_support::join_env_scope(env_ticket);
let config = state.config.read().clone();
let identity = provider_models_identity(&config, &id, params.model_provider_id.as_deref())?;
Ok(Json(runtime.block_on(
codewhale_engine::provider_lake::update_provider_catalog(&config, &identity),
)))
Ok(Json(runtime.block_on(async {
let before = public_catalog_status();
let (public_result, provider) = tokio::join!(
codewhale_engine::models_dev_live::refresh(params.force_catalog),
codewhale_engine::provider_lake::update_provider_catalog(&config, &identity),
);
let mut public_catalog = public_catalog_status();
match public_result {
Ok(_)
if public_catalog.error.is_none()
&& (params.force_catalog
|| before.fetched_at != public_catalog.fetched_at) =>
{
public_catalog.outcome = "updated";
}
Ok(_) => {}
Err(error) => {
use codewhale_engine::models_dev_live::ModelsDevRefreshError;
public_catalog.outcome = "failed";
public_catalog.error = Some(match error {
ModelsDevRefreshError::Disabled => "fetch_disabled",
ModelsDevRefreshError::Network(_) => "network",
ModelsDevRefreshError::HttpStatus(_) => "http_status",
ModelsDevRefreshError::InvalidResponse(_) => "invalid_response",
ModelsDevRefreshError::EmptyCatalog => "empty_catalog",
ModelsDevRefreshError::Io(_) => "cache_io",
});
}
}
RefreshProviderModelsResponse {
provider,
public_catalog,
}
})))
})
.await
.map_err(|_| ApiError::internal("Provider model refresh failed"))?
@@ -14826,6 +14921,7 @@ base_url = "http://127.0.0.1:9/v1"
config: Arc::new(parking_lot::RwLock::new(config)),
workspace: workspace.clone(),
plugin_discovery: codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv(),
plugin_ui_grants: Arc::new(plugins::PluginUiGrants::default()),
task_manager,
runtime_threads,
cors_origins: Vec::new(),
@@ -0,0 +1,274 @@
use axum::extract::{DefaultBodyLimit, State};
use axum::http::{HeaderValue, header};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use codewhale_account_cli::{AccountClient, AccountStatus};
use codewhale_secrets::account::AccountAuthBundle;
use serde::Deserialize;
use serde_json::{Value, json};
use super::{ApiError, RuntimeApiState, auth};
pub(super) fn routes(state: &RuntimeApiState) -> Router<RuntimeApiState> {
Router::new()
.route("/v1/account/status", get(status))
.route("/v1/account/lease", post(lease))
.route("/v1/account/import", post(import))
.route("/v1/account/session", delete(logout))
.route("/v1/account/claim", post(claim).delete(cancel_claim))
.layer(DefaultBodyLimit::max(256 * 1024))
.route_layer(middleware::from_fn_with_state(
state.clone(),
auth::require_host_token,
))
.layer(middleware::from_fn(no_store))
}
async fn no_store(request: axum::extract::Request, next: Next) -> Response {
let mut response = next.run(request).await;
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response
}
fn client(state: &RuntimeApiState) -> Result<AccountClient, ApiError> {
AccountClient::new(
state.config_profile.as_deref(),
Some(&codewhale_engine::profile_constitution::runtime_account_api_base()),
)
.map_err(|_| ApiError::internal("Codewhale account storage is unavailable"))
}
fn receipt(status: AccountStatus) -> Value {
let display_name = status
.user
.as_ref()
.map(|user| {
if user.display_name.trim().is_empty() {
user.id.as_str()
} else {
user.display_name.as_str()
}
})
.map(|name| name.chars().take(120).collect::<String>());
json!({
"apiBase": status.api_base,
"profile": status.profile,
"state": status.account.state,
"revision": status.revision,
"accountId": status.account.account_id,
"sessionId": status.account.session_id,
"expiresAt": status.account.expires_at,
"displayName": display_name,
"importAllowed": status.import_allowed,
})
}
fn read_status(client: &AccountClient) -> Result<Value, ApiError> {
client
.status()
.map(receipt)
.map_err(|_| ApiError::internal("Codewhale account storage could not be read"))
}
async fn blocking(
operation: impl FnOnce() -> Result<Value, ApiError> + Send + 'static,
) -> Result<Response, ApiError> {
tokio::task::spawn_blocking(operation)
.await
.map_err(|_| ApiError::internal("Codewhale account operation failed"))?
.map(|body| Json(body).into_response())
}
async fn status(State(state): State<RuntimeApiState>) -> Result<Response, ApiError> {
blocking(move || read_status(&client(&state)?)).await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct LeaseRequest {
rejected_access_token: Option<String>,
}
async fn lease(
State(state): State<RuntimeApiState>,
Json(request): Json<LeaseRequest>,
) -> Result<Response, ApiError> {
if request
.rejected_access_token
.as_ref()
.is_some_and(|token| token.len() > 64 * 1024)
{
return Err(ApiError::bad_request("Invalid rejected account credential"));
}
blocking(move || {
let client = client(&state)?;
let lease = client
.fresh_auth(request.rejected_access_token.as_deref())
.map_err(|_| ApiError::conflict("Reconnect your Codewhale account and try again"))?;
let mut status = read_status(&client)?;
if let Some(lease) = lease {
if status["revision"].as_str() != Some(lease.snapshot.fingerprint().as_str())
|| status["accountId"].as_str()
!= lease.bundle.user.as_ref().map(|user| user.id.as_str())
|| status["sessionId"].as_str()
!= lease
.bundle
.session
.as_ref()
.map(|session| session.id.as_str())
|| status["accountId"].as_str().is_none()
|| status["sessionId"].as_str().is_none()
{
return Err(ApiError::conflict(
"The Codewhale account changed; try again",
));
}
status["accessToken"] = json!(lease.bundle.access_token);
}
Ok(status)
})
.await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ImportRequest {
expected_revision: String,
auth: AccountAuthBundle,
#[serde(default)]
replace_existing: bool,
}
async fn import(
State(state): State<RuntimeApiState>,
Json(request): Json<ImportRequest>,
) -> Result<Response, ApiError> {
blocking(move || {
let client = client(&state)?;
let committed = client
.import_with_receipt(
request.auth,
&request.expected_revision,
request.replace_existing,
)
.map_err(|_| {
ApiError::internal("The account connection could not be confirmed; try again")
})?
.ok_or_else(|| {
ApiError::conflict("The Codewhale account changed; try again")
.with_code("account_import_superseded")
})?;
let stored = committed
.load()
.map_err(|_| ApiError::internal("The account connection could not be confirmed"))?
.ok_or_else(|| ApiError::internal("The account connection could not be confirmed"))?;
let revision = committed.fingerprint();
let mut status = receipt(AccountStatus {
profile: codewhale_secrets::account::normalize_account_profile(
state.config_profile.as_deref(),
),
api_base: stored.api_base,
account: committed
.runtime_info_at(chrono::Utc::now())
.map_err(|_| ApiError::internal("The account connection could not be confirmed"))?,
user: stored.bundle.user,
revision: revision.clone(),
import_allowed: false,
});
status["committedRevision"] = json!(revision);
Ok(status)
})
.await
}
async fn claim(
State(state): State<RuntimeApiState>,
Json(request): Json<CancelClaimRequest>,
) -> Result<Response, ApiError> {
blocking(move || {
let client = client(&state)?;
let claimed = client
.claim_if_unchanged(&request.expected_revision)
.map_err(|_| ApiError::internal("The account connection could not be started"))?
.ok_or_else(|| ApiError::conflict("The Codewhale account changed; try again"))?;
let status = read_status(&client)?;
if status["revision"].as_str() != Some(claimed.fingerprint().as_str()) {
return Err(ApiError::conflict(
"The Codewhale account changed; try again",
));
}
Ok(status)
})
.await
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct CancelClaimRequest {
expected_revision: String,
}
async fn cancel_claim(
State(state): State<RuntimeApiState>,
Json(request): Json<CancelClaimRequest>,
) -> Result<Response, ApiError> {
blocking(move || {
let client = client(&state)?;
let cancelled = client
.cancel_claim(&request.expected_revision)
.map_err(|_| ApiError::internal("The account connection could not be cancelled"))?;
let mut status = read_status(&client)?;
status["cancelled"] = json!(cancelled);
Ok(status)
})
.await
}
async fn logout(
State(state): State<RuntimeApiState>,
Json(request): Json<CancelClaimRequest>,
) -> Result<Response, ApiError> {
blocking(move || {
let client = client(&state)?;
let expected = client
.snapshot()
.map_err(|_| ApiError::internal("Codewhale account storage could not be read"))?;
if expected.fingerprint() != request.expected_revision {
return Err(ApiError::conflict(
"The Codewhale account changed; try again",
));
}
let expected_session = expected
.load()
.ok()
.flatten()
.and_then(|stored| stored.bundle.session)
.map(|session| session.id);
let expected_profile =
codewhale_secrets::account::normalize_account_profile(state.config_profile.as_deref());
let revoked = client
.logout_if_unchanged(&expected)
.map_err(|_| ApiError::internal("The Codewhale account could not be signed out"))?;
{
let config = state.config.read();
let mut access = config.account_model_access.write();
if access.as_ref().is_some_and(|access| {
expected_session.as_deref() == Some(access.session_id.as_str())
&& codewhale_secrets::account::normalize_account_profile(
access.profile.as_deref(),
) == expected_profile
}) {
access.take();
}
}
let mut status = read_status(&client)?;
status["remoteRevoked"] = json!(revoked);
Ok(status)
})
.await
}
+116 -2
View File
@@ -2518,8 +2518,13 @@ pub(super) async fn worktree_remove(
Query(scope): Query<ThreadScopeQuery>,
Json(request): Json<WorktreeRemoveRequest>,
) -> Result<Json<Value>, GitWriteError> {
let admission = state.runtime_threads.session_checkpoint_guard().await;
let root = scoped_workspace(&state, scope.thread_id.as_deref()).await?;
let _guard = state.git_writes.try_lock().map_err(|_| busy_error())?;
let guard = state
.git_writes
.clone()
.try_lock_owned()
.map_err(|_| busy_error())?;
let (main, rows) = repository_worktrees(&root).await?;
let wanted = FsPath::new(request.path.trim());
let Some(row) = rows.iter().find(|row| FsPath::new(&row.path) == wanted) else {
@@ -2544,6 +2549,40 @@ pub(super) async fn worktree_remove(
.reject_active_turns_in_workspace(&path)
.await
.map_err(|error| coded(StatusCode::CONFLICT, "worktree_busy", error.to_string()))?;
let managers = state.runtime_threads.shell_managers_snapshot().await;
let job_workspace = path.clone();
let busy = tokio::task::spawn_blocking(move || -> Result<bool, ApiError> {
let worktree = job_workspace.canonicalize().ok();
for (_, manager) in managers {
let mut manager = manager
.lock()
.map_err(|_| ApiError::internal("job listing failed"))?;
for job in manager.list_jobs() {
if job.stale || job.status != codewhale_engine::tools::shell::ShellStatus::Running {
continue;
}
let cwd = job.cwd.canonicalize().ok();
if match (&worktree, cwd) {
(Some(worktree), Some(cwd)) => {
cwd.starts_with(worktree) || worktree.starts_with(&cwd)
}
_ => true,
} {
return Ok(true);
}
}
}
Ok(false)
})
.await
.map_err(|_| ApiError::internal("job listing failed"))??;
if busy {
return Err(coded(
StatusCode::CONFLICT,
"worktree_busy",
"A shell job is still running in or around this worktree. Stop it before archiving.",
));
}
if !row.prunable {
let status = git_read_ok(
&path,
@@ -2578,7 +2617,82 @@ pub(super) async fn worktree_remove(
}
args.push("--".to_string());
args.push(row.path.clone());
let run = git_write(&main, args).await?;
let managers = state.runtime_threads.shell_managers_snapshot().await;
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(super::jobs::operation_error)?;
let runtime_owner = state.runtime_threads.clone();
let store = state.runtime_threads.job_operation_store();
let runtime = tokio::runtime::Handle::current();
let fence_path = path.clone();
let run = tokio::task::spawn_blocking(move || {
let _admission = admission;
let _guard = guard;
store.with_job_operations(|transaction| {
Ok((|| -> Result<GitRun, ApiError> {
let worktree = fence_path.canonicalize().unwrap_or(fence_path);
let worktree = if cfg!(windows) {
let raw = worktree
.to_str()
.ok_or_else(|| ApiError::internal("worktree path unavailable"))?;
PathBuf::from(super::jobs::plain_windows_path(raw))
} else {
worktree
};
for mut record in transaction.list().map_err(super::jobs::operation_error)? {
let (operation, _) = super::jobs::project_operation(
transaction,
&mut record,
&managers,
&generation,
)?;
if !matches!(operation.status.as_str(), "completed" | "rejected")
&& (super::jobs::overlaps(&worktree, &operation.workspace)
|| super::jobs::overlaps(&worktree, &operation.cwd))
{
return Err(ApiError::conflict(
"a workspace job is running or unconfirmed",
)
.with_code("worktree_busy"));
}
}
for (_, manager) in &managers {
let mut manager = manager
.lock()
.map_err(|_| ApiError::internal("job listing failed"))?;
for job in manager.list_jobs() {
if !job.stale
&& job.status == codewhale_engine::tools::shell::ShellStatus::Running
{
let cwd = job.cwd.canonicalize().ok().and_then(|cwd| {
if cfg!(windows) {
cwd.to_str().map(|raw| {
PathBuf::from(super::jobs::plain_windows_path(raw))
})
} else {
Some(cwd)
}
});
if cwd.is_none_or(|cwd| super::jobs::overlaps(&worktree, &cwd)) {
return Err(ApiError::conflict("a workspace job is still running")
.with_code("worktree_busy"));
}
}
}
}
if runtime_owner.job_operation_generation().ok().as_deref()
!= Some(generation.as_str())
{
return Err(ApiError::internal("job operation owner changed"));
}
runtime.block_on(git_write(&main, args))
})())
})
})
.await
.map_err(|_| ApiError::internal("worktree removal failed"))?
.map_err(super::jobs::operation_error)??;
if !run.status_success {
return Err(ApiError::bad_request(output_tail(&run)).into());
}
+695 -85
View File
@@ -6,7 +6,7 @@
//! per-session completion drain never claims client-launched work as model
//! evidence — and the model's jobs never appear "client-owned" here.
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use axum::Json;
use axum::extract::{Path, Query, State};
@@ -14,9 +14,15 @@ use axum::http::StatusCode;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use codewhale_engine::runtime_threads::RuntimeThreadStore;
use codewhale_engine::runtime_threads::job_operations::{
JobOperation, JobOperationRecord, JobOperationTransaction, JobOperationsBusy,
request_fingerprint, validate_key,
};
use codewhale_engine::tools::shell::SharedShellManager;
use codewhale_engine::tools::shell::{
PtyDimensions, ShellJobSnapshot, ShellManager, ShellOutputChunk, ShellOutputStream,
ShellResult, ShellStatus,
PtyDimensions, ShellJobSnapshot, ShellLaunchNotStarted, ShellManager, ShellOutputChunk,
ShellOutputStream, ShellResult, ShellStatus,
};
use super::{ApiError, RuntimeApiState, map_thread_err};
@@ -170,11 +176,266 @@ pub(super) struct CreateJobRequest {
tty: bool,
#[serde(default)]
env: HashMap<String, String>,
operation_key: Option<String>,
purpose: Option<String>,
expected_store_id: Option<String>,
expected_workspace: Option<String>,
}
#[derive(Debug, Serialize)]
pub(super) struct CreateJobResponse {
job: JobView,
job: Option<JobView>,
#[serde(skip_serializing_if = "Option::is_none")]
operation: Option<JobOperation>,
#[serde(skip_serializing_if = "Option::is_none")]
request_fingerprint: Option<String>,
}
pub(super) type JobManagers = Vec<(String, SharedShellManager)>;
pub(super) fn operation_error(error: anyhow::Error) -> ApiError {
if error.downcast_ref::<JobOperationsBusy>().is_some() {
ApiError::conflict("job operation admission is busy").with_code("job_operation_pending")
} else {
ApiError::internal("job operation storage unavailable")
}
}
pub(super) fn project_operation(
transaction: &JobOperationTransaction<'_>,
record: &mut JobOperationRecord,
managers: &JobManagers,
generation: &str,
) -> Result<(JobOperation, Option<JobView>), ApiError> {
let mut operation = record.operation.clone();
let mut view = None;
if !matches!(operation.status.as_str(), "completed" | "rejected") {
operation.status = "unknown".to_string();
if record.owner_generation == generation
&& let Some(job_id) = record.operation.job_id.clone()
&& let Some((_, manager)) = managers
.iter()
.find(|(thread, _)| thread == &operation.thread_id)
&& let Ok(mut manager) = manager.lock()
&& let Some(witness) = manager.job_receipt_witness(&job_id)
&& !witness.wait_failed
&& (witness.status == ShellStatus::Running || witness.terminal_confirmed)
&& witness.owner_session_id == api_job_scope(&operation.thread_id)
&& witness.cwd == operation.cwd
{
operation.status = if witness.status == ShellStatus::Running {
"running"
} else {
"completed"
}
.to_string();
if operation.status == "completed" {
operation.job_status = Some(format!("{:?}", witness.status));
operation.exit_code = witness.exit_code;
operation.updated_at_ms = chrono::Utc::now()
.timestamp_millis()
.max(operation.created_at_ms);
record.operation = operation.clone();
transaction.save(record).map_err(operation_error)?;
manager.release_job_receipt(&job_id);
}
if let Ok(detail) = manager.inspect_job(&job_id)
&& !detail.snapshot.stale
{
view = Some(JobView::new(
detail.snapshot,
operation.thread_id.clone(),
&manager,
));
}
}
} else if record.owner_generation == generation
&& let Some(job_id) = operation.job_id.as_deref()
&& let Some((_, manager)) = managers
.iter()
.find(|(thread, _)| thread == &operation.thread_id)
&& let Ok(mut manager) = manager.lock()
&& let Ok(detail) = manager.inspect_job(job_id)
&& !detail.snapshot.stale
&& detail.snapshot.owner_session_id == api_job_scope(&operation.thread_id)
&& detail.snapshot.cwd == operation.cwd
{
view = Some(JobView::new(
detail.snapshot,
operation.thread_id.clone(),
&manager,
));
}
Ok((operation, view))
}
fn observe_operation(
store: RuntimeThreadStore,
runtime_owner: std::sync::Weak<
codewhale_engine::runtime_threads::manager::RuntimeThreadManager,
>,
manager: std::sync::Weak<std::sync::Mutex<ShellManager>>,
thread_id: String,
generation: String,
id: String,
) {
tokio::spawn(async move {
let mut delay = std::time::Duration::from_millis(500);
loop {
tokio::time::sleep(delay).await;
let store = store.clone();
let runtime_owner = runtime_owner.clone();
let manager = manager.clone();
let thread_id = thread_id.clone();
let generation = generation.clone();
let id = id.clone();
let result = tokio::task::spawn_blocking(move || {
let Some(runtime_owner) = runtime_owner.upgrade() else {
return Ok(None);
};
if runtime_owner.job_operation_generation().ok().as_deref()
!= Some(generation.as_str())
{
return Ok(None);
}
let Some(manager) = manager.upgrade() else {
return Ok(None);
};
let managers = vec![(thread_id, manager)];
store.with_job_operations(|transaction| {
let Some(mut record) = transaction.get(&id)? else {
return Ok(None);
};
let projected =
project_operation(transaction, &mut record, &managers, &generation)
.map_err(|_| anyhow::anyhow!("job observation unavailable"))?;
match projected.0.status.as_str() {
"running" => Ok(Some(true)),
"unknown" => {
let available =
record.operation.job_id.as_deref().is_some_and(|job_id| {
managers[0].1.lock().ok().is_some_and(|mut manager| {
manager.job_receipt_witness(job_id).is_some()
})
});
Ok(available.then_some(false))
}
_ => Ok(None),
}
})
})
.await;
match result {
Ok(Ok(Some(true))) => delay = std::time::Duration::from_millis(500),
Ok(Ok(Some(false))) => delay = (delay * 2).min(std::time::Duration::from_secs(10)),
Ok(Ok(None)) => break,
_ => delay = (delay * 2).min(std::time::Duration::from_secs(10)),
}
}
});
}
pub(super) fn overlaps(left: &std::path::Path, right: &std::path::Path) -> bool {
left.starts_with(right) || right.starts_with(left)
}
#[derive(Serialize)]
pub(super) struct JobOperationsResponse {
operations: Vec<JobOperation>,
}
pub(super) async fn list_job_operations(
State(state): State<RuntimeApiState>,
) -> Result<Json<JobOperationsResponse>, ApiError> {
list_operations(&state, None).await
}
pub(super) async fn list_thread_job_operations(
State(state): State<RuntimeApiState>,
Path(thread): Path<String>,
) -> Result<Json<JobOperationsResponse>, ApiError> {
state
.runtime_threads
.get_thread(&thread)
.await
.map_err(map_thread_err)?;
list_operations(&state, Some(thread)).await
}
async fn list_operations(
state: &RuntimeApiState,
thread: Option<String>,
) -> Result<Json<JobOperationsResponse>, ApiError> {
let managers = state.runtime_threads.shell_managers_snapshot().await;
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(operation_error)?;
let store = state.runtime_threads.job_operation_store();
let operations = tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let mut operations = Vec::new();
for mut record in transaction.list()? {
if thread
.as_ref()
.is_none_or(|thread| thread == &record.operation.thread_id)
{
let (operation, _) =
project_operation(transaction, &mut record, &managers, &generation)
.map_err(|_| anyhow::anyhow!("job operation projection failed"))?;
operations.push(operation);
}
}
Ok(operations)
})
})
.await
.map_err(|_| ApiError::internal("job operation listing failed"))?
.map_err(operation_error)?;
Ok(Json(JobOperationsResponse { operations }))
}
pub(super) async fn get_job_operation(
State(state): State<RuntimeApiState>,
Path((thread, key)): Path<(String, String)>,
) -> Result<Json<CreateJobResponse>, ApiError> {
validate_key(&key).map_err(|_| ApiError::bad_request("invalid job operation key"))?;
state
.runtime_threads
.get_thread(&thread)
.await
.map_err(map_thread_err)?;
let managers = state.runtime_threads.shell_managers_snapshot().await;
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(operation_error)?;
let store = state.runtime_threads.job_operation_store();
let result = tokio::task::spawn_blocking(move || {
store.with_job_operations(|transaction| {
let id = transaction.operation_id(&thread, &key)?;
let Some(mut record) = transaction.get(&id)? else {
return Ok(None);
};
let result = project_operation(transaction, &mut record, &managers, &generation)
.map_err(|_| anyhow::anyhow!("job operation projection failed"))?;
Ok(Some((
result.0,
result.1,
record.request_fingerprint.clone(),
)))
})
})
.await
.map_err(|_| ApiError::internal("job operation lookup failed"))?
.map_err(operation_error)?;
let (operation, job, fingerprint) =
result.ok_or_else(|| ApiError::not_found("job operation not found"))?;
Ok(Json(CreateJobResponse {
job,
operation: Some(operation),
request_fingerprint: Some(fingerprint),
}))
}
/// Resolve a job `cwd` against the thread workspace, never the server's cwd.
@@ -189,9 +450,8 @@ pub(super) struct CreateJobResponse {
/// whose name is not UTF-8 is refused rather than lossily renamed into a
/// different one. On Windows the `\\?\` verbatim prefix `canonicalize` adds
/// is dropped ([`plain_windows_path`]) because `cmd.exe` refuses it as a
/// current directory. Runs on `tokio::fs`, so no path resolution blocks a
/// runtime worker.
async fn resolve_job_cwd(
/// current directory.
fn resolve_job_cwd(
workspace: &std::path::Path,
raw: &str,
trust_mode: bool,
@@ -203,18 +463,12 @@ async fn resolve_job_cwd(
workspace.join(requested)
};
let not_a_directory = || ApiError::bad_request("cwd must be an existing directory");
let resolved = tokio::fs::canonicalize(&candidate)
.await
.map_err(|_| not_a_directory())?;
if !tokio::fs::metadata(&resolved)
.await
.is_ok_and(|meta| meta.is_dir())
{
let resolved = std::fs::canonicalize(&candidate).map_err(|_| not_a_directory())?;
if !std::fs::metadata(&resolved).is_ok_and(|meta| meta.is_dir()) {
return Err(not_a_directory());
}
if !trust_mode {
let root = tokio::fs::canonicalize(workspace)
.await
let root = std::fs::canonicalize(workspace)
.map_err(|_| ApiError::internal("thread workspace is unavailable"))?;
if !resolved.starts_with(&root) {
return Err(ApiError::forbidden(
@@ -235,7 +489,7 @@ async fn resolve_job_cwd(
/// `\\?\C:\dir` -> `C:\dir` and `\\?\UNC\host\share` -> `\\host\share`; any
/// other spelling is returned unchanged. A string rule so every host tests it.
fn plain_windows_path(path: &str) -> String {
pub(super) fn plain_windows_path(path: &str) -> String {
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
return format!(r"\\{rest}");
}
@@ -250,6 +504,16 @@ fn plain_windows_path(path: &str) -> String {
}
}
fn canonical_job_path(path: &std::path::Path) -> Option<std::path::PathBuf> {
let path = std::fs::canonicalize(path).ok()?;
if cfg!(windows) {
path.to_str()
.map(|raw| std::path::PathBuf::from(plain_windows_path(raw)))
} else {
Some(path)
}
}
/// `POST /v1/threads/{id}/jobs` — launch a client-owned background job under
/// the thread's own sandbox policy. The client asking is the approval; the
/// thread's posture still bounds what the job may touch.
@@ -262,6 +526,9 @@ pub(super) async fn create_thread_job(
if command.is_empty() {
return Err(ApiError::bad_request("command is required"));
}
if command.contains('\0') {
return Err(ApiError::bad_request("command contains a NUL byte"));
}
if command.len() > COMMAND_MAX_BYTES {
return Err(ApiError::bad_request(format!(
"command must be at most {COMMAND_MAX_BYTES} bytes"
@@ -273,28 +540,145 @@ pub(super) async fn create_thread_job(
)));
}
for (key, value) in &request.env {
if key.len() > ENV_KEY_MAX_BYTES || key.contains(['=', '\0']) {
if key.is_empty() || key.len() > ENV_KEY_MAX_BYTES || key.contains(['=', '\0']) {
return Err(ApiError::bad_request("invalid env key"));
}
if value.len() > ENV_VALUE_MAX_BYTES || value.contains('\0') {
return Err(ApiError::bad_request("invalid env value"));
}
}
#[cfg(target_env = "ohos")]
if request.tty {
return Err(ApiError::bad_request(
"TTY shell mode is not supported on HarmonyOS/OpenHarmony",
));
}
let keyed = match (
&request.operation_key,
&request.purpose,
&request.expected_store_id,
&request.expected_workspace,
) {
(None, None, None, None) => false,
(Some(key), Some(purpose), Some(expected), Some(workspace)) => {
if workspace.trim().is_empty() {
return Err(ApiError::bad_request("expected_workspace is required"));
}
validate_key(key).map_err(|_| ApiError::bad_request("invalid job operation key"))?;
if !matches!(purpose.as_str(), "setup" | "run" | "archive" | "command") {
return Err(ApiError::bad_request("invalid job operation purpose"));
}
if expected != state.runtime_threads.job_operation_store_id() {
return Err(ApiError::conflict("job operation store changed")
.with_code("job_operation_store_changed"));
}
true
}
_ => {
return Err(ApiError::bad_request(
"operation_key, purpose, expected_store_id and expected_workspace must be supplied together",
));
}
};
let admission = state.runtime_threads.session_checkpoint_guard().await;
let thread = state
.runtime_threads
.get_thread(&thread_id)
.await
.map_err(map_thread_err)?;
let command = command.to_string();
let fingerprint = if let Some(key) = request.operation_key.as_deref() {
let env: BTreeMap<_, _> = request.env.iter().collect();
let env: Vec<_> = env.into_iter().collect();
let payload = serde_json::to_vec(&(
2,
&thread_id,
&request.expected_workspace,
&command,
&request.cwd,
request.timeout_ms.unwrap_or(120_000),
request.tty,
env,
&request.purpose,
))
.map_err(|_| ApiError::internal("job operation validation failed"))?;
Some(
request_fingerprint(key, &payload)
.map_err(|_| ApiError::bad_request("invalid job operation key"))?,
)
} else {
None
};
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(operation_error)?;
let store = state.runtime_threads.job_operation_store();
if let Some(key) = request.operation_key.clone() {
let lookup_store = store.clone();
let lookup_thread = thread_id.clone();
let lookup_fingerprint = fingerprint.clone();
let lookup_generation = generation.clone();
let managers = state.runtime_threads.shell_managers_snapshot().await;
let replay = tokio::task::spawn_blocking(move || {
lookup_store.with_job_operations(|transaction| {
let id = transaction.operation_id(&lookup_thread, &key)?;
let Some(mut record) = transaction.get(&id)? else {
return Ok(None);
};
if Some(&record.request_fingerprint) != lookup_fingerprint.as_ref() {
return Ok(Some(Err(ApiError::conflict(
"job operation key belongs to a different request",
)
.with_code("job_operation_mismatch"))));
}
Ok(Some(
project_operation(transaction, &mut record, &managers, &lookup_generation).map(
|(operation, job)| (operation, job, record.request_fingerprint.clone()),
),
))
})
})
.await
.map_err(|_| ApiError::internal("job operation lookup failed"))?
.map_err(operation_error)?;
if let Some(result) = replay {
let (operation, job, fingerprint) = result?;
return Ok((
StatusCode::OK,
Json(CreateJobResponse {
job,
operation: Some(operation),
request_fingerprint: Some(fingerprint),
}),
));
}
}
if !thread.allow_shell {
return Err(ApiError::forbidden(
"this thread does not allow shell commands",
));
}
let requested_workspace = thread.workspace.clone();
let expected_workspace = request.expected_workspace.clone();
let workspace = tokio::task::spawn_blocking(move || {
let workspace = canonical_job_path(&requested_workspace)
.ok_or_else(|| ApiError::internal("thread workspace is unavailable"))?;
if expected_workspace.as_deref().is_some_and(|expected| {
canonical_job_path(std::path::Path::new(expected)).as_ref() != Some(&workspace)
}) {
return Err(ApiError::conflict("job operation workspace changed")
.with_code("job_operation_workspace_changed"));
}
Ok(workspace)
})
.await
.map_err(|_| ApiError::internal("thread workspace resolution failed"))??;
state
.runtime_threads
.validate_shell_access_policy(
&thread.workspace,
&workspace,
state.config_path.as_deref(),
state.config_profile.as_deref(),
)
@@ -313,49 +697,238 @@ pub(super) async fn create_thread_job(
"this server runs commands in an external sandbox backend, and a thread job starts a local process; run the command through the thread's shell tool instead",
));
}
// The manager resolves a relative `working_dir` against the server's own
// cwd, so hand it the directory resolved against the thread workspace.
let request_cwd = match request.cwd.as_deref() {
Some(cwd) => Some(resolve_job_cwd(&thread.workspace, cwd, thread.trust_mode).await?),
None => None,
};
let mut policy_thread = thread.clone();
policy_thread.workspace = workspace.clone();
let policy = state
.runtime_threads
.thread_job_sandbox_policy(&thread)
.thread_job_sandbox_policy(&policy_thread)
.await;
let manager = thread_manager(&state, &thread_id, true).await?;
let managers = state.runtime_threads.shell_managers_snapshot().await;
let store_id = state.runtime_threads.job_operation_store_id().to_string();
let runtime_owner = state.runtime_threads.clone();
let scope = api_job_scope(&thread_id);
let request_timeout = request.timeout_ms;
let request_tty = request.tty;
let request_env = request.env;
let command = command.to_string();
let job = tokio::task::spawn_blocking(move || -> Result<JobView, ApiError> {
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
let result = guard
.execute_with_options_env_for_session(
&command,
request_cwd.as_deref(),
request_timeout.unwrap_or(120_000),
true,
None,
request_tty,
Some(policy),
request_env,
&scope,
)
.map_err(|error| ApiError::internal(format!("job launch failed: {error}")))?;
let task_id = result.task_id.clone().unwrap_or_default();
let snapshot = guard
.inspect_job(&task_id)
.map_err(|error| {
ApiError::internal(format!("job launched but is not tracked: {error}"))
})?
.snapshot;
Ok(JobView::new(snapshot, thread_id, &guard))
let result = tokio::task::spawn_blocking(move || {
let _admission = admission;
store.with_job_operations(|transaction| {
Ok((|| -> Result<(StatusCode, CreateJobResponse), ApiError> {
let id = request
.operation_key
.as_deref()
.map(|key| transaction.operation_id(&thread_id, key))
.transpose()
.map_err(operation_error)?;
if let Some(id) = &id
&& let Some(mut existing) = transaction.get(id).map_err(operation_error)?
{
if Some(&existing.request_fingerprint) != fingerprint.as_ref() {
return Err(ApiError::conflict(
"job operation key belongs to a different request",
)
.with_code("job_operation_mismatch"));
}
let (operation, job) =
project_operation(transaction, &mut existing, &managers, &generation)?;
return Ok((
StatusCode::OK,
CreateJobResponse {
job,
operation: Some(operation),
request_fingerprint: Some(existing.request_fingerprint.clone()),
},
));
}
if canonical_job_path(&thread.workspace).as_ref() != Some(&workspace)
|| request
.expected_workspace
.as_deref()
.is_some_and(|expected| {
canonical_job_path(std::path::Path::new(expected)).as_ref()
!= Some(&workspace)
})
{
return Err(ApiError::conflict("job operation workspace changed")
.with_code("job_operation_workspace_changed"));
}
let raw_cwd = request.cwd.as_deref().unwrap_or(".");
let cwd = resolve_job_cwd(&workspace, raw_cwd, thread.trust_mode)?;
let cwd_path = std::path::PathBuf::from(&cwd);
for mut record in transaction.list().map_err(operation_error)? {
let (operation, _) =
project_operation(transaction, &mut record, &managers, &generation)?;
if overlaps(&workspace, &operation.workspace)
|| overlaps(&workspace, &operation.cwd)
|| overlaps(&cwd_path, &operation.workspace)
|| overlaps(&cwd_path, &operation.cwd)
{
if operation.status == "unknown" {
return Err(ApiError::conflict(
"an earlier job operation is unconfirmed",
)
.with_code("job_operation_unconfirmed"));
}
if keyed
&& request.purpose.as_deref() != Some("command")
&& operation.status == "running"
&& operation.purpose != "command"
{
return Err(ApiError::conflict(
"a workspace script is already running",
)
.with_code("job_operation_busy"));
}
}
}
runtime_owner
.validate_job_operation_thread(&thread)
.map_err(|_| {
ApiError::conflict("job operation workspace or shell policy changed")
.with_code("job_operation_workspace_changed")
})?;
if runtime_owner.job_operation_generation().ok().as_deref()
!= Some(generation.as_str())
{
return Err(ApiError::internal("job operation owner changed"));
}
let mut guard = manager
.lock()
.map_err(|_| ApiError::internal("job manager unavailable"))?;
guard
.validate_background_job(
&command,
&cwd_path,
request.timeout_ms.unwrap_or(120_000),
&policy,
&request.env,
)
.map_err(|error| ApiError::forbidden(error.to_string()))?;
if canonical_job_path(&thread.workspace).as_ref() != Some(&workspace)
|| canonical_job_path(&cwd_path).as_ref() != Some(&cwd_path)
{
return Err(ApiError::conflict("job operation workspace changed")
.with_code("job_operation_workspace_changed"));
}
let mut record = if let Some(id) = id {
let now = chrono::Utc::now().timestamp_millis();
let record = JobOperationRecord {
schema_version: 1,
operation: JobOperation {
id,
store_id,
thread_id: thread_id.clone(),
workspace,
cwd: cwd_path,
purpose: request.purpose.clone().expect("keyed purpose"),
status: "unknown".to_string(),
job_id: None,
job_status: None,
exit_code: None,
created_at_ms: now,
updated_at_ms: now,
},
request_fingerprint: fingerprint.expect("keyed fingerprint"),
owner_generation: generation.clone(),
};
transaction.save(&record).map_err(operation_error)?;
Some(record)
} else {
None
};
if runtime_owner.job_operation_generation().ok().as_deref()
!= Some(generation.as_str())
{
return Err(ApiError::internal("job operation owner changed")
.with_code("job_operation_unconfirmed"));
}
runtime_owner
.validate_job_operation_thread(&thread)
.map_err(|_| {
ApiError::internal("job operation thread changed")
.with_code("job_operation_unconfirmed")
})?;
let result = match guard.execute_with_options_env_for_session(
&command,
Some(&cwd),
request.timeout_ms.unwrap_or(120_000),
true,
None,
request.tty,
Some(policy),
request.env,
&scope,
) {
Ok(result) => result,
Err(error) if error.downcast_ref::<ShellLaunchNotStarted>().is_some() => {
if let Some(record) = &mut record {
record.operation.status = "rejected".to_string();
record.operation.updated_at_ms = chrono::Utc::now()
.timestamp_millis()
.max(record.operation.created_at_ms);
transaction.save(record).map_err(|_| {
ApiError::internal("job launch could not be confirmed")
.with_code("job_operation_unconfirmed")
})?;
return Err(ApiError::conflict("job did not start")
.with_code("job_operation_rejected"));
}
return Err(ApiError::internal("job did not start"));
}
Err(_) => {
return Err(ApiError::internal("job launch could not be confirmed")
.with_code("job_operation_unconfirmed"));
}
};
let task_id = result.task_id.ok_or_else(|| {
ApiError::internal("job launch could not be confirmed")
.with_code("job_operation_unconfirmed")
})?;
if record.is_some() {
guard.pin_job_receipt(&task_id).map_err(|_| {
ApiError::internal("job launch could not be confirmed")
.with_code("job_operation_unconfirmed")
})?;
}
let snapshot = guard
.inspect_job(&task_id)
.map_err(|_| {
ApiError::internal("job launch could not be confirmed")
.with_code("job_operation_unconfirmed")
})?
.snapshot;
let job = JobView::new(snapshot, thread_id.clone(), &guard);
if let Some(record) = &mut record {
record.operation.job_id = Some(task_id);
record.operation.status = "running".to_string();
record.operation.updated_at_ms = chrono::Utc::now()
.timestamp_millis()
.max(record.operation.created_at_ms);
transaction.save(record).map_err(operation_error)?;
observe_operation(
store.clone(),
std::sync::Arc::downgrade(&runtime_owner),
std::sync::Arc::downgrade(&manager),
thread_id,
generation,
record.operation.id.clone(),
);
}
Ok((
StatusCode::CREATED,
CreateJobResponse {
job: Some(job),
request_fingerprint: record
.as_ref()
.map(|record| record.request_fingerprint.clone()),
operation: record.map(|record| record.operation),
},
))
})())
})
})
.await
.map_err(|_| ApiError::internal("job launch failed"))??;
Ok((StatusCode::CREATED, Json(CreateJobResponse { job })))
.map_err(|_| ApiError::internal("job launch failed"))?
.map_err(operation_error)??;
Ok((result.0, Json(result.1)))
}
#[derive(Debug, Serialize)]
@@ -591,16 +1164,65 @@ pub(super) async fn kill_thread_job(
if job_id.len() > JOB_ID_MAX_BYTES {
return Err(ApiError::not_found("job not found"));
}
let admission = state.runtime_threads.session_checkpoint_guard().await;
let manager = thread_manager(&state, &thread_id, false).await?;
let store = state.runtime_threads.job_operation_store();
let generation = state
.runtime_threads
.job_operation_generation()
.map_err(operation_error)?;
let runtime_owner = state.runtime_threads.clone();
tokio::task::spawn_blocking(move || {
let _admission = admission;
if runtime_owner.job_operation_generation().ok().as_deref() != Some(generation.as_str()) {
return Err(ApiError::internal("job operation owner changed"));
}
let mut guard = manager.lock().unwrap_or_else(|e| e.into_inner());
let result = guard.kill(&job_id).map_err(map_job_err)?;
guard.mark_cancel_reported(&result);
let snapshot = guard.inspect_job(&job_id).map_err(map_job_err)?.snapshot;
Ok(Json(KillJobResponse {
job: JobView::new(snapshot, thread_id, &guard),
result,
}))
let pinned = guard.job_receipt_witness(&job_id).is_some();
let job = JobView::new(snapshot, thread_id.clone(), &guard);
drop(guard);
if !pinned {
return Ok(Json(KillJobResponse { job, result }));
}
let managers = vec![(thread_id.clone(), manager)];
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
let settlement = store.with_job_operations(|transaction| {
let mut records: Vec<_> = transaction
.list()?
.into_iter()
.filter(|record| {
record.operation.thread_id == thread_id
&& record.operation.job_id.as_deref() == Some(job_id.as_str())
})
.collect();
anyhow::ensure!(records.len() <= 1, "duplicate job operation binding");
let Some(mut record) = records.pop() else {
return Ok(false);
};
let (operation, _) =
project_operation(transaction, &mut record, &managers, &generation)
.map_err(|_| anyhow::anyhow!("job settlement unavailable"))?;
Ok(operation.status == "completed")
});
match settlement {
Ok(true) => return Ok(Json(KillJobResponse { job, result })),
Ok(false) => {}
Err(error) if error.downcast_ref::<JobOperationsBusy>().is_some() => {}
Err(_) => {
return Err(ApiError::internal("job stop could not be confirmed")
.with_code("job_operation_unconfirmed"));
}
}
if std::time::Instant::now() >= deadline {
return Err(ApiError::internal("job stop could not be confirmed")
.with_code("job_operation_unconfirmed"));
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
})
.await
.map_err(|_| ApiError::internal("job kill failed"))?
@@ -657,8 +1279,8 @@ mod tests {
}
}
#[tokio::test]
async fn job_cwd_resolves_against_the_thread_workspace() {
#[test]
fn job_cwd_resolves_against_the_thread_workspace() {
let tmp = tempfile::tempdir().unwrap();
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(workspace.join("packages/app")).unwrap();
@@ -667,40 +1289,30 @@ mod tests {
// Relative and absolute requests both come back as the canonical
// directory that was checked, never the caller's spelling.
let resolved = resolve_job_cwd(&workspace, "packages/app", false)
.await
.unwrap();
let resolved = resolve_job_cwd(&workspace, "packages/app", false).unwrap();
assert!(std::path::Path::new(&resolved).is_absolute(), "{resolved}");
assert_eq!(resolved, canonical(&workspace.join("packages/app")));
let absolute = workspace.join("packages");
assert_eq!(
resolve_job_cwd(&workspace, &absolute.to_string_lossy(), false)
.await
.unwrap(),
resolve_job_cwd(&workspace, &absolute.to_string_lossy(), false).unwrap(),
canonical(&absolute)
);
let missing = resolve_job_cwd(&workspace, "packages/none", false)
.await
.unwrap_err();
let missing = resolve_job_cwd(&workspace, "packages/none", false).unwrap_err();
assert_eq!(missing.status, StatusCode::BAD_REQUEST);
let outside_raw = outside.to_string_lossy().into_owned();
for raw in [outside_raw.as_str(), "../outside"] {
let error = resolve_job_cwd(&workspace, raw, false).await.unwrap_err();
let error = resolve_job_cwd(&workspace, raw, false).unwrap_err();
assert_eq!(error.status, StatusCode::FORBIDDEN, "{raw}");
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside, workspace.join("escape")).unwrap();
let error = resolve_job_cwd(&workspace, "escape", false)
.await
.unwrap_err();
let error = resolve_job_cwd(&workspace, "escape", false).unwrap_err();
assert_eq!(error.status, StatusCode::FORBIDDEN);
}
assert_eq!(
resolve_job_cwd(&workspace, "../outside", true)
.await
.unwrap(),
resolve_job_cwd(&workspace, "../outside", true).unwrap(),
canonical(&outside)
);
}
@@ -709,8 +1321,8 @@ mod tests {
/// checked: retargeting the requested symlink afterwards does not move
/// the child out of the workspace.
#[cfg(unix)]
#[tokio::test]
async fn job_cwd_launches_in_the_checked_directory_after_a_retarget() {
#[test]
fn job_cwd_launches_in_the_checked_directory_after_a_retarget() {
let tmp = tempfile::tempdir().unwrap();
let workspace = tmp.path().join("workspace");
let inside = workspace.join("inside");
@@ -720,7 +1332,7 @@ mod tests {
let link = workspace.join("link");
std::os::unix::fs::symlink(&inside, &link).unwrap();
let resolved = resolve_job_cwd(&workspace, "link", false).await.unwrap();
let resolved = resolve_job_cwd(&workspace, "link", false).unwrap();
assert_eq!(resolved, canonical(&inside));
std::fs::remove_file(&link).unwrap();
@@ -741,8 +1353,8 @@ mod tests {
/// than lossily renamed into a `U+FFFD` sibling. Linux only: macOS and
/// Windows filesystems do not store non-UTF-8 names.
#[cfg(target_os = "linux")]
#[tokio::test]
async fn job_cwd_refuses_a_directory_name_that_is_not_utf8() {
#[test]
fn job_cwd_refuses_a_directory_name_that_is_not_utf8() {
use std::os::unix::ffi::OsStrExt as _;
let tmp = tempfile::tempdir().unwrap();
let workspace = tmp.path().join("workspace");
@@ -751,9 +1363,7 @@ mod tests {
std::fs::create_dir_all(workspace.join("dir-\u{fffd}")).unwrap();
std::os::unix::fs::symlink(workspace.join(raw_name), workspace.join("alias")).unwrap();
let error = resolve_job_cwd(&workspace, "alias", false)
.await
.unwrap_err();
let error = resolve_job_cwd(&workspace, "alias", false).unwrap_err();
assert_eq!(error.status, StatusCode::BAD_REQUEST);
}
+307 -228
View File
@@ -13,6 +13,8 @@
//! `/plugin marketplace` command: local catalog documents only, tiers are
//! display-only, and installs route through the same reviewed installer.
use std::collections::{BTreeMap, HashMap};
use std::io::Read;
use std::net::SocketAddr;
use std::path::{Path as FsPath, PathBuf};
use std::sync::Arc;
@@ -25,7 +27,7 @@ use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri, header};
use axum::response::Response;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::io::AsyncReadExt;
use sha2::{Digest, Sha256};
use codewhale_plugins::marketplace::document::{
CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
@@ -179,6 +181,7 @@ pub(super) struct PluginUiSlotResponse {
pub(super) slot: String,
pub(super) base: String,
pub(super) entry: String,
pub(super) revision: String,
}
enum UiFileError {
@@ -506,87 +509,51 @@ fn ui_asset_response(bytes: Vec<u8>, content_type: &'static str) -> Response {
response
}
async fn ui_file_path(root: &FsPath, relative: &FsPath) -> Result<PathBuf, UiFileError> {
if relative.as_os_str().is_empty() {
return Err(UiFileError::Invalid("it is empty".into()));
}
let mut current = root.to_path_buf();
let mut last = None;
let mut components = relative.components().peekable();
while let Some(component) = components.next() {
let std::path::Component::Normal(part) = component else {
return Err(UiFileError::Invalid(
"it must be a relative path of literal components".into(),
));
};
current.push(part);
let metadata = match tokio::fs::symlink_metadata(&current).await {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(UiFileError::Missing);
}
Err(error) => return Err(UiFileError::Internal(error.to_string())),
};
if codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
return Err(UiFileError::Invalid(
"it must not traverse symbolic links".into(),
));
}
if components.peek().is_some() {
if !metadata.is_dir() {
return Err(UiFileError::Missing);
}
} else {
last = Some(metadata);
}
}
let Some(metadata) = last else {
return Err(UiFileError::Invalid("it is empty".into()));
};
if !metadata.is_file() {
return Err(UiFileError::Invalid("it is not a regular file".into()));
}
let canonical =
tokio::fs::canonicalize(&current)
.await
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => UiFileError::Missing,
_ => UiFileError::Internal(error.to_string()),
})?;
if !canonical.starts_with(root) {
return Err(UiFileError::Invalid(
"it resolves outside the staged bundle".into(),
));
}
Ok(canonical)
#[derive(Clone)]
struct PluginUiGrant {
selector: String,
authority: codewhale_config::PluginAuthority,
state_file: Arc<std::fs::File>,
root: PathBuf,
root_file: Arc<std::fs::File>,
ui_prefix: PathBuf,
file_hashes: Arc<BTreeMap<PathBuf, String>>,
expires: std::time::Instant,
}
async fn ui_file_bytes(path: &FsPath) -> Result<Vec<u8>, UiFileError> {
let file = tokio::fs::File::open(path)
.await
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => UiFileError::Missing,
_ => UiFileError::Internal(error.to_string()),
})?;
let metadata = file
.metadata()
.await
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if !metadata.is_file() {
return Err(UiFileError::Invalid("it is not a regular file".into()));
#[derive(Default)]
pub(super) struct PluginUiGrants {
entries: parking_lot::Mutex<HashMap<String, PluginUiGrant>>,
}
impl PluginUiGrants {
fn insert(&self, grant: PluginUiGrant) -> String {
let mut entries = self.entries.lock();
let now = std::time::Instant::now();
entries.retain(|_, value| value.expires > now);
if entries.len() >= 256
&& let Some(oldest) = entries
.iter()
.min_by_key(|(_, value)| value.expires)
.map(|(key, _)| key.clone())
{
entries.remove(&oldest);
}
loop {
let revision = format!("\"{}\"", uuid::Uuid::new_v4());
if !entries.contains_key(&revision) {
entries.insert(revision.clone(), grant);
return revision;
}
}
}
if metadata.len() > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
fn get(&self, revision: &str) -> Option<PluginUiGrant> {
let mut entries = self.entries.lock();
let now = std::time::Instant::now();
entries.retain(|_, value| value.expires > now);
entries.get(revision).cloned()
}
let mut file = file.take(PLUGIN_UI_MAX_BYTES + 1);
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.read_to_end(&mut bytes)
.await
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if bytes.len() as u64 > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
}
Ok(bytes)
}
fn ui_file_refusal(plugin: &LoadedPlugin, display: &str, error: UiFileError) -> ApiError {
@@ -610,74 +577,209 @@ fn ui_file_refusal(plugin: &LoadedPlugin, display: &str, error: UiFileError) ->
}
}
async fn ui_staged_root(plugin: &LoadedPlugin) -> Result<PathBuf, ApiError> {
let Some(staged_root) = plugin.staged_root.as_deref() else {
fn ui_root_matches(grant: &PluginUiGrant) -> Result<(), UiFileError> {
let metadata = std::fs::symlink_metadata(&grant.root)
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if !metadata.is_dir() || codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
return Err(UiFileError::Invalid("the staged root changed".into()));
}
let (canonical, current) = codewhale_util::workspace_dir::open_workspace_directory(&grant.root)
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if canonical != grant.root
|| !codewhale_util::files::same_file(&grant.root_file, &current)
.map_err(|error| UiFileError::Internal(error.to_string()))?
{
return Err(UiFileError::Invalid("the staged root changed".into()));
}
Ok(())
}
fn verified_ui_file(grant: &PluginUiGrant, relative: &FsPath) -> Result<Vec<u8>, UiFileError> {
let expected = grant
.file_hashes
.get(relative)
.ok_or(UiFileError::Missing)?;
ui_root_matches(grant)?;
let file = codewhale_util::fs_confined::open_read(&grant.root, &grant.root.join(relative))
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => UiFileError::Missing,
_ => UiFileError::Invalid(error.to_string()),
})?;
let metadata = file
.metadata()
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if metadata.len() > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
}
let mut bytes = Vec::with_capacity(metadata.len() as usize);
file.take(PLUGIN_UI_MAX_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| UiFileError::Internal(error.to_string()))?;
if bytes.len() as u64 > PLUGIN_UI_MAX_BYTES {
return Err(UiFileError::TooLarge);
}
let mut hash = Sha256::new();
hash.update(b"codewhale-plugin-file-bytes-v1\0");
hash.update(&bytes);
let actual = hash
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
if actual != *expected {
return Err(UiFileError::Invalid(
"the file no longer matches its reviewed bytes".into(),
));
}
ui_root_matches(grant)?;
Ok(bytes)
}
fn admit_ui_grant(state: &RuntimeApiState, grant: &PluginUiGrant) -> Result<(), ApiError> {
if grant.expires <= std::time::Instant::now() {
return Err(ui_stale_revision());
}
let registry = registry_for_state(state);
if !registry
.get(&grant.selector)
.is_some_and(LoadedPlugin::active)
|| registry.authority_for(&grant.selector).as_ref() != Some(&grant.authority)
{
return Err(ui_stale_revision());
}
codewhale_plugins::registry::capture_plugin_state_authority(
&grant.authority,
Some(&grant.state_file),
)
.map_err(|_| ui_stale_revision())?;
ui_root_matches(grant).map_err(|_| ui_stale_revision())?;
Ok(())
}
fn ui_stale_revision() -> ApiError {
ApiError {
status: StatusCode::PRECONDITION_FAILED,
message: "plugin UI authorization has expired or changed; reopen the plugin".into(),
code: None,
}
}
fn prepare_plugin_ui(
state: &RuntimeApiState,
selector: &str,
) -> Result<(String, PluginUiGrant), ApiError> {
let registry = registry_for_state(state);
let plugin = registry
.get(selector)
.cloned()
.ok_or_else(|| ApiError::not_found(format!("plugin '{selector}' not found")))?;
if !plugin.active() {
return Err(ApiError::bad_request(format!(
"plugin '{}' has no staged runtime snapshot; review, trust and enable it to stage its files",
"plugin '{}' must be active before loading its client UI",
plugin.name()
)));
};
let root = tokio::fs::canonicalize(staged_root)
.await
.map_err(|error| {
ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is unavailable: {error}",
plugin.name()
))
})?;
let metadata = tokio::fs::metadata(&root).await.map_err(|error| {
ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is unavailable: {error}",
}
let staged_root = plugin.staged_root.as_ref().ok_or_else(|| {
ApiError::bad_request(format!(
"plugin '{}' has no staged runtime snapshot",
plugin.name()
))
})?;
if !metadata.is_dir() {
return Err(ApiError::not_found(format!(
"plugin '{}' staged runtime snapshot is not a directory",
plugin.name()
)));
let authority = registry
.authority_for(selector)
.ok_or_else(|| ApiError::bad_request("plugin UI has no reviewed authority"))?;
let state_file = codewhale_plugins::registry::capture_plugin_state_authority(&authority, None)
.map_err(|_| ui_stale_revision())?;
codewhale_plugins::registry::verify_plugin_authority(&authority)
.map_err(|_| ui_stale_revision())?;
let validated =
codewhale_plugins::manifest::PluginManifest::validate_from_path(&authority.staged_manifest)
.map_err(|_| ui_stale_revision())?;
if validated.content_hash != authority.content_hash
|| validated.capability_hash != authority.capability_hash
{
return Err(ui_stale_revision());
}
Ok(root)
}
async fn gated_plugin_ui_root(
state: &RuntimeApiState,
selector: &str,
) -> Result<(LoadedPlugin, PathBuf), ApiError> {
let plugin = find_plugin(state, selector)?;
if !plugin.trusted() {
return Err(ApiError::bad_request(format!(
"plugin '{}' is not trusted; review its capabilities and trust it before loading its client UI",
plugin.name()
)));
let metadata = std::fs::symlink_metadata(staged_root).map_err(|_| ui_stale_revision())?;
if !metadata.is_dir() || codewhale_util::path_identity::metadata_is_link_or_reparse(&metadata) {
return Err(ui_stale_revision());
}
if !plugin.enabled {
return Err(ApiError::bad_request(format!(
"plugin '{}' is disabled; enable it after trust before loading its client UI",
plugin.name()
)));
let (root, root_file) = codewhale_util::workspace_dir::open_workspace_directory(staged_root)
.map_err(|_| ui_stale_revision())?;
if root != validated.canonical_root {
return Err(ui_stale_revision());
}
let root = ui_staged_root(&plugin).await?;
Ok((plugin, root))
}
async fn plugin_ui_manifest(
plugin: &LoadedPlugin,
staged_root: &FsPath,
) -> Result<Option<(PathBuf, PathBuf)>, ApiError> {
let mut grant = PluginUiGrant {
selector: selector.to_string(),
authority,
state_file: Arc::new(state_file),
root,
root_file: Arc::new(root_file),
ui_prefix: PathBuf::new(),
file_hashes: Arc::new(validated.file_hashes),
expires: std::time::Instant::now() + std::time::Duration::from_secs(3600),
};
let mut found = None;
for candidate in ["package.json", "source/package.json"] {
let relative = FsPath::new(candidate);
let display = ui_relative_display(relative);
match ui_file_path(staged_root, relative).await {
Ok(path) => {
let root = path.parent().unwrap_or(staged_root).to_path_buf();
return Ok(Some((root, path)));
}
Err(UiFileError::Missing) => {}
Err(error) => return Err(ui_file_refusal(plugin, &display, error)),
if grant.file_hashes.contains_key(relative) {
let bytes = verified_ui_file(&grant, relative)
.map_err(|error| ui_file_refusal(&plugin, candidate, error))?;
found = Some((relative.to_path_buf(), bytes));
break;
}
}
Ok(None)
let (manifest_path, bytes) = found.ok_or_else(|| ApiError::not_found(format!("plugin '{}' declares no dsh.client UI; package.json is absent from its staged bundle and source/ directory", plugin.name())).with_code("plugin_ui_not_declared"))?;
let manifest: Value = serde_json::from_slice(&bytes).map_err(|error| {
ApiError::bad_request(format!(
"plugin '{}' {} is not valid JSON: {error}",
plugin.name(),
ui_relative_display(&manifest_path)
))
})?;
if manifest
.get("dsh")
.and_then(|dsh| dsh.get("client"))
.is_none_or(Value::is_null)
{
return Err(ApiError::not_found(format!("plugin '{}' declares no dsh.client UI in {}; add dsh.client and exports[\"./client\"] to expose client assets", plugin.name(), ui_relative_display(&manifest_path))).with_code("plugin_ui_not_declared"));
}
let conversion_path = FsPath::new("CONVERSION.json");
if grant.file_hashes.contains_key(conversion_path) {
let bytes = verified_ui_file(&grant, conversion_path)
.map_err(|error| ui_file_refusal(&plugin, "CONVERSION.json", error))?;
let conversion: Value = serde_json::from_slice(&bytes).map_err(|_| {
ApiError::bad_request("plugin UI conversion report is invalid")
.with_code("plugin_ui_unsupported")
})?;
if conversion.get("schema").and_then(Value::as_str)
== Some("codewhale.plugin-conversion.v1")
&& conversion
.pointer("/source/dialect")
.and_then(Value::as_str)
== Some("dsh")
{
return Err(ApiError::bad_request(
"this DSH client UI was not converted and is not supported by Codewhale yet",
)
.with_code("plugin_ui_unsupported"));
}
}
grant.ui_prefix = manifest_path
.parent()
.unwrap_or(FsPath::new(""))
.to_path_buf();
let raw = ui_client_entry(&manifest).unwrap_or_else(|| "client.js".into());
let relative = ui_relative_path(&raw).map_err(|reason| {
ApiError::bad_request(format!(
"plugin '{}' dsh.client entry '{raw}' is invalid: {reason}",
plugin.name()
))
})?;
verified_ui_file(&grant, &grant.ui_prefix.join(&relative))
.map_err(|error| ui_file_refusal(&plugin, &ui_relative_display(&relative), error))?;
admit_ui_grant(state, &grant)?;
Ok((ui_relative_display(&relative), grant))
}
fn plugin_ui_base(
@@ -838,7 +940,7 @@ async fn run_plugin_mutation(
})
}
/// Run a registry state mutation (`trust`/`enable`/`disable`/`revoke`)
/// Run a registry state mutation (`trust`/`enable`/`restage`/`disable`/`revoke`)
/// against a fresh registry, then invalidate the MCP pool. Trust is the only
/// one with a precondition beyond the registry's own checks: the request
/// token must match the bundle's review token.
@@ -865,6 +967,7 @@ async fn run_registry_mutation(
let action = match mutation {
RegistryMutation::Trust { .. } => "trusted",
RegistryMutation::Enable => "enabled",
RegistryMutation::Restage => "restaged",
RegistryMutation::Disable => "disabled",
RegistryMutation::Revoke => "trust-revoked",
};
@@ -873,6 +976,7 @@ async fn run_registry_mutation(
let result = match mutation {
RegistryMutation::Trust { .. } => registry.trust(selector),
RegistryMutation::Enable => registry.enable(selector),
RegistryMutation::Restage => registry.restage(selector),
RegistryMutation::Disable => registry.disable(selector),
RegistryMutation::Revoke => registry.revoke_trust(selector),
};
@@ -920,6 +1024,7 @@ async fn run_registry_mutation(
enum RegistryMutation<'a> {
Trust { token: &'a str },
Enable,
Restage,
Disable,
Revoke,
}
@@ -1169,114 +1274,79 @@ pub(super) async fn get_plugin_ui(
"slot '{slot}' is not supported; supported slot: 'customize.plugins.detail'"
)));
}
let (plugin, staged_root) = gated_plugin_ui_root(&state, &selector).await?;
let Some((ui_root, manifest_path)) = plugin_ui_manifest(&plugin, &staged_root).await? else {
return Err(ApiError::not_found(format!(
"plugin '{}' declares no dsh.client UI; package.json is absent from its staged bundle and source/ directory",
plugin.name()
)));
};
let manifest_display = manifest_path
.strip_prefix(&staged_root)
.map(ui_relative_display)
.unwrap_or_else(|_| "package.json".to_string());
let manifest_bytes = ui_file_bytes(&manifest_path)
.await
.map_err(|error| ui_file_refusal(&plugin, &manifest_display, error))?;
let manifest: Value = serde_json::from_slice(&manifest_bytes).map_err(|error| {
ApiError::bad_request(format!(
"plugin '{}' {manifest_display} is not valid JSON: {error}",
plugin.name()
))
})?;
if manifest
.get("dsh")
.and_then(|dsh| dsh.get("client"))
.is_none_or(Value::is_null)
{
return Err(ApiError::not_found(format!(
"plugin '{}' declares no dsh.client UI in {manifest_display}; add dsh.client and exports[\"./client\"] to expose client assets",
plugin.name()
)));
}
let entry = match ui_client_entry(&manifest) {
Some(raw) => {
let relative = ui_relative_path(&raw).map_err(|reason| {
ApiError::bad_request(format!(
"plugin '{}' dsh.client entry '{raw}' is invalid: {reason}",
plugin.name()
))
})?;
match ui_file_path(&ui_root, &relative).await {
Ok(_) => ui_relative_display(&relative),
Err(UiFileError::Missing) => {
return Err(ApiError::not_found(format!(
"plugin '{}' dsh.client entry '{raw}' does not exist in its staged bundle; update the plugin or fix exports[\"./client\"]",
plugin.name()
)));
}
Err(error) => {
return Err(ui_file_refusal(
&plugin,
&ui_relative_display(&relative),
error,
));
}
}
}
None => {
let fallback = PathBuf::from("client.js");
match ui_file_path(&ui_root, &fallback).await {
Ok(_) => ui_relative_display(&fallback),
Err(UiFileError::Missing) => {
return Err(ApiError::not_found(format!(
"plugin '{}' declares dsh.client but has no client entry; set exports[\"./client\"] or add client.js to the staged bundle",
plugin.name()
)));
}
Err(error) => {
return Err(ui_file_refusal(
&plugin,
&ui_relative_display(&fallback),
error,
));
}
}
}
};
let state_for_prepare = state.clone();
let selector_for_prepare = selector.clone();
let (entry, grant) = tokio::task::spawn_blocking(move || {
prepare_plugin_ui(&state_for_prepare, &selector_for_prepare)
})
.await
.map_err(|_| ApiError::internal("plugin UI preparation failed"))??;
let base = plugin_ui_base(&state, &selector, &headers, &uri)?;
Ok(Json(PluginUiSlotResponse { slot, base, entry }))
let revision = state.plugin_ui_grants.insert(grant);
Ok(Json(PluginUiSlotResponse {
slot,
base,
entry,
revision,
}))
}
pub(super) async fn get_plugin_ui_asset(
State(state): State<RuntimeApiState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
Path((selector, path)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Response, ApiError> {
if !peer.ip().is_loopback() {
return Err(ApiError::forbidden(
"plugin UI assets are available only from loopback",
));
}
let (plugin, staged_root) = gated_plugin_ui_root(&state, &selector).await?;
let ui_root = plugin_ui_manifest(&plugin, &staged_root)
.await?
.map(|(root, _)| root)
.unwrap_or(staged_root);
let relative = ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!(
"plugin '{}' UI path '{path}' is invalid: {reason}",
plugin.name()
))
let mut values = headers.get_all(header::IF_MATCH).iter();
let first = values.next().ok_or_else(|| ApiError {
status: StatusCode::PRECONDITION_REQUIRED,
message: "plugin UI revision is required".into(),
code: None,
})?;
let display = ui_relative_display(&relative);
let staged_path = ui_file_path(&ui_root, &relative)
.await
.map_err(|error| ui_file_refusal(&plugin, &display, error))?;
let bytes = ui_file_bytes(&staged_path)
.await
.map_err(|error| ui_file_refusal(&plugin, &display, error))?;
Ok(ui_asset_response(bytes, ui_content_type(&relative)))
if values.next().is_some() {
return Err(ui_stale_revision());
}
let revision = first.to_str().map_err(|_| ui_stale_revision())?.to_string();
let grant = state
.plugin_ui_grants
.get(&revision)
.ok_or_else(ui_stale_revision)?;
if grant.selector != selector || grant.authority.workspace != state.workspace {
return Err(ui_stale_revision());
}
let relative = ui_relative_path(&path).map_err(|reason| {
ApiError::bad_request(format!("plugin UI path '{path}' is invalid: {reason}"))
})?;
let content_type = ui_content_type(&relative);
let state_for_asset = state.clone();
let bytes = tokio::task::spawn_blocking(move || {
codewhale_plugins::registry::verify_plugin_authority(&grant.authority)
.map_err(|_| ui_stale_revision())?;
let bytes = verified_ui_file(&grant, &grant.ui_prefix.join(&relative)).map_err(
|error| match error {
UiFileError::Missing => {
ApiError::not_found("plugin UI file is not in the reviewed bundle")
}
UiFileError::TooLarge => {
ApiError::payload_too_large("plugin UI file exceeds the 8 MiB limit")
}
_ => ui_stale_revision(),
},
)?;
admit_ui_grant(&state_for_asset, &grant)?;
if state_for_asset.plugin_ui_grants.get(&revision).is_none() {
return Err(ui_stale_revision());
}
Ok::<_, ApiError>(bytes)
})
.await
.map_err(|_| ApiError::internal("plugin UI read failed"))??;
Ok(ui_asset_response(bytes, content_type))
}
/// `POST /v1/apps/plugins/install`
@@ -1395,6 +1465,15 @@ pub(super) async fn enable_plugin_api(
))
}
pub(super) async fn restage_plugin_api(
State(state): State<RuntimeApiState>,
Path(selector): Path<String>,
) -> Result<Json<PluginActionResponse>, ApiError> {
Ok(Json(
run_registry_mutation(&state, &selector, RegistryMutation::Restage).await?,
))
}
/// `POST /v1/apps/plugins/{selector}/disable`
pub(super) async fn disable_plugin_api(
State(state): State<RuntimeApiState>,
+12 -8
View File
@@ -83,12 +83,12 @@ fn account_model_access_receipt(config: &codewhale_runtime::config::Config) -> V
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(super) struct SetAccountModelAccessRequest {
api_base: String,
session_id: String,
expected_session_id: Option<String>,
key: String,
expires_at: i64,
pub struct SetAccountModelAccessRequest {
pub api_base: String,
pub session_id: String,
pub expected_session_id: Option<String>,
pub key: String,
pub expires_at: i64,
}
#[derive(Deserialize)]
@@ -124,7 +124,7 @@ pub(super) async fn set_account_model_access(
.map_err(|_| ApiError::internal("account access update failed"))?
}
fn install_account_model_access(
pub fn install_account_model_access(
config: &codewhale_runtime::config::Config,
profile: Option<&str>,
request: SetAccountModelAccessRequest,
@@ -197,7 +197,11 @@ fn install_account_model_access(
.provider_config_for(&identity)
.is_some_and(|entry| entry.auth.is_some() || entry.api_key_env.is_some())
|| !credential_writeability(&existing, &identity).writable
|| codewhale_engine::provider_readiness::has_api_key_for(&existing, &identity)
|| !matches!(
codewhale_runtime::config::resolve_credential_source(&existing, &identity).source,
codewhale_runtime::credentials::CredentialSource::Missing { .. }
| codewhale_runtime::credentials::CredentialSource::AccountSession
)
{
return Err(ApiError::conflict(
"This Codewhale route already has its own credential.",
+19 -1
View File
@@ -1452,6 +1452,7 @@ async fn build_test_server(
plugin_discovery: overrides
.plugin_discovery
.unwrap_or_else(codewhale_plugins::PluginDiscoveryContext::capture_pre_dotenv),
plugin_ui_grants: Arc::new(super::plugins::PluginUiGrants::default()),
task_manager: manager,
runtime_threads: runtime_threads.clone(),
cors_origins: Vec::new(),
@@ -24544,7 +24545,20 @@ async fn thread_notices_serve_list_and_ack() -> Result<()> {
#[tokio::test]
async fn provider_models_refresh_uses_exact_configured_route_without_switching() -> Result<()> {
let _env = codewhale_engine::test_support::lock_test_env();
let _live = codewhale_engine::provider_lake::lock_live_snapshot();
codewhale_engine::provider_lake::clear_live_snapshot();
let root = tempfile::tempdir()?;
let catalog_path = root.path().join("models-dev.json");
fs::write(
&catalog_path,
json!({"models": {}, "providers": {"togetherai": {"id": "togetherai", "models": {
"catalog-refresh-only": {"id": "catalog-refresh-only", "modalities": {"input": ["text"], "output": ["text"]}}
}}}}).to_string(),
)?;
let _catalog = codewhale_engine::test_support::EnvVarGuard::set(
"CODEWHALE_MODELS_DEV_PATH",
&catalog_path,
);
let _home = codewhale_engine::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
let _cli =
codewhale_engine::test_support::EnvVarGuard::remove(codewhale_config::CLI_API_KEY_ENV);
@@ -24604,7 +24618,7 @@ api_key = "refresh-fixture-key"
}
let response = client
.post(format!(
"http://{addr}/v1/providers/custom/models/refresh?model_provider_id=second"
"http://{addr}/v1/providers/custom/models/refresh?model_provider_id=second&force_catalog=true"
))
.send()
.await?;
@@ -24612,6 +24626,9 @@ api_key = "refresh-fixture-key"
let receipt: Value = response.json().await?;
assert_eq!(receipt["outcome"], "updated");
assert_eq!(receipt["model_count"], 1);
assert_eq!(receipt["public_catalog"]["freshness"], "live");
assert_eq!(receipt["public_catalog"]["outcome"], "updated");
assert!(receipt["public_catalog"]["error"].is_null());
assert!(!receipt.to_string().contains("refresh-fixture-key"));
let catalog: Value = client
.get(format!(
@@ -24630,6 +24647,7 @@ api_key = "refresh-fixture-key"
);
assert_eq!(get_config(&client, &addr).await["provider"], "first");
handle.abort();
codewhale_engine::provider_lake::clear_live_snapshot();
Ok(())
}
@@ -12,13 +12,13 @@ fn inputs() -> codewhale_paths::daemon_socket::SocketPathInputs {
explicit: None,
codewhale_home_override: None,
xdg_runtime_dir: None,
user_home: Some(PathBuf::from("/home/whale")),
user_home: Some(PathBuf::from("/cw-test-home/whale")),
macos: false,
}
}
fn default_socket() -> PathBuf {
PathBuf::from("/home/whale/.codewhale/run/daemon.sock")
PathBuf::from("/cw-test-home/whale/.codewhale/run/daemon.sock")
}
fn temp_root(tag: &str) -> PathBuf {
@@ -153,7 +153,7 @@ async fn the_runtime_dir_override_reaches_the_control_endpoint() -> Result<()> {
resolved.display()
);
assert!(
resolved.starts_with(&home),
resolved.starts_with(codewhale_paths::resolve_root_owned_ancestors(&home)),
"the derived endpoint stays beside the well-known one, got {}",
resolved.display()
);
+1 -1
View File
@@ -1064,7 +1064,7 @@ pub(crate) async fn dispatch_stdio_request_with_writer<W: AsyncWrite + Unpin>(
.partition_point(|thread| thread.id.as_str() <= cursor)
});
let remaining = &loaded.threads[start..];
let count = limit.unwrap_or(remaining.len()).min(remaining.len());
let count = limit.unwrap_or(remaining.len()).max(1).min(remaining.len());
let data = remaining[..count]
.iter()
.map(|thread| thread.id.clone())
+58 -18
View File
@@ -233,6 +233,12 @@ enum Commands {
Init(TuiPassthroughArgs),
#[command(about = "Install a plugin bundle without trusting or enabling it")]
Install(TuiPassthroughArgs),
#[command(
about = "Inspect or restage reviewed plugin bundles",
override_usage = "codewhale plugins <COMMAND>",
after_help = "Commands:\n status [NAME] [--json] Inspect plugin status and diagnostics\n restage <NAME> [--json] Restore an eligible reviewed plugin snapshot"
)]
Plugins(TuiPassthroughArgs),
/// Bootstrap MCP config and/or skills directories.
Setup(TuiPassthroughArgs),
/// Generate a remote Codewhale agent deploy bundle (cloud + chat bridge).
@@ -374,17 +380,16 @@ New integrations should prefer `codewhale app-server --http` for the full Engine
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),
/// Sign in to an existing Codewhale account; new accounts are not open.
/// Sign in to your Codewhale account through the browser.
#[command(
after_help = "New Codewhale accounts are not open, and local use does not require one.\nKeep provider keys on this machine with /provider or `codewhale auth set --provider <provider>`.\nSigning in does not upload existing local keys."
after_help = "Local use does not require a Codewhale account.\nConnect a model in the TUI with /connect, or run `codewhale auth set --provider <provider>`.\nSigning in preserves your selected model and does not upload existing local keys."
)]
Login(LoginArgs),
/// Remove saved authentication state (every provider key, OAuth login,
/// the Codewhale account session). Asks before deleting.
/// Sign out of Codewhale, keeping local model connections and work.
Logout(LogoutArgs),
/// Manage authentication credentials and provider mode.
Auth(AuthArgs),
/// Manage an existing Codewhale account; new accounts are not open.
/// Manage your Codewhale account and its available features.
#[command(visible_alias = "cloud")]
Account(codewhale_account_cli::CloudArgs),
/// Run a task on a Codewhale cloud computer using your configured model.
@@ -1768,7 +1773,12 @@ fn remote_setup_tui_args(args: RemoteSetupArgs) -> Vec<String> {
#[derive(Debug, Args)]
struct LogoutArgs {
/// Delete without the confirmation prompt (required when stdin is not a terminal).
#[arg(
long,
help = "Remove every saved provider key, OAuth login and account session; asks before deleting"
)]
all: bool,
/// Skip the confirmation for --all (required with --all when stdin is not a terminal).
#[arg(long, short = 'y', default_value_t = false)]
yes: bool,
}
@@ -2400,7 +2410,12 @@ fn run() -> Result<()> {
if !inherited_listener
&& !matches!(
cli.command.as_ref(),
Some(Commands::Update(_) | Commands::Uninstall { .. } | Commands::Doctor(_))
Some(
Commands::Update(_)
| Commands::Uninstall { .. }
| Commands::Doctor(_)
| Commands::Plugins(_)
)
)
&& !argv_requests_machine_output(&argv)
{
@@ -2589,6 +2604,11 @@ fn run() -> Result<()> {
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("install", args))
}
Some(Commands::Plugins(args)) => {
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
run_tui_in_process(&cli, &resolved_runtime, tui_args("plugins", args))
}
Some(Commands::Setup(args)) => {
let resolved_runtime = if setup_is_status_report(&args) {
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides)
@@ -2713,8 +2733,12 @@ fn run() -> Result<()> {
)
}
Some(Commands::Logout(args)) => {
confirm_logout(args.yes)?;
run_logout_command(&mut store, cli.profile.as_deref())
if args.all {
confirm_logout(args.yes)?;
run_logout_command(&mut store, cli.profile.as_deref())
} else {
codewhale_account_cli::run_account_logout(cli.profile.as_deref(), &mut store)
}
}
Some(Commands::Auth(args)) => match args.command {
AuthCommand::PluginLogin { provider } => {
@@ -3231,8 +3255,8 @@ fn reject_legacy_login_provider_args(args: &LoginArgs) -> Result<()> {
const LOGOUT_CONFIRM_PROMPT: &str = "This deletes every saved provider API key and OAuth login, \
the Codewhale account session. Type 'yes' to log out: ";
/// `codewhale logout` wipes every provider credential at once, so it must not
/// run on a stray keystroke. Non-interactive callers opt in with `--yes`.
/// `codewhale logout --all` wipes every provider credential at once, so it must
/// not run on a stray keystroke. Non-interactive callers opt in with `--all --yes`.
fn confirm_logout(yes: bool) -> Result<()> {
if yes {
return Ok(());
@@ -3240,7 +3264,7 @@ fn confirm_logout(yes: bool) -> Result<()> {
if !io::stdin().is_terminal() {
bail!(
"logout would delete every saved provider key; nothing was deleted. \
Re-run with --yes to confirm non-interactively."
Re-run with --all --yes to confirm non-interactively."
);
}
confirm_logout_answer(&mut io::stdin().lock(), &mut io::stderr().lock())
@@ -10124,12 +10148,14 @@ verbosity = "concise"
"login help must describe account sign-in: {help}"
);
assert!(
help.contains("New Codewhale accounts are not open"),
"login help must not offer accounts that cannot be created: {help}"
help.contains("Local use does not require a Codewhale account"),
"login help must explain that local use does not require an account: {help}"
);
assert!(
help.contains("Signing in does not upload existing local keys"),
"login help must explain the local-key upload boundary: {help}"
help.contains(
"Signing in preserves your selected model and does not upload existing local keys"
),
"login help must explain model preservation and the local-key upload boundary: {help}"
);
}
@@ -12491,12 +12517,26 @@ verbosity = "concise"
let cli = parse_ok(&["codewhale", "logout", "--yes"]);
assert!(matches!(
cli.command,
Some(Commands::Logout(LogoutArgs { yes: true }))
Some(Commands::Logout(LogoutArgs {
yes: true,
all: false
}))
));
let cli = parse_ok(&["codewhale", "logout"]);
assert!(matches!(
cli.command,
Some(Commands::Logout(LogoutArgs { yes: false }))
Some(Commands::Logout(LogoutArgs {
yes: false,
all: false
}))
));
let cli = parse_ok(&["codewhale", "logout", "--all", "--yes"]);
assert!(matches!(
cli.command,
Some(Commands::Logout(LogoutArgs {
yes: true,
all: true
}))
));
}
+23 -1
View File
@@ -687,8 +687,30 @@ pub fn run_rollback() -> Result<()> {
println!("v{} is already the running version.", previous.version);
return Ok(());
}
preflight_downloaded_binary(&autoupdate::binary_name(), &bytes)?;
let current = env!("CARGO_PKG_VERSION");
let current_version = codewhale_release::parse_release_version(current).context(
"Cannot determine the running version; rollback did not replace the executable.",
)?;
let previous_version = codewhale_release::parse_release_version(&previous.version).context(
"Cannot determine the previous version; rollback did not replace the executable.",
)?;
if (
current_version.major,
current_version.minor,
current_version.patch,
) >= (0, 10, 3)
&& (
previous_version.major,
previous_version.minor,
previous_version.patch,
) <= (0, 10, 3)
{
bail!(
"Rollback to v{} is disabled because its compatibility with migrated workspace state cannot be verified. Rollback did not replace the executable or change workspace state. Continue using v{current} and keep your workspace data.",
previous.version
);
}
preflight_downloaded_binary(&autoupdate::binary_name(), &bytes)?;
let plan = update_plan_for_exe(&identity.path);
for target in &plan.target_paths {
validate_update_target(target, &identity)?;
-1
View File
@@ -15,7 +15,6 @@ codewhale-config = { path = "../config", version = "0.10.3" }
codewhale-release = { path = "../release", version = "0.10.3" }
reqwest.workspace = true
libc.workspace = true
semver.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
-17
View File
@@ -414,26 +414,9 @@ pub struct CommandLoginStatus {
pub provider: String,
}
/// `/logout` result; each entry is `Ok(true)` cleared, `Ok(false)` absent,
/// `Err` the host error text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandLogoutOutcome {
pub provider: String,
pub account: Result<bool, String>,
pub legacy_sandbox: Result<bool, String>,
}
/// Failure to clear the active provider key during `/logout`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LogoutFailure {
pub provider: String,
pub error: String,
}
/// Account/provider session state for `/login` and `/logout`.
pub trait CommandLoginContext {
fn login_status(&self) -> CommandLoginStatus;
fn logout(&mut self) -> Result<CommandLogoutOutcome, LogoutFailure>;
/// `/connect [<provider>]` — plugin provider sign-in.
fn connect(&mut self, provider: Option<&str>) -> ConfigReply;
/// `/disconnect <provider>` — plugin provider sign-out.
+5
View File
@@ -994,6 +994,11 @@ pub trait CommandPluginContext {
/// Mutation: enable a bundle. Success means enabled; re-read `detail` for
/// the post-mutation compatibility note.
fn enable(&mut self, selector: &str) -> Result<(), String>;
#[doc = "Restore a missing stage from bytes matching the current trust receipt."]
fn restage(&mut self, selector: &str) -> Result<(), String> {
let _ = selector;
Err("Plugin restaging is not available in this host.".to_string())
}
/// Mutation: disable a bundle.
fn disable(&mut self, selector: &str) -> Result<(), String>;
/// Mutation: revoke trust.
+4
View File
@@ -129,6 +129,10 @@ pub enum ConfigAction {
/// A permission rule was safely removed; the host reloads active rules.
PermissionRulesChanged,
// Auth flows (no host observation needed).
OpenModelConnectionSetup,
StartAccountConnect,
StartAccountLogin,
StartAccountLogout,
StartXaiDeviceLogin,
StartClaudeLogin,
StartClaudeRevoke,
+16 -3
View File
@@ -1567,9 +1567,9 @@
"wire_policy": "chat_completions",
"credential_help": {
"acquisition": "api_key",
"credential_url": "https://console.mistral.ai/api-keys",
"docs_url": "https://docs.mistral.ai/",
"guidance": "Create a Mistral API key in the Mistral Console (la Plateforme)."
"credential_url": "https://chat.mistral.ai/code/extensions",
"docs_url": "https://docs.mistral.ai/vibe/code/cli/api-keys-profiles",
"guidance": "Use a Vibe Code API key or a Mistral Studio API key. Plan allowances and pay-as-you-go depend on your organization."
},
"selectable": true,
"retired": false,
@@ -2111,6 +2111,19 @@
"schema": "codewhale.provider_descriptors.v2",
"note": "One built-in and compatible-host metadata owner. Defaults are bootstrap hints; model offerings and route/auth behavior retain their existing Rust and live-catalog authorities.",
"descriptors": [
{
"id": "tinker",
"label": "Thinking Machines Tinker (beta)",
"wire": "openai-compatible",
"base_url": "https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1",
"api_key_env": "TINKER_API_KEY",
"default_model": "thinkingmachines/Inkling-2-Small:peft:262144:sampling-nvfp4",
"discovery": "none",
"docs_url": "https://tinker-docs.thinkingmachines.ai/tinker/compatible-apis/openai/",
"credential_url": "https://tinker-docs.thinkingmachines.ai/tinker/quickstart/",
"guidance": "Tinker serverless and its OpenAI-compatible API are beta. Model-list and key verification are unavailable through this route; configure a documented serverless model ID. A successful model call is needed to verify access.",
"aliases": ["thinking-machines", "thinkingmachines"]
},
{
"id": "aicraft",
"label": "AICraft",
+4 -2
View File
@@ -234,7 +234,9 @@ mod tests {
assert!(!row.default_model.is_empty(), "{}", row.id);
assert!(matches!(
row.discovery,
DescriptorDiscovery::ModelsEndpoint | DescriptorDiscovery::PublicModelsEndpoint
DescriptorDiscovery::ModelsEndpoint
| DescriptorDiscovery::PublicModelsEndpoint
| DescriptorDiscovery::None
));
assert_eq!(row.wire, DescriptorWire::OpenaiCompatible);
}
@@ -436,6 +438,6 @@ mod tests {
// reusing its name; compatible-host lookup remains separately scoped.
assert!(provider_descriptor("openai").is_none());
assert!(provider_descriptor("deepseek").is_none());
assert_eq!(bundled_provider_descriptors().len(), 14);
assert_eq!(bundled_provider_descriptors().len(), 15);
}
}
+56 -21
View File
@@ -6,6 +6,8 @@
use crate::windows_identity::{CurrentWindowsUser, WindowsLocalAllocation};
use anyhow::{Context, Result, bail};
#[cfg(unix)]
use codewhale_paths::resolve_root_owned_ancestors as resolve_ancestors;
#[cfg(unix)]
use std::ffi::CString;
use std::fs::{self, File};
use std::io::Write as _;
@@ -572,22 +574,6 @@ fn validate_private_basename(name: &str) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn resolve_ancestors(directory: &Path) -> std::path::PathBuf {
use std::os::unix::fs::MetadataExt as _;
let mut resolved = std::path::PathBuf::new();
for component in directory.components() {
resolved.push(component);
let root_owned_link = std::fs::symlink_metadata(&resolved)
.is_ok_and(|meta| meta.file_type().is_symlink() && meta.uid() == 0);
if root_owned_link && let Ok(real) = resolved.canonicalize() {
resolved = real;
}
}
resolved
}
#[cfg(unix)]
fn open_owned_directory(
directory: &Path,
@@ -1714,17 +1700,39 @@ fn secure_windows_owner_only_handle(file: &File, inherit_to_children: bool) -> R
#[cfg(windows)]
pub(crate) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
verify_windows_owner_only_handle_access(file, FILE_ALL_ACCESS, false)
}
#[cfg(windows)]
pub fn verify_windows_owner_only_read_execute_handle(file: &File) -> Result<()> {
use windows_sys::Win32::Storage::FileSystem::{FILE_GENERIC_EXECUTE, FILE_GENERIC_READ};
verify_windows_owner_only_handle_access(file, FILE_GENERIC_READ | FILE_GENERIC_EXECUTE, true)
}
#[cfg(windows)]
fn verify_windows_owner_only_handle_access(
file: &File,
expected_access: u32,
require_protected: bool,
) -> Result<()> {
use std::os::windows::io::AsRawHandle as _;
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
use windows_sys::Win32::Foundation::{
ERROR_SUCCESS, GENERIC_ALL, GENERIC_EXECUTE, GENERIC_READ, GENERIC_WRITE,
};
use windows_sys::Win32::Security::Authorization::{
EXPLICIT_ACCESS_W, GRANT_ACCESS, GetExplicitEntriesFromAclW, GetSecurityInfo,
SE_FILE_OBJECT, SET_ACCESS, TRUSTEE_IS_SID,
};
use windows_sys::Win32::Security::{
ACL, DACL_SECURITY_INFORMATION, EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
PSID,
ACL, DACL_SECURITY_INFORMATION, EqualSid, GetSecurityDescriptorControl,
OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED,
};
use windows_sys::Win32::Storage::FileSystem::{
FILE_ALL_ACCESS, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
};
use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
let user = CurrentWindowsUser::open()?;
let mut owner: PSID = std::ptr::null_mut();
@@ -1748,6 +1756,18 @@ pub(crate) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
.context("reading Codewhale-owned xAI OAuth security descriptor");
}
let _descriptor = WindowsLocalAllocation(descriptor.cast());
if require_protected {
let mut control = 0;
let mut revision = 0;
if unsafe { GetSecurityDescriptorControl(descriptor, &mut control, &mut revision) } == 0 {
return Err(std::io::Error::last_os_error())
.context("reading Codewhale-owned storage DACL protection");
}
anyhow::ensure!(
control & SE_DACL_PROTECTED != 0,
"Codewhale-owned storage DACL is not protected"
);
}
// SAFETY: `owner` is non-null; `user.sid()` is owned by `user`.
anyhow::ensure!(
!owner.is_null() && unsafe { EqualSid(owner, user.sid()) } != 0,
@@ -1774,13 +1794,28 @@ pub(crate) fn verify_windows_owner_only_handle(file: &File) -> Result<()> {
// SAFETY: `count == 1` proves the first returned entry is initialized.
let entry = unsafe { &*entries };
let trustee_sid: PSID = entry.Trustee.ptstrName.cast();
let normalized_access = |access: u32| {
let mut normalized =
access & !(GENERIC_ALL | GENERIC_EXECUTE | GENERIC_READ | GENERIC_WRITE);
for (generic, specific) in [
(GENERIC_ALL, FILE_ALL_ACCESS),
(GENERIC_EXECUTE, FILE_GENERIC_EXECUTE),
(GENERIC_READ, FILE_GENERIC_READ),
(GENERIC_WRITE, FILE_GENERIC_WRITE),
] {
if access & generic != 0 {
normalized |= specific;
}
}
normalized
};
// SAFETY: form and null checked in this expression; sid owned by `user`.
anyhow::ensure!(
entry.Trustee.TrusteeForm == TRUSTEE_IS_SID
&& !trustee_sid.is_null()
&& unsafe { EqualSid(trustee_sid, user.sid()) } != 0
&& matches!(entry.grfAccessMode, SET_ACCESS | GRANT_ACCESS)
&& entry.grfAccessPermissions == FILE_ALL_ACCESS,
&& normalized_access(entry.grfAccessPermissions) == normalized_access(expected_access),
"Codewhale-owned xAI OAuth DACL is not current-user-only"
);
Ok(())
+1 -1
View File
@@ -5144,7 +5144,7 @@ fn mistral_provider_resolves_defaults_and_metadata() {
);
assert_eq!(
help.credential_url,
Some("https://console.mistral.ai/api-keys")
Some("https://chat.mistral.ai/code/extensions")
);
let config: ConfigToml = toml::from_str(
+37 -5
View File
@@ -747,6 +747,32 @@ fn sha256_hex(bytes: &[u8]) -> String {
pub const CONTEXT_UPDATE_MAX_LINES: usize = 80;
pub const CONTEXT_UPDATE_MAX_BYTES: usize = 6_000;
/// The fixed opening every [`context_update_message`] body starts with.
///
/// Readers that must tell the engine's drift notices from a person's own
/// words recognize the envelope by it — see `is_context_update_envelope`.
pub const CONTEXT_UPDATE_HEADER: &str = concat!(
"<context_update>\n",
"Session context changed since it was pinned; the pinned system ",
"prompt is unchanged. Delta (+ added, - removed):\n",
);
/// The fixed closing every [`context_update_message`] body ends with.
pub const CONTEXT_UPDATE_CLOSE: &str = "</context_update>";
/// True when `text` is one whole `<context_update>` envelope exactly as
/// [`context_update_message`] renders it: the engine's fixed header, then the
/// delta, then the fixed close, and nothing of a person's own around it.
///
/// The drift notice is appended to the model-visible history by the engine,
/// between turn records, so no reconstruction from records can reproduce it —
/// history alignments must skip it rather than read it as a user prompt. A
/// person quoting or asking about a notice carries their own words outside the
/// envelope, which fails either end of this check and keeps their turn whole.
pub fn is_context_update_envelope(text: &str) -> bool {
text.starts_with(CONTEXT_UPDATE_HEADER) && text.ends_with(CONTEXT_UPDATE_CLOSE)
}
pub fn context_update_message(known: &str, current: &str) -> Option<String> {
use std::collections::HashMap;
let mut counts: HashMap<&str, i64> = HashMap::new();
@@ -782,10 +808,7 @@ pub fn context_update_message(known: &str, current: &str) -> Option<String> {
return None;
}
let mut out = String::from(
"<context_update>\nSession context changed since it was pinned; the pinned system \
prompt is unchanged. Delta (+ added, - removed):\n",
);
let mut out = String::from(CONTEXT_UPDATE_HEADER);
let mut lines_written = 0usize;
let mut truncated = 0usize;
for (sign, group) in [("+ ", &added), ("- ", &removed)] {
@@ -807,7 +830,7 @@ pub fn context_update_message(known: &str, current: &str) -> Option<String> {
"(+{truncated} more changed lines; re-read the files you need)\n"
));
}
out.push_str("</context_update>");
out.push_str(CONTEXT_UPDATE_CLOSE);
Some(out)
}
@@ -974,6 +997,15 @@ mod tests {
assert!(!update.contains("- src/a.rs"));
assert!(context_update_message(known, known).is_none());
// The recognizer answers for every body the builder can render, so a
// history alignment can skip the engine's notices without guessing at
// their shape, while a person's words around one stay their own.
assert!(is_context_update_envelope(&update));
let quoted = format!("what does this mean?\n{update}");
assert!(!is_context_update_envelope(&quoted));
let truncated = &update[..update.len() - CONTEXT_UPDATE_CLOSE.len()];
assert!(!is_context_update_envelope(truncated));
let mut big = String::new();
for i in 0..500 {
big.push_str(&format!("line {i}\n"));
-9
View File
@@ -114,20 +114,13 @@ async-trait.workspace = true
base64 = "0.23.1"
axum.workspace = true
clap.workspace = true
crossterm = "0.29"
encoding_rs.workspace = true
fd-lock = "4.0.4"
dirs.workspace = true
futures-util = "0.3.31"
oauth2 = { version = "5", default-features = false }
# No ratatui-core pin: ColorCompatBackend answers `get_cursor_position()`
# from tracked state (ratatui#2640's recommended workaround), so the CPR query
# inside `Terminal::clear()` no longer races the input loop. See
# `tui/color_compat.rs`.
ratatui = { version = "=0.30.2", features = ["unstable-rendered-line-info"] }
regex = "1.11"
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2"] }
rquickjs.workspace = true
rmcp = { version = "3.7.0", default-features = false, features = ["auth", "client", "server", "transport-io"] }
rustls.workspace = true
similar = { version = "3", features = ["unicode"] }
@@ -156,7 +149,6 @@ tempfile.workspace = true
thiserror.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
tower-http.workspace = true
wait-timeout = "0.2"
webbrowser = "1.0"
shlex = "2.0.1"
@@ -179,7 +171,6 @@ liblzma = { version = "0.4", features = ["static"] }
sha2.workspace = true
# Reuse the locked cryptographic implementation for exact-call user decisions.
ring = "0.17"
semver.workspace = true
codewhale-localization = { path = "../localization", version = "0.10.3" }
shell-words = "1.1.1"
+21
View File
@@ -30,6 +30,7 @@ Packages:
[email protected] (MIT)
[email protected] (MIT)
[email protected] (MIT)
[email protected] (ISC)
[email protected] (MIT)
========================================================================
@@ -1183,6 +1184,26 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
========================================================================
[email protected] (ISC)
========================================================================
The ISC License
Copyright (c) Isaac Z. Schlueter and Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
========================================================================
[email protected] (MIT)
========================================================================
File diff suppressed because it is too large. Load diff
+22
View File
@@ -16,9 +16,11 @@
"@modelcontextprotocol/client": "2.4.0",
"@types/js-yaml": "4.0.9",
"@types/node": "22.20.4",
"@types/semver": "7.8.0",
"acorn": "8.18.0",
"esbuild": "0.28.1",
"js-yaml": "4.3.2",
"semver": "7.8.5",
"typescript": "5.9.3"
},
"engines": {
@@ -578,6 +580,13 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/acorn": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
@@ -738,6 +747,19 @@
"node": ">=16.20.0"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -24,9 +24,11 @@
"@modelcontextprotocol/client": "2.4.0",
"@types/js-yaml": "4.0.9",
"@types/node": "22.20.4",
"@types/semver": "7.8.0",
"acorn": "8.18.0",
"esbuild": "0.28.1",
"js-yaml": "4.3.2",
"semver": "7.8.5",
"typescript": "5.9.3"
}
}
@@ -1,6 +1,8 @@
/** Detached trusted offline reviewer. No HostRoot, plugin import or expression evaluation. */
import { reviewComposition, type ReviewedComposition } from './composition.ts'
import {reviewAgentPresets, type PresetReviewInput} from './agent-presets.ts'
import satisfies from 'semver/functions/satisfies.js'
import validRange from 'semver/ranges/valid.js'
try {
let bytes = 0
@@ -11,8 +13,16 @@ try {
if (bytes > 8 * 1024 * 1024) throw new Error('review request exceeds 8 MiB')
chunks.push(part)
}
const input = JSON.parse(Buffer.concat(chunks).toString('utf8')) as ReviewedComposition
const output = JSON.stringify((input as any).kind === 'agent-presets' ? await reviewAgentPresets(input as unknown as PresetReviewInput) : reviewComposition(input))
const input = JSON.parse(Buffer.concat(chunks).toString('utf8')) as ReviewedComposition & { compatibility?: { range: string; version: string } }
const result = (input as any).kind === 'agent-presets' ? await reviewAgentPresets(input as unknown as PresetReviewInput) : reviewComposition(input)
let compatibility: string | undefined
if (input.compatibility !== undefined) {
const { range, version } = input.compatibility
if (typeof range !== 'string' || typeof version !== 'string') throw new Error('invalid compatibility request')
const options = { includePrerelease: true }
compatibility = range.trim() === '' || validRange(range, options) === null ? 'invalid' : satisfies(version, range, options) ? 'match' : 'mismatch'
}
const output = JSON.stringify(compatibility === undefined ? result : { ...result, compatibility })
if (Buffer.byteLength(output) > 8 * 1024 * 1024) throw new Error('review response exceeds 8 MiB')
process.stdout.write(output)
} catch {
+42 -3
View File
@@ -2585,7 +2585,7 @@ impl CodewhaleClient {
auth_disabled,
surface,
)?;
let mut builder = codewhale_release::tls::reqwest_client_builder()
let mut builder = crate::platform_client_builder()
.default_headers(headers)
.user_agent(client_user_agent(api_provider))
.connect_timeout(config.connect_timeout())
@@ -2923,6 +2923,32 @@ fn model_listing_is_public(base_url: &str) -> bool {
})
}
pub(crate) fn model_listing_unavailable(api_provider: ProviderKind, base_url: &str) -> bool {
if api_provider != ProviderKind::Custom {
return false;
}
let Ok(url) = reqwest::Url::parse(base_url) else {
return false;
};
if !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
{
return false;
}
codewhale_config::descriptors::bundled_provider_descriptors()
.iter()
.filter(|row| row.discovery == codewhale_config::descriptors::DescriptorDiscovery::None)
.filter_map(|row| reqwest::Url::parse(&row.base_url).ok())
.any(|known| {
known.scheme() == url.scheme()
&& known.host_str() == url.host_str()
&& known.port_or_known_default() == url.port_or_known_default()
&& known.path().trim_end_matches('/') == url.path().trim_end_matches('/')
})
}
fn api_provider_skips_models_probe(api_provider: ProviderKind) -> bool {
// Concentrate's `GET /v1/models` is explicitly unauthenticated
// (docs/PROVIDERS.md): a 2xx proves nothing about the key, so guided
@@ -2944,7 +2970,9 @@ pub fn provider_api_key_verification_is_observed_for_route(
api_provider: ProviderKind,
base_url: &str,
) -> bool {
provider_api_key_verification_is_observed(api_provider) && !model_listing_is_public(base_url)
provider_api_key_verification_is_observed(api_provider)
&& !model_listing_is_public(base_url)
&& !model_listing_unavailable(api_provider, base_url)
}
/// Verify a provider API key by hitting the `/models` endpoint
@@ -2982,7 +3010,7 @@ pub async fn verify_provider_api_key(
AuthHeaderSurface::Listing,
)
.map_err(|err| format!("failed to build auth headers: {err:#}"))?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.default_headers(headers)
.redirect(reqwest::redirect::Policy::none())
.user_agent(concat!(
@@ -4093,6 +4121,11 @@ impl CodewhaleClient {
&self,
mode: ModelsRequestMode,
) -> Result<(String, tokio::time::Instant), ModelsFetchError> {
if model_listing_unavailable(self.api_provider, &self.base_url) {
return Err(ModelsFetchError::Interactive(anyhow::anyhow!(
"This provider has no documented model-list endpoint; select a configured model instead"
)));
}
let mut endpoint = reqwest::Url::parse(&api_url(&self.base_url, "models"))
.map_err(|_| CatalogRefreshError::InvalidResponse)?;
// OrcaRouter scopes `GET /v1/models` to one capability. Ask for the chat
@@ -4629,6 +4662,9 @@ impl CodewhaleClient {
}
async fn maybe_probe_recovery(&self) {
if model_listing_unavailable(self.api_provider, &self.base_url) {
return;
}
let should_probe = {
let mut health = self.connection_health.lock().await;
mark_recovery_probe_if_due(&mut health, Instant::now())
@@ -5412,6 +5448,9 @@ impl LlmClient for CodewhaleClient {
}
async fn health_check(&self) -> Result<bool> {
if model_listing_unavailable(self.api_provider, &self.base_url) {
anyhow::bail!("Provider health is unobserved: no documented model-list endpoint");
}
if !provider_api_key_verification_is_observed_for_route(self.api_provider, &self.base_url) {
self.mark_request_success().await;
return Ok(true);
+3 -5
View File
@@ -161,7 +161,7 @@ async fn collect_fixture(
server: &MockServer,
limits: ModelsFetchLimits,
) -> Result<String, ModelsFetchError> {
let http = codewhale_release::tls::reqwest_client_builder()
let http = crate::platform_client_builder()
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
@@ -648,9 +648,7 @@ async fn chunked_catalog_body_is_bounded_without_content_length() {
read_head(&mut stream).await;
stream.write_all(response.as_bytes()).await.unwrap();
});
let http = codewhale_release::tls::reqwest_client_builder()
.build()
.unwrap();
let http = crate::platform_client_builder().build().unwrap();
let result = collect_models_document(
reqwest::Url::parse(&endpoint).unwrap(),
None,
@@ -697,7 +695,7 @@ async fn traversal_deadline_bounds_pending_and_completed_later_pages() {
// Fetch the first fixture response before starting the short clock so host
// scheduling/connection setup cannot make this accidentally a page-one test.
let mut first = Some(
codewhale_release::tls::reqwest_client_builder()
crate::platform_client_builder()
.build()
.unwrap()
.get(format!("{}/v1/models", server.uri()))
+2 -2
View File
@@ -211,7 +211,7 @@ pub fn probe_client() -> anyhow::Result<reqwest::Client> {
// The first-run probe can run before any provider client has installed
// the rustls crypto provider; the shared builder installs it (the bare
// `reqwest::Client::builder()` panics under `rustls-no-provider`).
Ok(codewhale_release::tls::reqwest_client_builder()
Ok(crate::platform_client_builder()
.timeout(TAGS_PROBE_TIMEOUT)
.build()?)
}
@@ -221,7 +221,7 @@ pub const MODEL_LOAD_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(test, all(feature = "test-thread-scoped-state", debug_assertions))))]
pub async fn load_model(origin: &str, tag: &str) -> bool {
let Ok(client) = codewhale_release::tls::reqwest_client_builder()
let Ok(client) = crate::platform_client_builder()
.timeout(MODEL_LOAD_TIMEOUT)
.build()
else {
+1 -1
View File
@@ -2056,7 +2056,7 @@ impl Engine {
let concrete_client = match route.take_preflighted_client() {
Some(client) => Ok(client),
None => {
let config = std::mem::take(&mut route.config);
let config = route.config.clone();
match CodewhaleClient::capture_candidate_preflight(config, &route.candidate).await {
Ok((config, captured)) => {
route.config = config;
@@ -1,6 +1,6 @@
#[test]
fn default_active_contract_keeps_discovery_and_core_tools_eager() {
const EXPECTED_NATIVE: [&str; 15] = [
const EXPECTED_NATIVE: [&str; 16] = [
"read",
"write",
"edit",
@@ -13,6 +13,7 @@ fn default_active_contract_keeps_discovery_and_core_tools_eager() {
"get_goal",
"update_goal",
"task_shell_wait",
"present",
"load_skill",
"context_archive_get",
"context_archive_search",
@@ -78,7 +78,7 @@ impl StreamOpenFailure {
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = listener.local_addr().expect("local addr");
drop(listener);
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.no_proxy()
.build()
.expect("client");
@@ -113,6 +113,7 @@ fn first_turn_surface_is_stable_across_plan_work_and_operate() {
"get_goal",
"update_goal",
"task_shell_wait",
"present",
"load_skill",
"context_archive_get",
"context_archive_search"
@@ -127,6 +128,7 @@ fn first_turn_surface_is_stable_across_plan_work_and_operate() {
"edit",
"file_search",
"load_skill",
"present",
"read",
"task_shell_wait",
"todo_write",
@@ -152,6 +154,7 @@ fn first_turn_surface_is_stable_across_plan_work_and_operate() {
"get_goal",
"update_goal",
"task_shell_wait",
"present",
"Git",
"Run",
"tasks",
+9 -2
View File
@@ -695,7 +695,9 @@ mod tests {
#[test]
fn reviewed_skill_root_parser_bounds_accumulation_and_parent_claims() {
let (_many, authority) = parser_bundle(129, 1);
let (many, authority) = parser_bundle(129, 1);
let _many_stage =
codewhale_plugins::test_fixture::finalize_staged_bundle(many.path()).unwrap();
assert!(
codewhale_plugins::discovery::load_staged_skill_root_snapshots(
&authority,
@@ -704,7 +706,9 @@ mod tests {
.unwrap_err()
.contains("candidate count")
);
let (_large, authority) = parser_bundle(8, 600 * 1024);
let (large, authority) = parser_bundle(8, 600 * 1024);
let _large_stage =
codewhale_plugins::test_fixture::finalize_staged_bundle(large.path()).unwrap();
assert!(
codewhale_plugins::discovery::load_staged_skill_root_snapshots(
&authority,
@@ -727,6 +731,8 @@ mod tests {
.unwrap();
authority.content_hash = validated.content_hash;
authority.capability_hash = validated.capability_hash;
let _one_stage =
codewhale_plugins::test_fixture::finalize_staged_bundle(one.path()).unwrap();
let snapshots = codewhale_plugins::discovery::load_staged_skill_root_snapshots(
&authority,
Path::new("skills"),
@@ -765,6 +771,7 @@ mod tests {
std::fs::write(&manifest, "schema_version = 1\n[plugin]\nname = \"bounded-skills\"\nversion = \"0.1.0\"\n[skills]\npath = \"skills\"\n[native]\npath = \"index.mjs\"\n").unwrap();
let validated =
codewhale_plugins::manifest::PluginManifest::validate_from_path(&manifest).unwrap();
let _stage = codewhale_plugins::test_fixture::finalize_staged_bundle(temp.path()).unwrap();
let declared = codewhale_plugins::discovery::load_staged_skill_snapshots(
&validated.canonical_root,
&validated.content_hash,
+1 -1
View File
@@ -1956,7 +1956,7 @@ fn native_entry_rule_fails_validation_when_the_host_is_enabled() {
let errors = native_errors(&registry, name);
assert_eq!(errors.len(), 1, "{name}: {errors:?}");
assert!(
errors[0].contains(".mjs, .js or .mts"),
errors[0].contains(".mjs, .js, .cjs, .mts or .cts"),
"{name}: {errors:?}"
);
}
+1
View File
@@ -6806,6 +6806,7 @@ command = "echo project"
("get_goal", "other"),
("update_goal", "other"),
("task_shell_wait", "other"),
("present", "other"),
// Reads the skill registry, not caller-named paths, so it keeps
// the classification it already had as a deferred tool. Making it
// eager must not silently re-gate it.
+14
View File
@@ -79,3 +79,17 @@ pub mod vision;
pub mod voice;
pub mod whale_soul;
pub mod work_graph;
/// The platform HTTP client builder the engine's model, search, vision,
/// finance and catalog clients start from.
///
/// A session builds these clients once and reqwest snapshots the ambient proxy
/// environment at build time, so one builder decides the route for the whole
/// process lifetime. Installing the loopback fallback here, rather than at
/// selected call sites, keeps those clients on the same proxy rules. Reviewed
/// plugin remotes and guarded fetches do not come through here: they use
/// `codewhale_net::guard::guarded_reqwest_client_builder`, which disables
/// ambient proxies on purpose.
pub(crate) fn platform_client_builder() -> reqwest::ClientBuilder {
codewhale_net::proxy::install_loopback_proxy(codewhale_release::tls::reqwest_client_builder())
}
+31 -9
View File
@@ -640,11 +640,10 @@ impl ReviewedPluginMcpSource {
cwd: Option<&Path>,
) -> Result<ReviewedStdioLaunch> {
self.validate_before_stdio_spawn(server_name)?;
let staged_root = self
.authority
.staged_manifest
.parent()
.context("reviewed plugin stage manifest has no parent")?;
let staged_root = codewhale_plugins::agent_plugin::plugin_root_for_manifest(
&self.authority.staged_manifest,
)
.context("reviewed plugin stage manifest has no bundle root")?;
let validated = codewhale_plugins::manifest::PluginManifest::validate_from_path(
&self.authority.staged_manifest,
)
@@ -4525,6 +4524,29 @@ impl McpPool {
self.server_allowed(name) && self.needs_auth_servers.contains(name)
}
pub fn has_connection_backoff(
&self,
name: &str,
timeouts: McpTimeouts,
server: &McpServerConfig,
) -> Option<bool> {
let current = self.config.servers.get(name)?;
if !self.server_allowed(name)
|| !server.is_enabled()
|| !current.is_enabled()
|| self.connecting.contains(name)
|| self
.connections
.get(name)
.is_some_and(McpConnection::is_transport_ready)
|| mcp_configured_server_identity(self.config.timeouts, name, current)
!= mcp_configured_server_identity(timeouts, name, server)
{
return None;
}
Some(self.connect_backoff.contains_key(name))
}
/// The needs-auth server that owns a model tool name (`mcp_<server>_…`),
/// if any: the server the model is trying to reach with a real tool name
/// from a catalog built before its login lapsed. Longest configured name
@@ -7715,10 +7737,10 @@ fn merge_plugin_mcp_servers(
"Native MCP namespace collides with an existing reviewed or explicit server"
);
if server.command.is_some() {
let root = authority
.staged_manifest
.parent()
.context("Native stage has no root")?;
let root = codewhale_plugins::agent_plugin::plugin_root_for_manifest(
&authority.staged_manifest,
)
.context("Native stage has no root")?;
server.cwd = Some(resolve_plugin_mcp_cwd(root, server.cwd.as_deref())?);
freeze_plugin_stdio_paths(&mut server, root)?;
}
+2 -1
View File
@@ -3951,7 +3951,8 @@ mod decision_router_tests {
.expect(1)
.mount(&server)
.await;
let config = decision_config(&format!("{}/api/v1", server.uri()), false, 2);
let mut config = decision_config(&format!("{}/api/v1", server.uri()), false, 2);
config.retry = Some(toml::from_str("enabled = false").unwrap());
let selection = route(&config, "Explain the diff", "").await;
assert_eq!(selection.model, "deepseek-v4-pro", "status {status}");
+61 -17
View File
@@ -20,7 +20,7 @@ use codewhale_runtime::catalog_cache::CACHE_FILE;
use codewhale_runtime::catalog_cache::cache_path;
use std::path::Path;
use std::sync::RwLock;
use std::time::Duration;
use std::time::{Duration, Instant};
use codewhale_config::catalog::{
CatalogSnapshot, base_url_fingerprint, live_offerings_from_models_dev, now_unix,
@@ -45,6 +45,15 @@ pub const ENV_MODELS_DEV_PATH: &str = "CODEWHALE_MODELS_DEV_PATH";
/// Env: disable network fetch entirely (`1`/`true`/`yes`/`on`).
pub const ENV_DISABLE_FETCH: &str = "CODEWHALE_DISABLE_MODELS_DEV_FETCH";
struct RefreshAttempt {
source: String,
completed_at: Instant,
result: Result<usize, ModelsDevRefreshError>,
}
static REFRESH_ATTEMPT: tokio::sync::Mutex<Option<RefreshAttempt>> =
tokio::sync::Mutex::const_new(None);
const CACHE_SCHEMA_VERSION: u32 = 1;
/// Largest catalog body accepted from the network, an override file, or the
/// disk cache. The public catalog is a few MiB; anything past this is refused
@@ -172,7 +181,14 @@ impl std::fmt::Display for ModelsDevRefreshError {
/// Current quiet status (for UI / slash-command feedback).
#[must_use]
pub fn status() -> ModelsDevStatus {
let current = STATUS.read().map(|guard| guard.clone()).unwrap_or_default();
let mut current = STATUS.read().map(|guard| guard.clone()).unwrap_or_default();
if current.freshness == ModelsDevFreshness::Live
&& current
.fetched_at
.is_none_or(|timestamp| !within_ttl(timestamp, now_unix()))
{
current.freshness = ModelsDevFreshness::Stale;
}
honor_bundled_staleness(
current,
codewhale_config::catalog::reviewed::bundled_source_fetched_at()
@@ -271,6 +287,30 @@ pub fn maybe_load_persisted_cache() {
/// status. A cache write that fails after a successful publish keeps the fresh
/// rows and reports only the persistence error.
pub async fn refresh(force_network: bool) -> Result<usize, ModelsDevRefreshError> {
let mut previous = REFRESH_ATTEMPT.lock().await;
let source = current_source_fingerprint();
if !force_network
&& std::env::var(ENV_MODELS_DEV_PATH)
.ok()
.is_none_or(|path| path.trim().is_empty())
&& let Some(attempt) = previous.as_ref()
&& attempt.source == source
&& attempt.completed_at.elapsed() < Duration::from_secs(60)
&& attempt.result.is_err()
&& !matches!(attempt.result, Err(ModelsDevRefreshError::Disabled))
{
return attempt.result.clone();
}
let result = refresh_inner(force_network).await;
*previous = Some(RefreshAttempt {
source,
completed_at: Instant::now(),
result: result.clone(),
});
result
}
async fn refresh_inner(force_network: bool) -> Result<usize, ModelsDevRefreshError> {
if let Ok(path) = std::env::var(ENV_MODELS_DEV_PATH) {
let trimmed = path.trim();
if !trimmed.is_empty() {
@@ -286,6 +326,7 @@ pub async fn refresh(force_network: bool) -> Result<usize, ModelsDevRefreshError
if !force_network {
let current = status();
if current.freshness == ModelsDevFreshness::Live
&& current.source_label == resolve_catalog_url()
&& current
.fetched_at
.is_some_and(|ts| within_ttl(ts, now_unix()))
@@ -359,21 +400,24 @@ pub fn spawn_background_refresh() {
return;
};
handle.spawn(async {
match refresh(false).await {
Ok(count) => {
tracing::debug!(
target: "models_dev_live",
offering_count = count,
"Models.dev live catalog refreshed"
);
}
Err(err) => {
tracing::debug!(
target: "models_dev_live",
error = %err,
"Models.dev live catalog refresh skipped"
);
loop {
match refresh(false).await {
Ok(count) => {
tracing::debug!(
target: "models_dev_live",
offering_count = count,
"Models.dev live catalog refreshed"
);
}
Err(err) => {
tracing::debug!(
target: "models_dev_live",
error = %err,
"Models.dev live catalog refresh skipped"
);
}
}
tokio::time::sleep(Duration::from_secs(15 * 60)).await;
}
});
}
@@ -413,7 +457,7 @@ async fn refresh_from_path(path: &Path) -> Result<usize, ModelsDevRefreshError>
}
async fn fetch_catalog_body(url: &str) -> Result<String, ModelsDevRefreshError> {
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(FETCH_TIMEOUT)
.connect_timeout(Duration::from_secs(10))
.user_agent(USER_AGENT)
@@ -153,7 +153,7 @@ fn shipped_default_routes_have_reviewed_pricing_coverage() {
assert_eq!(built_in_count, 51, "review changed shipped-default breadth");
assert_eq!(
defaults.len() - built_in_count,
14,
15,
"review compatible-default breadth"
);
let recorded_at = Utc.with_ymd_and_hms(2026, 9, 8, 12, 0, 0).unwrap();
@@ -37,7 +37,7 @@
},
{
"reason": "unknown_billing_basis",
"review": "No mode/credential product is supplied. Both MiniMax dialects share a PAYG/plan endpoint that cannot prove product (route_billing.rs:253,402; pricing.rs:534); Ollama Cloud is unclassified possible spend, not local/free (pricing.rs:411). Generic Custom and twelve non-Baseten compatible defaults are transport descriptors without a reviewed product; production envelope refuses Unknown at cost_status.rs:239. AICraft is the sixth compatible default: dc170add3 restored its row from the closed PR #6171 with endpoint, credential env and bootstrap model only, and no billing product or pricing row was reviewed for it. Its default is claude-4.6-sonnet, which has no bundled/hand row either; that omission is recorded here on the shape of the descriptor, and a real source review of AICraft's billing basis is still owed. Tsubasa (#6695) is the seventh: endpoint, credential env and bootstrap model tsubasa-pro only; its public site lists prepaid-credit per-token rates, but no pricing row was reviewed or frozen for it, so it stays Unknown here too. Yolo-Auto is the eighth: its row carries endpoint, credential env and bootstrap model (qwen3.8-flash, the vendor's recommended start model on free and paid plans) only, the gateway sells flat-rate subscription tiers with no published per-token catalog to review, and the URL proves no plan product, so the route stays Unknown rather than being guessed into subscription or local-no-bill; a source review of Yolo-Auto's billing basis is owed. Cheaper Inference is the ninth non-Baseten compatible default: its gpt-5.4-mini bootstrap model and API endpoint do not establish a billing product or reviewed price, so it also remains Unknown. Standard Compute is the tenth: its row carries endpoint, credential env and the standardcompute router id only; the gateway sells flat monthly plans with a per-plan compute budget, and neither the URL nor the router id proves a plan product or per-request price, so the route stays Unknown. Runcrate is the eleventh: its row carries endpoint, credential env and the deepseek-ai/DeepSeek-V4.1-Flash bootstrap model only; its public /v1/models listing shows per-token rates, but no billing product or reviewed price was frozen for it and the URL proves no plan product, so the route stays Unknown. Opper is the twelfth: its row carries endpoint, credential env and the claude-sonnet-4-6 pool name only; token rates are the model providers' rates with no markup, but no pricing row was reviewed for it, so it stays Unknown too. ChatGPT source review: the official API endpoint alone does not prove a ChatGPT plan product. route_billing.rs capture_product requires the Config's owned official registration for Subscription; the offline default inventory has none and remains Unknown. The retired backend endpoint supplies no transport or billing authority.",
"review": "No mode/credential product is supplied. Both MiniMax dialects share a PAYG/plan endpoint that cannot prove product (route_billing.rs:253,402; pricing.rs:534); Ollama Cloud is unclassified possible spend, not local/free (pricing.rs:411). Generic Custom and thirteen non-Baseten compatible defaults are transport descriptors without a reviewed product; production envelope refuses Unknown at cost_status.rs:239. AICraft is the sixth compatible default: dc170add3 restored its row from the closed PR #6171 with endpoint, credential env and bootstrap model only, and no billing product or pricing row was reviewed for it. Its default is claude-4.6-sonnet, which has no bundled/hand row either; that omission is recorded here on the shape of the descriptor, and a real source review of AICraft's billing basis is still owed. Tsubasa (#6695) is the seventh: endpoint, credential env and bootstrap model tsubasa-pro only; its public site lists prepaid-credit per-token rates, but no pricing row was reviewed or frozen for it, so it stays Unknown here too. Yolo-Auto is the eighth: its row carries endpoint, credential env and bootstrap model (qwen3.8-flash, the vendor's recommended start model on free and paid plans) only, the gateway sells flat-rate subscription tiers with no published per-token catalog to review, and the URL proves no plan product, so the route stays Unknown rather than being guessed into subscription or local-no-bill; a source review of Yolo-Auto's billing basis is owed. Cheaper Inference is the ninth non-Baseten compatible default: its gpt-5.4-mini bootstrap model and API endpoint do not establish a billing product or reviewed price, so it also remains Unknown. Standard Compute is the tenth: its row carries endpoint, credential env and the standardcompute router id only; the gateway sells flat monthly plans with a per-plan compute budget, and neither the URL nor the router id proves a plan product or per-request price, so the route stays Unknown. Runcrate is the eleventh: its row carries endpoint, credential env and the deepseek-ai/DeepSeek-V4.1-Flash bootstrap model only; its public /v1/models listing shows per-token rates, but no billing product or reviewed price was frozen for it and the URL proves no plan product, so the route stays Unknown. Opper is the twelfth: its row carries endpoint, credential env and the claude-sonnet-4-6 pool name only; token rates are the model providers' rates with no markup, but no pricing row was reviewed for it, so it stays Unknown too. Tinker publishes serverless rates but the generic custom route does not establish the account product or a reviewed provider-owned price row, so its cost stays Unknown. ChatGPT source review: the official API endpoint alone does not prove a ChatGPT plan product. route_billing.rs capture_product requires the Config's owned official registration for Subscription; the offline default inventory has none and remains Unknown. The retired backend endpoint supplies no transport or billing authority.",
"routes": [
["aicraft", "claude-4.6-sonnet", "https://aicraftapi.com/v1", "chat_completions", "unknown"],
["cerebras", "llama-3.3-70b", "https://api.cerebras.ai/v1", "chat_completions", "unknown"],
@@ -54,6 +54,7 @@
["runcrate", "deepseek-ai/DeepSeek-V4.1-Flash", "https://api.runcrate.ai/v1", "chat_completions", "unknown"],
["sensenova", "deepseek-v4-flash", "https://token.sensenova.cn/v1", "chat_completions", "unknown"],
["standardcompute", "standardcompute", "https://api.stdcmpt.com/v1", "chat_completions", "unknown"],
["tinker", "thinkingmachines/Inkling-2-Small:peft:262144:sampling-nvfp4", "https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1", "chat_completions", "unknown"],
["tsubasa", "tsubasa-pro", "https://api.tsubasa.sh/v1", "chat_completions", "unknown"],
["yolo-auto", "qwen3.8-flash", "https://yolo-auto.com/v1", "chat_completions", "unknown"]
]
+1 -1
View File
@@ -78,7 +78,7 @@ async fn load_from(
.map(|user| user.id.as_str())
.filter(|id| !id.is_empty())
.context("Sign in again to load your profile constitution")?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10))
.build()?;
@@ -661,6 +661,9 @@ fn orcarouter_and_existing_custom_routes_own_live_catalogs() {
}
pub fn provider_owns_live_catalog(provider: ProviderKind, base_url: &str) -> bool {
if crate::client::model_listing_unavailable(provider, base_url) {
return false;
}
matches!(
provider,
ProviderKind::Openrouter
+13 -4
View File
@@ -170,6 +170,13 @@ fn offerings_by_provider(
/// staleness without re-merging.
static LIVE_GENERATION: AtomicU64 = AtomicU64::new(0);
pub fn catalog_revision() -> (u64, u64) {
(
LIVE_GENERATION.load(Ordering::SeqCst),
codewhale_config::cloud_facts::overlay::snapshot().generation,
)
}
type MergedCacheEntry = ((u64, u64), Arc<SharedSnapshot>);
/// Memoized result of [`merged_snapshot`], tagged with the `LIVE_GENERATION`
@@ -536,10 +543,7 @@ pub fn lock_live_snapshot() -> LiveSnapshotLock {
/// `LIVE_GENERATION`; every other call returns the cached `Arc` (the picker
/// calls this per row, so it must be cheap).
fn merged_snapshot() -> Arc<SharedSnapshot> {
let generation = (
LIVE_GENERATION.load(Ordering::SeqCst),
codewhale_config::cloud_facts::overlay::snapshot().generation,
);
let generation = catalog_revision();
if let Ok(guard) = MERGED_CACHE.read()
&& let Some((cached_generation, cached)) = guard.as_ref()
&& *cached_generation == generation
@@ -1817,6 +1821,11 @@ pub async fn update_provider_catalog(
let base_url = route_config.active_route_base_url();
let fingerprint = base_url_fingerprint(&base_url);
let mut receipt = cached_receipt(&route_config, identity);
if crate::client::model_listing_unavailable(identity.provider, &base_url) {
receipt.outcome = "skipped";
receipt.error = Some("provider_model_listing_unavailable");
return receipt;
}
if identity.provider == ProviderKind::Antigravity {
receipt.outcome = "skipped";
receipt.error = Some("provider_retired");
+8 -1
View File
@@ -84,7 +84,7 @@ const AGENT_MAIL_OWNER_FILE: &str = "owner.json";
/// across all of them is what lets a switch adopt an existing store (#6207);
/// `adoptable_empty_store_reports_nothing_to_abandon` pins this list against
/// `open` by asserting each directory is load-bearing on its own.
const RUNTIME_STORE_WORK_DIRS: [&str; 7] = [
const RUNTIME_STORE_WORK_DIRS: [&str; 8] = [
"threads",
"turns",
"items",
@@ -92,6 +92,7 @@ const RUNTIME_STORE_WORK_DIRS: [&str; 7] = [
"goals",
"agent-mail",
"turn-operations",
"job-operations",
];
const TURN_OPERATION_BINDING_SCHEMA_VERSION: u32 = 2;
const RUNTIME_USAGE_PARTITION_SUMMARY: &str = "Provider usage receipt partition";
@@ -1586,6 +1587,7 @@ pub struct RuntimeThreadStore {
goal_mutation: Arc<parking_lot::Mutex<()>>,
mail_dir: PathBuf,
turn_operations_dir: PathBuf,
job_operations_dir: PathBuf,
owner_id: String,
state_path: PathBuf,
event_lock_path: PathBuf,
@@ -1665,6 +1667,7 @@ impl RuntimeThreadStore {
let goals_dir = root.join("goals");
let mail_dir = root.join("agent-mail");
let turn_operations_dir = root.join("turn-operations");
let job_operations_dir = root.join("job-operations");
ensure_runtime_store_dir(&threads_dir)?;
ensure_runtime_store_dir(&turns_dir)?;
ensure_runtime_store_dir(&items_dir)?;
@@ -1672,6 +1675,7 @@ impl RuntimeThreadStore {
ensure_runtime_store_dir(&goals_dir)?;
ensure_runtime_store_dir(&mail_dir)?;
ensure_runtime_store_dir(&turn_operations_dir)?;
ensure_runtime_store_dir(&job_operations_dir)?;
let state_path = root.join("state.json");
let owner_path = root.join(AGENT_MAIL_OWNER_FILE);
let event_lock_path = root.join(EVENT_TRANSACTION_LOCK_FILE);
@@ -1689,6 +1693,7 @@ impl RuntimeThreadStore {
goals_dir,
mail_dir,
turn_operations_dir,
job_operations_dir,
owner_id,
state_path,
event_lock_path,
@@ -1745,6 +1750,7 @@ impl RuntimeThreadStore {
goals_dir: root.join("goals"),
mail_dir: root.join("agent-mail"),
turn_operations_dir: root.join("turn-operations"),
job_operations_dir: root.join("job-operations"),
owner_id: String::new(),
state_path: root.join("state.json"),
event_lock_path: root.join(EVENT_TRANSACTION_LOCK_FILE),
@@ -5975,6 +5981,7 @@ fn remove_file_if_exists(path: &Path) -> Result<()> {
}
}
pub mod job_operations;
pub mod manager;
mod turn_artifacts;
pub use turn_artifacts::{
@@ -0,0 +1,236 @@
use super::*;
use ring::hmac;
use std::sync::{Mutex as StdMutex, TryLockError};
static JOB_OPERATION_MUTEX: StdMutex<()> = StdMutex::new(());
#[derive(Debug, thiserror::Error)]
#[error("job operation admission is busy")]
pub struct JobOperationsBusy;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JobOperation {
pub id: String,
pub store_id: String,
pub thread_id: String,
pub workspace: PathBuf,
pub cwd: PathBuf,
pub purpose: String,
pub status: String,
pub job_id: Option<String>,
pub job_status: Option<String>,
pub exit_code: Option<i64>,
pub created_at_ms: i64,
pub updated_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct JobOperationRecord {
pub schema_version: u32,
pub operation: JobOperation,
pub request_fingerprint: String,
pub owner_generation: String,
}
pub struct JobOperationTransaction<'a> {
store: &'a RuntimeThreadStore,
}
pub fn validate_key(key: &str) -> Result<()> {
let id = Uuid::parse_str(key).context("invalid job operation key")?;
anyhow::ensure!(
id.get_version_num() == 4 && id.to_string() == key,
"invalid job operation key"
);
Ok(())
}
pub fn request_fingerprint(key: &str, request: &[u8]) -> Result<String> {
validate_key(key)?;
let key = hmac::Key::new(hmac::HMAC_SHA256, key.as_bytes());
Ok(hex_digest(hmac::sign(&key, request).as_ref()))
}
impl RuntimeThreadStore {
pub fn with_job_operations<T>(
&self,
action: impl FnOnce(&JobOperationTransaction<'_>) -> Result<T>,
) -> Result<T> {
let _local = match JOB_OPERATION_MUTEX.try_lock() {
Ok(guard) => guard,
Err(TryLockError::WouldBlock) => return Err(JobOperationsBusy.into()),
Err(TryLockError::Poisoned(_)) => bail!("job operation admission unavailable"),
};
let root = self
.job_operations_dir
.parent()
.context("missing runtime store root")?;
checked_existing_runtime_store_dir(&self.job_operations_dir)?;
let path = root.join("job-operations.lock");
let file = open_runtime_store_file(&path, "job operation lock", |options| {
options.create(true).truncate(false).read(true).write(true);
})?;
let mut lock = fd_lock::RwLock::new(file);
let guard = match lock.try_write() {
Ok(guard) => guard,
Err(error)
if matches!(
error.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted
) =>
{
return Err(JobOperationsBusy.into());
}
Err(error) => return Err(error).context("job operation lock unavailable"),
};
anyhow::ensure!(
owner_lock_is_at_path(&guard, &path)?,
"job operation lock changed"
);
action(&JobOperationTransaction { store: self })
}
}
impl JobOperationTransaction<'_> {
pub fn operation_id(&self, thread_id: &str, key: &str) -> Result<String> {
validated_record_id(thread_id, "job operation thread")?;
validate_key(key)?;
Ok(codewhale_runtime::hashing::sha256_hex(format!(
"runtime-job-operation\u{1f}{}\u{1f}{thread_id}\u{1f}{key}",
self.store.owner_id
)))
}
fn path(&self, id: &str) -> Result<PathBuf> {
validate_sha256_fingerprint(id, "job operation id")?;
RuntimeThreadStore::record_path(
&self.store.job_operations_dir,
id,
"json",
"job operation id",
)
}
fn validate(&self, record: &JobOperationRecord) -> Result<()> {
let operation = &record.operation;
anyhow::ensure!(
record.schema_version == 1,
"unsupported job operation schema"
);
validate_sha256_fingerprint(&operation.id, "job operation id")?;
validate_sha256_fingerprint(&record.request_fingerprint, "job request fingerprint")?;
validated_record_id(&operation.thread_id, "job operation thread")?;
anyhow::ensure!(
operation.store_id == self.store.owner_id,
"job operation store mismatch"
);
anyhow::ensure!(
operation.workspace.is_absolute() && operation.cwd.is_absolute(),
"invalid job operation workspace"
);
anyhow::ensure!(
matches!(
operation.purpose.as_str(),
"setup" | "run" | "archive" | "command"
),
"invalid job purpose"
);
anyhow::ensure!(
matches!(
operation.status.as_str(),
"running" | "completed" | "rejected" | "unknown"
),
"invalid job status"
);
if let Some(id) = &operation.job_id {
validated_record_id(id, "job id")?;
anyhow::ensure!(
id.starts_with("shell_") && id.len() <= 128,
"invalid job id"
);
}
anyhow::ensure!(
operation.created_at_ms > 0 && operation.updated_at_ms >= operation.created_at_ms,
"invalid job operation timestamps"
);
anyhow::ensure!(
operation.status == "completed"
|| (operation.job_status.is_none() && operation.exit_code.is_none()),
"nonterminal job operation has a terminal result"
);
anyhow::ensure!(
operation.status != "rejected" || operation.job_id.is_none(),
"rejected job operation has a job id"
);
anyhow::ensure!(
operation.status != "running" || operation.job_id.is_some(),
"running job operation has no job id"
);
anyhow::ensure!(
operation.status != "completed"
|| (operation.job_id.is_some()
&& operation
.job_status
.as_deref()
.is_some_and(|status| matches!(
status,
"Completed" | "Failed" | "Killed" | "TimedOut"
))),
"completed job operation has no terminal result"
);
anyhow::ensure!(
Uuid::parse_str(&record.owner_generation).is_ok_and(|id| id.get_version_num() == 4),
"invalid job owner generation"
);
Ok(())
}
pub fn get(&self, id: &str) -> Result<Option<JobOperationRecord>> {
let path = self.path(id)?;
let raw = match read_store_file(&path) {
Ok(raw) => raw,
Err(error)
if error
.downcast_ref::<io::Error>()
.is_some_and(|error| error.kind() == io::ErrorKind::NotFound) =>
{
return Ok(None);
}
Err(error) => return Err(error),
};
let record: JobOperationRecord = serde_json::from_str(&raw)?;
self.validate(&record)?;
anyhow::ensure!(record.operation.id == id, "job operation path mismatch");
Ok(Some(record))
}
pub fn list(&self) -> Result<Vec<JobOperationRecord>> {
let directory = checked_existing_runtime_store_dir(&self.store.job_operations_dir)?;
let mut records = Vec::new();
for entry in fs::read_dir(directory)? {
let path = entry?.path();
if path.extension().is_none_or(|ext| ext != "json") {
continue;
}
let id = path
.file_stem()
.and_then(|name| name.to_str())
.context("invalid job operation filename")?;
records.push(self.get(id)?.context("job operation disappeared")?);
}
records.sort_by(|a, b| {
a.operation
.created_at_ms
.cmp(&b.operation.created_at_ms)
.then_with(|| a.operation.id.cmp(&b.operation.id))
});
Ok(records)
}
pub fn save(&self, record: &JobOperationRecord) -> Result<()> {
self.validate(record)?;
write_json_atomic(&self.path(&record.operation.id)?, record)
}
}
+105 -41
View File
@@ -477,6 +477,18 @@ pub(super) fn projected_user_text(message: &Message) -> Option<String> {
{
return None;
}
// A `<context_update>` drift notice is the same case in a bare shape: the
// engine appends it between turns as one whole text block, without the
// `<turn_meta>` provenance line the notices above carry, and no turn
// record keeps it. Reading it as a kept prompt made every fork, `/undo`
// and retry of a session that had received one refuse with "cannot
// identify an exact saved-history boundary"; the envelope is the engine's
// own fixed rendering, so a message that is exactly one is skipped.
if let [ContentBlock::Text { text, .. }] = message.content.as_slice()
&& codewhale_core::prefix_cache::is_context_update_envelope(text)
{
return None;
}
let text = message
.content
.iter()
@@ -4331,6 +4343,36 @@ impl RuntimeThreadManager {
&self.workspace
}
pub fn job_operation_store_id(&self) -> &str {
&self.store.owner_id
}
pub fn job_operation_store(&self) -> RuntimeThreadStore {
self.store.clone()
}
pub fn job_operation_generation(&self) -> Result<String> {
#[cfg(any(unix, windows))]
{
Ok(self.capture_control_owner()?.1)
}
#[cfg(not(any(unix, windows)))]
{
bail!("job operation owner unavailable")
}
}
pub fn validate_job_operation_thread(&self, thread: &ThreadRecord) -> Result<()> {
let current = self.store.load_thread(&thread.id)?;
anyhow::ensure!(
current.workspace == thread.workspace
&& current.allow_shell == thread.allow_shell
&& current.trust_mode == thread.trust_mode,
"thread workspace or shell policy changed"
);
Ok(())
}
pub fn mcp_runtime_servers(&self, workspace: &Path) -> crate::mcp::SharedRuntimeMcpServers {
self.runtime_mcp_servers
.lock()
@@ -7220,44 +7262,48 @@ impl RuntimeThreadManager {
return Ok(thread);
}
self.store.save_thread(&thread)?;
if let Err(error) = self
.emit_event(
&thread.id,
None,
None,
"thread.started",
json!({ "thread": thread.clone() }),
)
.await
{
self.active.lock().await.shell_managers.remove(&thread.id);
let _ = self.store.remove_thread(&thread.id);
return Err(error);
}
self.emit_event(
&thread.id,
None,
None,
"thread.started",
json!({ "thread": thread.clone() }),
)
.await?;
Ok(thread)
}
pub async fn discard_empty_thread(&self, thread_id: &str) -> Result<()> {
let _admission = self.session_checkpoint_guard().await;
let mut active = self.active.lock().await;
if active.engines.contains_key(thread_id) {
bail!("cannot discard a loaded Runtime thread");
}
let _thread_mutation = self.store.thread_mutation.lock();
let thread = self.store.load_thread(thread_id)?;
if thread.latest_turn_id.is_some() {
bail!("cannot discard a Runtime thread that owns turns");
}
// Drop the thread's shell authority with it: the manager owns any
// API-created jobs, and dropping the last handle kills them.
active.shell_managers.remove(thread_id);
self.store.with_job_operations(|transaction| {
anyhow::ensure!(
!transaction
.list()?
.iter()
.any(|record| record.operation.thread_id == thread_id
&& !matches!(record.operation.status.as_str(), "completed" | "rejected")),
"thread has a running or unconfirmed job operation"
);
let _thread_mutation = self.store.thread_mutation.lock();
let thread = self.store.load_thread(thread_id)?;
if thread.latest_turn_id.is_some() {
bail!("cannot discard a Runtime thread that owns turns");
}
active.shell_managers.remove(thread_id);
self.store.remove_thread(thread_id)?;
Ok(())
})?;
drop(active);
self.store.remove_thread(thread_id)?;
// A deleted conversation keeps no session grants behind it.
self.take_approval_grants(thread_id);
Ok(())
}
pub async fn delete_thread(&self, thread_id: &str) -> Result<ThreadRecord> {
let _admission = self.session_checkpoint_guard().await;
let mut active = self.active.lock().await;
if active
.engines
@@ -7266,21 +7312,32 @@ impl RuntimeThreadManager {
{
bail!("thread {thread_id} already has an active turn; stop it before deleting");
}
let _thread_mutation = self.store.thread_mutation.lock();
let thread = self.store.load_thread(thread_id)?;
active.lru.retain(|id| id != thread_id);
active.shell_managers.remove(thread_id);
let evicted = active.engines.remove(thread_id).map(|state| state.engine);
for turn in self.store.list_turns_for_thread(thread_id)? {
for item_id in &turn.item_ids {
self.store.remove_item(item_id)?;
let (thread, evicted) = self.store.with_job_operations(|transaction| {
anyhow::ensure!(
!transaction
.list()?
.iter()
.any(|record| record.operation.thread_id == thread_id
&& !matches!(record.operation.status.as_str(), "completed" | "rejected")),
"thread has a running or unconfirmed job operation"
);
let _thread_mutation = self.store.thread_mutation.lock();
let thread = self.store.load_thread(thread_id)?;
active.lru.retain(|id| id != thread_id);
active.shell_managers.remove(thread_id);
let evicted = active.engines.remove(thread_id).map(|state| state.engine);
for turn in self.store.list_turns_for_thread(thread_id)? {
for item_id in &turn.item_ids {
self.store.remove_item(item_id)?;
}
self.store.remove_turn(&turn.id)?;
}
self.store.remove_turn(&turn.id)?;
}
remove_file_if_exists(&self.store.events_path(thread_id)?)?;
remove_file_if_exists(&self.store.goal_path(thread_id)?)?;
self.store.remove_seed_journal(thread_id)?;
self.store.remove_thread(thread_id)?;
remove_file_if_exists(&self.store.events_path(thread_id)?)?;
remove_file_if_exists(&self.store.goal_path(thread_id)?)?;
self.store.remove_seed_journal(thread_id)?;
self.store.remove_thread(thread_id)?;
Ok((thread, evicted))
})?;
drop(active);
if let Some(engine) = evicted {
engine.cancel_with_reason(crate::core::engine::CancelReason::Internal);
@@ -10535,10 +10592,17 @@ impl RuntimeThreadManager {
})();
if let Err(persistence_error) = persistence {
let mut cleanup_errors = Vec::new();
if let Err(error) = self.store.remove_thread(&thread.id) {
cleanup_errors.push(format!("remove thread: {error}"));
let thread_path = self.store.thread_path(&thread.id)?;
match thread_path.try_exists() {
Ok(false) => {}
Ok(true) => bail!(
"Fork publication could not be confirmed; retained its records for recovery: {persistence_error}"
),
Err(error) => bail!(
"Fork publication could not be checked; retained its records for recovery: {error}"
),
}
let mut cleanup_errors = Vec::new();
for turn_id in saved_turn_ids.iter().rev() {
if let Err(error) = self.store.remove_turn(turn_id) {
cleanup_errors.push(format!("remove turn {turn_id}: {error}"));
@@ -24081,6 +24081,63 @@ fn runtime_traffic_and_attachment_tags_are_not_kept_prompts() {
);
}
/// The workspace-drift notice the engine appends between turns, in the bare
/// shape `context_update_message` renders: one whole text block, no
/// `<turn_meta>` provenance line, and nothing of it on any turn record. A
/// session that had received one could not be cut at all — every fork and
/// `/undo` past the first notice answered "Cannot identify an exact
/// saved-history boundary" — so the walks must skip it like the provenance-
/// carrying notices above, while a person quoting a notice keeps their turn.
#[test]
fn a_context_update_notice_is_not_a_kept_prompt() {
let text = |text: &str| ContentBlock::Text {
text: text.to_string(),
cache_control: None,
};
let user = |content: &str| Message {
role: Role::User,
content: vec![text(content)],
};
let notice = codewhale_core::prefix_cache::context_update_message(
"## Skills\nold skill\n",
"## Skills\nnew skill\n",
)
.expect("workspace drifted");
assert!(
codewhale_core::prefix_cache::is_context_update_envelope(&notice),
"the fixture is the engine's own drift notice"
);
let quoted = format!("what does this notice mean?\n{notice}");
let messages = vec![user("first"), user(&notice), user(&quoted), user("undo me")];
assert!(
projected_user_text(&messages[1]).is_none(),
"the drift notice is not a prompt"
);
assert_eq!(
projected_user_text(&messages[3]).as_deref(),
Some("undo me"),
"the prompt after a notice still reads as one"
);
assert_eq!(
projected_user_text(&messages[2]).as_deref(),
Some(quoted.as_str()),
"a person quoting a notice keeps their own turn"
);
let kept = vec!["first".to_string(), quoted.clone()];
assert_eq!(saved_history_boundary(&messages, &kept, "undo me"), Some(3));
assert_eq!(
exact_prefix_boundary(
&messages,
&session_recovery_projection(&[user("first"), user(&quoted)]),
),
Some(3),
"the exact prefix ends just past the last kept message"
);
}
/// The decision a rebuild makes about a call the turn store left unanswered:
/// which outcome the item itself holds, and which calls it must leave alone.
#[test]
@@ -288,6 +288,8 @@ async fn wait_for_http(
deadline: Instant,
context: &ToolContext,
) -> Result<ReadinessOutput, ToolError> {
// This probe always goes direct, so it must not install the loopback watch
// and start a probe thread it will never consult.
let client = codewhale_release::tls::reqwest_client_builder()
.timeout(request.timeout)
.redirect(reqwest::redirect::Policy::none())
+2 -2
View File
@@ -192,7 +192,7 @@ impl FinanceTool {
pub fn new() -> Self {
Self {
endpoints: FinanceEndpoints::default(),
client: codewhale_release::tls::reqwest_client_builder()
client: crate::platform_client_builder()
.user_agent(USER_AGENT)
.build()
.expect("failed to build HTTP client"),
@@ -206,7 +206,7 @@ impl FinanceTool {
quote_base: quote_base.into(),
chart_base: chart_base.into(),
},
client: codewhale_release::tls::reqwest_client_builder()
client: crate::platform_client_builder()
.user_agent(USER_AGENT)
.build()
.expect("failed to build HTTP client"),
+1 -1
View File
@@ -818,7 +818,7 @@ fn merge_incremental_entries(
/// memory, and atomically replace the cache file. Retried on failure;
/// the old snapshot survives any failed sync.
async fn sync_once(path: &Path) -> Result<(), ToolError> {
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.user_agent(USER_AGENT)
.timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
.build()
+114 -10
View File
@@ -11,7 +11,7 @@
use anyhow::{Context, Result, anyhow};
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io;
use std::io::{Read, Write};
@@ -147,6 +147,10 @@ fn validate_shell_working_dir(path: &Path, inherited_session_workspace: bool) ->
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[error("shell launch did not start")]
pub struct ShellLaunchNotStarted;
/// Status of a shell process.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ShellStatus {
@@ -251,6 +255,32 @@ pub struct ShellJobSnapshot {
pub owner_session_id: String,
}
#[derive(Debug, Clone)]
pub struct ShellReceiptWitness {
pub cwd: PathBuf,
pub status: ShellStatus,
pub exit_code: Option<i64>,
pub owner_session_id: String,
pub wait_failed: bool,
pub terminal_confirmed: bool,
}
impl ShellReceiptWitness {
fn from_shell(shell: &mut BackgroundShell) -> Self {
let terminal_confirmed = shell.status != ShellStatus::Running
&& !shell.wait_failed
&& shell.terminal_exit_confirmed;
Self {
cwd: shell.working_dir.clone(),
status: shell.status.clone(),
exit_code: shell.exit_code,
owner_session_id: shell.owner_session_id.clone(),
wait_failed: shell.wait_failed,
terminal_confirmed,
}
}
}
/// Once-only completion event for a tracked background shell job.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ShellCompletionEvent {
@@ -1152,6 +1182,7 @@ pub struct BackgroundShell {
/// The terminal status came from a failed `try_wait`, not an observed
/// exit or signal. Such a run gets no execution receipt (#6689).
wait_failed: bool,
terminal_exit_confirmed: bool,
}
#[derive(Clone)]
@@ -1315,6 +1346,7 @@ impl BackgroundShell {
let completed = if let Some(ref mut child) = self.child {
match child.try_wait() {
Ok(Some(status)) => {
self.terminal_exit_confirmed = true;
self.exit_code = status.code;
self.status = if status.success {
ShellStatus::Completed
@@ -1621,7 +1653,7 @@ impl BackgroundShell {
unregister_pending_persistent_process_group(process_group_id);
}
if let Some(ref mut child) = self.child {
match child {
match &mut *child {
ShellChild::Process(proc) => {
#[cfg(windows)]
{
@@ -1648,6 +1680,9 @@ impl BackgroundShell {
let _ = child.wait();
}
}
if child.try_wait().is_ok_and(|status| status.is_some()) {
self.terminal_exit_confirmed = true;
}
}
self.status = ShellStatus::Killed;
self.mark_finished();
@@ -1962,6 +1997,8 @@ pub fn inherited_interactive_terminal_refusal() -> Option<&'static str> {
pub struct ShellManager {
processes: HashMap<String, BackgroundShell>,
stale_jobs: HashMap<String, ShellJobSnapshot>,
receipt_pins: HashSet<String>,
receipt_witnesses: HashMap<String, ShellReceiptWitness>,
default_workspace: PathBuf,
sandbox_manager: SandboxManager,
sandbox_policy: ExecutionSandboxPolicy,
@@ -1994,6 +2031,28 @@ impl std::fmt::Debug for ShellManager {
}
impl ShellManager {
pub fn pin_job_receipt(&mut self, task_id: &str) -> Result<()> {
anyhow::ensure!(self.processes.contains_key(task_id), "job not tracked");
self.receipt_pins.insert(task_id.to_string());
Ok(())
}
pub fn job_receipt_witness(&mut self, task_id: &str) -> Option<ShellReceiptWitness> {
if !self.receipt_pins.contains(task_id) {
return None;
}
if let Some(shell) = self.processes.get_mut(task_id) {
shell.poll();
return Some(ShellReceiptWitness::from_shell(shell));
}
self.receipt_witnesses.get(task_id).cloned()
}
pub fn release_job_receipt(&mut self, task_id: &str) {
self.receipt_pins.remove(task_id);
self.receipt_witnesses.remove(task_id);
}
fn require_session_owner(&self, task_id: &str, active_session_id: &str) -> Result<()> {
let owned = self.processes.get(task_id).is_some_and(|shell| {
!active_session_id.is_empty() && shell.owner_session_id == active_session_id
@@ -2013,6 +2072,8 @@ impl ShellManager {
Self {
processes: HashMap::new(),
stale_jobs: HashMap::new(),
receipt_pins: HashSet::new(),
receipt_witnesses: HashMap::new(),
default_workspace: workspace,
sandbox_manager: SandboxManager::new(),
sandbox_policy: ExecutionSandboxPolicy::default(),
@@ -2088,6 +2149,7 @@ impl ShellManager {
last_lifecycle_status: None,
last_lifecycle_bytes: 0,
wait_failed: false,
terminal_exit_confirmed: false,
},
);
}
@@ -2148,6 +2210,29 @@ impl ShellManager {
self.sandbox_manager.prepare(&spec)
}
pub fn validate_background_job(
&self,
command: &str,
cwd: &Path,
timeout_ms: u64,
policy: &ExecutionSandboxPolicy,
env: &HashMap<String, String>,
) -> Result<()> {
validate_shell_working_dir(cwd, false)?;
let spec = CommandSpec::shell(
command,
cwd.to_path_buf(),
Duration::from_millis(timeout_ms.clamp(1_000, 600_000)),
)
.with_policy(policy.clone())
.with_env(env.clone());
let prepared = self.sandbox_manager.prepare(&spec);
if matches!(spec.sandbox_policy, ExecutionSandboxPolicy::ReadOnly { .. }) {
require_native_readonly_execution(&prepared)?;
}
Ok(())
}
fn register_shell_wait(&mut self, session_id: &str) -> Arc<AtomicBool> {
self.active_waits
.retain(|(_, wait)| wait.strong_count() > 0);
@@ -2346,7 +2431,10 @@ impl ShellManager {
codewhale_sandbox::shell_dispatcher::ShellDispatcher::log_exec(command);
let work_dir = working_dir.map_or_else(|| self.default_workspace.clone(), PathBuf::from);
validate_shell_working_dir(&work_dir, working_dir.is_none())?;
validate_shell_working_dir(&work_dir, working_dir.is_none()).map_err(|error| {
let message = error.to_string();
error.context(ShellLaunchNotStarted).context(message)
})?;
let timeout_ms = timeout_ms.clamp(timeout_bounds_ms.0, timeout_bounds_ms.1);
@@ -2380,7 +2468,10 @@ impl ShellManager {
{
// Arbitrary code with a read-only policy needs kernel enforcement;
// only the separately hardened argv subset may run without it.
require_native_readonly_execution(&exec_env)?;
require_native_readonly_execution(&exec_env).map_err(|error| {
let message = error.to_string();
error.context(ShellLaunchNotStarted).context(message)
})?;
}
if background {
@@ -2733,7 +2824,7 @@ impl ShellManager {
origin_turn_id,
work_lifecycle,
} = spawn_context;
let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]);
let task_id = format!("shell_{}", Uuid::new_v4().simple());
let mut spawn_guard =
ShellSpawnIntentGuard::new(work_lifecycle, &task_id, original_command);
// The guard owns the registration outcome: when bookkeeping could not
@@ -2791,6 +2882,7 @@ impl ShellManager {
pixel_width: 0,
pixel_height: 0,
})
.context(ShellLaunchNotStarted)
.context("Failed to open PTY")?;
let mut child = exec_env
@@ -2978,6 +3070,7 @@ impl ShellManager {
last_lifecycle_status: None,
last_lifecycle_bytes: 0,
wait_failed: false,
terminal_exit_confirmed: false,
};
#[cfg(unix)]
@@ -3755,14 +3848,20 @@ impl ShellManager {
/// age out too: jobs launched over the Runtime API, or owned by a session
/// that is no longer active, are never drained.
pub fn cleanup(&mut self, max_age: Duration) {
self.processes.retain(|_, shell| {
let pins = &self.receipt_pins;
let witnesses = &mut self.receipt_witnesses;
self.processes.retain(|id, shell| {
if shell.status == ShellStatus::Running {
return true;
}
shell.mark_finished();
shell
let retain = shell
.finished_at
.is_none_or(|finished| finished.elapsed() < max_age)
.is_none_or(|finished| finished.elapsed() < max_age);
if !retain && pins.contains(id) {
witnesses.insert(id.clone(), ShellReceiptWitness::from_shell(shell));
}
retain
});
self.enforce_finished_job_bounds();
}
@@ -3819,7 +3918,12 @@ impl ShellManager {
{
break;
}
self.processes.remove(&id);
if let Some(mut shell) = self.processes.remove(&id)
&& self.receipt_pins.contains(&id)
{
self.receipt_witnesses
.insert(id, ShellReceiptWitness::from_shell(&mut shell));
}
remaining_count -= 1;
remaining_bytes = remaining_bytes.saturating_sub(bytes);
}
@@ -6464,7 +6568,7 @@ impl ToolSpec for BashTool {
.lock()
.map_err(|_| ToolError::execution_failed("shell manager lock poisoned"))?;
let work_lifecycle = shell_work_lifecycle_from_context(context);
let task_id = format!("shell_{}", &Uuid::new_v4().to_string()[..8]);
let task_id = format!("shell_{}", Uuid::new_v4().simple());
let mut spawn_guard = ShellSpawnIntentGuard::new(work_lifecycle, &task_id, command);
// Only a registered operation is observed (#6435).
let work_lifecycle = spawn_guard.lifecycle.clone();
+1
View File
@@ -4308,6 +4308,7 @@ fn killed_shell_does_not_wait_for_blocked_reader_threads() {
last_lifecycle_status: None,
last_lifecycle_bytes: 0,
wait_failed: false,
terminal_exit_confirmed: false,
};
let started = std::time::Instant::now();
+22 -34
View File
@@ -3939,13 +3939,9 @@ struct CoordinationProcessLock {
thread: Option<std::thread::JoinHandle<()>>,
}
/// Marker prefix for a coordination-lock failure whose holder is THIS
/// process: an engine swap (model/provider switch) constructs the new
/// engine's manager while the old engine still holds the flock on its own
/// fd, and flock treats a second descriptor in the same process as a
/// conflict. This is a transient handover that self-heals on the next
/// projection retry (#5036) — UI surfaces must not present it as "another
/// Codewhale process" (owner report, 2026-08-04).
/// Marker prefix retained for presentation compatibility with same-process
/// handover notes from earlier hosts. It does not control lock acquisition
/// or retry.
pub const COORDINATION_SAME_PROCESS_HANDOVER: &str =
"handing delegated coordination between engines in this process";
@@ -4036,23 +4032,10 @@ impl CoordinationProcessLock {
}),
Ok(Err(error)) => {
let _ = thread.join();
let holder_pid = std::fs::read_to_string(&lock_path)
.ok()
.and_then(|contents| contents.trim().parse::<u32>().ok());
if holder_pid == Some(std::process::id()) {
Err(anyhow!(
"{COORDINATION_SAME_PROCESS_HANDOVER} for {}: {error}",
state_root.display()
))
} else {
Err(anyhow!(
"another Codewhale process{} owns delegated coordination for {}: {error}",
holder_pid
.map(|pid| format!(" (pid {pid})"))
.unwrap_or_default(),
state_root.display()
))
}
Err(anyhow!(
"delegated coordination lock is unavailable for {}: {error}; close any other running or suspended Codewhale session using this workspace, then restart this Runtime if admission was refused",
state_root.display()
))
}
Err(error) => {
drop(release_tx);
@@ -4166,7 +4149,7 @@ pub struct SubAgentManager {
/// capture the most recent checkpoint.
last_persist_at: Option<Instant>,
persist_pending: bool,
/// #3803: last time `cleanup` ran. The sidebar refresh (`Op::ListSubAgents`)
/// #3803: last time a cleanup attempt finished. The sidebar refresh (`Op::ListSubAgents`)
/// renders from a read-only `list()` snapshot and only runs the
/// write-locked `cleanup` on a bounded cadence, so a UI refresh storm during
/// a sub-agent fanout no longer contends for the write lock on every request.
@@ -11338,7 +11321,9 @@ impl SubAgentManager {
/// given duration. Returns the number of running agents auto-cancelled
/// during this pass.
pub fn cleanup(&mut self, max_age: Duration) -> Result<usize> {
self.with_store_transaction(|held| held.cleanup_inner(None, max_age))
let result = self.with_store_transaction(|held| held.cleanup_inner(None, max_age));
self.last_cleanup_at = Some(Instant::now());
result
}
pub fn cleanup_for_session(
@@ -11346,7 +11331,10 @@ impl SubAgentManager {
active_session_id: &str,
max_age: Duration,
) -> Result<usize> {
self.with_store_transaction(|held| held.cleanup_inner(Some(active_session_id), max_age))
let result = self
.with_store_transaction(|held| held.cleanup_inner(Some(active_session_id), max_age));
self.last_cleanup_at = Some(Instant::now());
result
}
fn cleanup_inner(
@@ -11592,18 +11580,18 @@ impl SubAgentManager {
self.pending_handle_evictions
.push((agent_id, owner_session_id));
}
self.last_cleanup_at = Some(Instant::now());
Ok(auto_cancelled)
}
/// #3803: whether enough time has elapsed since the last `cleanup` that the
/// next sidebar refresh should run the write-locked cleanup again. Every
/// other refresh renders from the read-only `list()` snapshot, so a UI
/// refresh storm during a fanout does not take the write lock per request.
/// #3803: whether the next sidebar refresh should attempt cleanup.
/// Read-only managers without a process lock only render their refusal.
/// Other managers use the last attempt's interval to bound cleanup locking.
#[must_use]
pub fn cleanup_due(&self, min_interval: Duration) -> bool {
self.last_cleanup_at
.is_none_or(|last| last.elapsed() >= min_interval)
(!self.coordination_snapshot_read_only || self.holds_coordination_process_lock())
&& self
.last_cleanup_at
.is_none_or(|last| last.elapsed() >= min_interval)
}
#[must_use]
@@ -8883,6 +8883,7 @@ fn small_surface_starts_with_core_tools_and_read_only_goal_control() {
"file_search",
"get_goal",
"load_skill",
"present",
"read",
"task_shell_wait",
"todo_write",
@@ -9269,6 +9270,7 @@ fn small_surface_depth_cap_removes_only_agent() {
"file_search",
"get_goal",
"load_skill",
"present",
"read",
"task_shell_wait",
"todo_write",
@@ -277,7 +277,7 @@ async fn real_core_http_capture_and_host_request_are_bounded_without_credentials
)
.mount(&server)
.await;
let response = codewhale_release::tls::reqwest_client_builder()
let response = crate::platform_client_builder()
.build()
.unwrap()
.get(format!("{}/oversize", server.uri()))
+1 -1
View File
@@ -1126,7 +1126,7 @@ async fn run_image_search(
context: &ToolContext,
) -> Result<(Vec<ImageResultEntry>, Option<String>), ToolError> {
super::web_search::check_policy(context.network_policy.as_ref(), "duckduckgo.com")?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
.build()
+11 -11
View File
@@ -717,7 +717,7 @@ impl WebSearchTool {
api_key: Option<&str>,
context: &ToolContext,
) -> AdapterResult<(Vec<WebSearchEntry>, String)> {
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
.build()
@@ -843,7 +843,7 @@ impl WebSearchTool {
};
check_policy(context.network_policy.as_ref(), &host)?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.user_agent(USER_AGENT)
.build()
@@ -929,7 +929,7 @@ impl WebSearchTool {
)
})?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| {
@@ -1013,7 +1013,7 @@ impl WebSearchTool {
)
})?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| {
@@ -1108,7 +1108,7 @@ impl WebSearchTool {
)
})?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| {
@@ -1187,7 +1187,7 @@ impl WebSearchTool {
"DeepSeek search requires an API key. Set `[search] api_key` in config.toml or the DEEPSEEK_API_KEY env var.",
)
})?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| {
@@ -1252,7 +1252,7 @@ impl WebSearchTool {
)
})?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| {
@@ -1351,7 +1351,7 @@ impl WebSearchTool {
)
})?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| {
@@ -1457,7 +1457,7 @@ impl WebSearchTool {
)
})?;
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(Duration::from_millis(timeout_ms))
.build()
.map_err(|e| {
@@ -1567,7 +1567,7 @@ impl WebSearchTool {
let effective_timeout = timeout_ms.max(90_000);
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.connect_timeout(Duration::from_secs(15))
.timeout(Duration::from_millis(effective_timeout))
.tcp_keepalive(Some(Duration::from_secs(30)))
@@ -2508,7 +2508,7 @@ async fn run_scrape_search_with_endpoints(
let started = Instant::now();
let budget = Duration::from_millis(timeout_ms);
let decider = context.network_policy.as_ref();
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
.timeout(budget)
.user_agent(USER_AGENT)
.build()
+1 -1
View File
@@ -53,7 +53,7 @@ impl ImageAnalyzeTool {
config: VisionModelConfig,
route_client: Option<CodewhaleClient>,
) -> Self {
let client = codewhale_release::tls::reqwest_client_builder()
let client = crate::platform_client_builder()
// Bound only the connect handshake. A client- or request-level
// `read_timeout` would start counting at `send()` and never
// reset before the response headers, quietly re-introducing a
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Inspecciona i controla els treballs Bash en segon pla",
"CmdLinksDescription": "Mostra enllaços de Codewhale, la comunitat i els proveïdors",
"CmdLoadDescription": "Carrega una sessió des d'un fitxer",
"CmdLogoutDescription": "Esborra la clau d'API i torna a la configuració",
"CmdLogoutDescription": "Tanca la sessió al Codewhale",
"CmdMcpDescription": "Obre o gestiona servidors MCP; init afegeix, doctor revisa",
"McpReloadAlreadyRunning": "La recàrrega MCP ja s'està executant; la barra d'estat en fa el seguiment.",
"McpRecommendedUnknownId": "ID MCP recomanat desconegut. Executa {recommendations_command} per revisar la llista seleccionada.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Canvia de tema o obre el selector de temes",
"CmdProviderDescription": "Canvia el proveïdor i/o model actiu",
"CmdAuthDescription": "Gestiona els fluxos d'autenticació dels proveïdors",
"CmdLoginDescription": "Inicia sessió a Codewhale o desa claus de proveïdor",
"CmdLoginDescription": "Inicia la sessió al Codewhale",
"CmdQueueDescription": "Mostra o edita els missatges en cua",
"CmdQueueUsage": "Ús: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "S'han restaurat {count} missatges en cua d'una sessió anterior. No s'envien automàticament: /queue send <n> n'envia un, /queue clear els descarta.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Traspàs del torn",
"PagerCopyAnswerLabel": "Resposta",
"PagerSearchMatch": "coincidència {current}/{total} (n/N)",
"ToolOutputShortened": "Sortida escurçada · {key} obre els detalls"
"ToolOutputShortened": "Sortida escurçada · {key} obre els detalls",
"AccountLoginTitle": "Inicia la sessió al Codewhale",
"AccountLoginStarting": "S’està iniciant la sessió…",
"AccountLoginWaiting": "Completa l’inici de sessió al navegador. També pots obrir l’enllaç en un altre dispositiu.",
"AccountLoginCode": "Codi: {code}",
"AccountLoginOpenBrowser": "Obre el navegador",
"AccountLoginCopyLink": "Copia l’enllaç",
"AccountLoginLink": "Enllaç d’inici de sessió",
"AccountLoginSuccess": "Has iniciat la sessió al Codewhale. Es manté el model seleccionat.",
"AccountLoginNeedsModel": "Has iniciat la sessió al Codewhale. Connecta un model per començar.",
"AccountLoginCancelled": "S’ha cancel·lat l’inici de sessió.",
"AccountLoginExpired": "L’inici de sessió ha esgotat el temps d’espera. Fes servir {command} per tornar-ho a provar.",
"AccountLoginFailed": "No s’ha pogut iniciar la sessió. Fes servir {command} per tornar-ho a provar.",
"AccountLogoutTitle": "Tanca la sessió al Codewhale",
"AccountLogoutStarting": "S’està tancant la sessió al Codewhale…",
"AccountLogoutSuccess": "Has tancat la sessió al Codewhale. Es mantenen els models i les converses locals.",
"AccountLogoutLocal": "Has tancat la sessió en aquest ordinador. No s’ha pogut confirmar la revocació al servidor.",
"AccountLogoutFailed": "No s’ha pogut tancar la sessió. Fes servir {command} per tornar-ho a provar.",
"AccountLogoutCancelled": "S’ha cancel·lat el tancament de sessió.",
"ConnectionSetupTitle": "Connecta un model",
"ConnectionSetupAccount": "Continua amb Codewhale",
"ConnectionSetupAccountDetail": "Inicia la sessió i consulta els models disponibles al teu compte.",
"ConnectionSetupLocal": "Connecta en aquest ordinador",
"ConnectionSetupLocalDetail": "Fes servir un compte de proveïdor, una clau d’API o un model local.",
"AccountModelsTitle": "Tria un model del compte",
"AccountModelsLoading": "S’està comprovant l’accés als models del compte…",
"AccountModelsUnavailable": "Aquí no hi ha cap model del compte disponible. Connecta un proveïdor en aquest ordinador o actualitza les connexions de models a l’aplicació Codewhale.",
"AccountModelsReady": "Connectat a {model} mitjançant el teu compte de Codewhale.",
"AccountModelsConnectFailed": "No s’han pogut connectar els models del compte: {error}",
"AccountModelsCancelled": "S’ha cancel·lat la configuració del model. Es manté el model actual.",
"AccountModelsLoadFailed": "No s’han pogut comprovar els models del compte. Torna-ho a provar o connecta un proveïdor en aquest ordinador."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Bash-Hintergrundjobs ansehen und steuern",
"CmdLinksDescription": "Codewhale-, Community- und Provider-Links anzeigen",
"CmdLoadDescription": "Sitzung aus Datei laden",
"CmdLogoutDescription": "API-Schlüssel löschen und zum Setup zurückkehren",
"CmdLogoutDescription": "Von Codewhale abmelden",
"CmdMcpDescription": "MCP-Server öffnen/verwalten; init fügt hinzu, doctor prüft",
"McpReloadAlreadyRunning": "Ein MCP-Neuladen läuft bereits; die Statusleiste verfolgt es.",
"McpRecommendedUnknownId": "Unbekannte empfohlene MCP-ID. Mit {recommendations_command} kann die kuratierte Liste geprüft werden.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Theme wechseln oder Theme-Auswahl öffnen",
"CmdProviderDescription": "Aktiven Provider und/oder Modell wechseln",
"CmdAuthDescription": "Provider-Authentifizierung verwalten",
"CmdLoginDescription": "Bei Codewhale anmelden oder Provider-Schlüssel speichern",
"CmdLoginDescription": "Bei Codewhale anmelden",
"CmdQueueDescription": "Nachrichten in der Warteschlange ansehen oder bearbeiten",
"CmdQueueUsage": "Verwendung: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "{count} Nachricht(en) aus einer früheren Sitzung wiederhergestellt. Sie werden nicht automatisch gesendet: /queue send <n> sendet eine, /queue clear verwirft sie.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Zugübergabe",
"PagerCopyAnswerLabel": "Antwort",
"PagerSearchMatch": "Treffer {current}/{total} (n/N)",
"ToolOutputShortened": "Ausgabe gekürzt · {key} öffnet Details"
"ToolOutputShortened": "Ausgabe gekürzt · {key} öffnet Details",
"AccountLoginTitle": "Bei Codewhale anmelden",
"AccountLoginStarting": "Anmeldung wird gestartet…",
"AccountLoginWaiting": "Schließe die Anmeldung im Browser ab. Du kannst den Link auch auf einem anderen Gerät öffnen.",
"AccountLoginCode": "Code: {code}",
"AccountLoginOpenBrowser": "Browser öffnen",
"AccountLoginCopyLink": "Link kopieren",
"AccountLoginLink": "Anmeldelink",
"AccountLoginSuccess": "Bei Codewhale angemeldet. Das ausgewählte Modell bleibt unverändert.",
"AccountLoginNeedsModel": "Bei Codewhale angemeldet. Verbinde ein Modell, um loszulegen.",
"AccountLoginCancelled": "Anmeldung abgebrochen.",
"AccountLoginExpired": "Zeitüberschreitung bei der Anmeldung. Versuche es mit {command} erneut.",
"AccountLoginFailed": "Anmeldung fehlgeschlagen. Versuche es mit {command} erneut.",
"AccountLogoutTitle": "Von Codewhale abmelden",
"AccountLogoutStarting": "Abmeldung von Codewhale läuft…",
"AccountLogoutSuccess": "Von Codewhale abgemeldet. Lokale Modelle und Unterhaltungen bleiben unverändert.",
"AccountLogoutLocal": "Auf diesem Computer abgemeldet. Der Widerruf auf dem Server konnte nicht bestätigt werden.",
"AccountLogoutFailed": "Abmeldung fehlgeschlagen. Versuche es mit {command} erneut.",
"AccountLogoutCancelled": "Abmeldung abgebrochen.",
"ConnectionSetupTitle": "Ein Modell verbinden",
"ConnectionSetupAccount": "Mit Codewhale fortfahren",
"ConnectionSetupAccountDetail": "Melde dich an und prüfe die verfügbaren Modelle deines Kontos.",
"ConnectionSetupLocal": "Auf diesem Computer verbinden",
"ConnectionSetupLocalDetail": "Nutze ein Anbieterkonto, einen API-Schlüssel oder ein lokales Modell.",
"AccountModelsTitle": "Ein Kontomodell auswählen",
"AccountModelsLoading": "Zugriff auf Kontomodelle wird geprüft…",
"AccountModelsUnavailable": "Hier sind keine nutzbaren Kontomodelle verfügbar. Verbinde einen Anbieter auf diesem Computer oder aktualisiere deine Modellverbindungen in der Codewhale-App.",
"AccountModelsReady": "Über dein Codewhale-Konto mit {model} verbunden.",
"AccountModelsConnectFailed": "Kontomodelle konnten nicht verbunden werden: {error}",
"AccountModelsCancelled": "Modelleinrichtung abgebrochen. Dein aktuelles Modell bleibt unverändert.",
"AccountModelsLoadFailed": "Kontomodelle konnten nicht geprüft werden. Versuche es erneut oder verbinde einen Anbieter auf diesem Computer."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "Manage background shell jobs",
"CmdLinksDescription": "Show Codewhale, community, and provider links",
"CmdLoadDescription": "Load a session from file",
"CmdLogoutDescription": "Sign out and return to setup",
"CmdLogoutDescription": "Sign out of Codewhale",
"CmdMcpDescription": "Open or manage MCP servers; init adds one, doctor checks it",
"McpReloadAlreadyRunning": "MCP reload is already running; the status bar tracks it.",
"McpRecommendedUnknownId": "Unknown MCP suggestion. Run {recommendations_command} to see the list.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "Pick a theme",
"CmdProviderDescription": "Switch provider or model",
"CmdAuthDescription": "Manage provider sign-in",
"CmdLoginDescription": "Sign in to Codewhale or store provider keys",
"CmdLoginDescription": "Sign in to Codewhale",
"CmdQueueDescription": "See or edit queued messages",
"CmdQueueUsage": "Usage: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Restored {count} queued message(s) from an earlier session. They are not sent automatically: /queue send <n> sends one, /queue clear discards them.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Turn handoff",
"PagerCopyAnswerLabel": "Answer",
"PagerSearchMatch": "match {current}/{total} (n/N)",
"ToolOutputShortened": "Output shortened · {key} opens details"
"ToolOutputShortened": "Output shortened · {key} opens details",
"AccountLoginTitle": "Sign in to Codewhale",
"AccountLoginStarting": "Starting sign-in…",
"AccountLoginWaiting": "Finish signing in in your browser. You can also open the link on another device.",
"AccountLoginCode": "Code: {code}",
"AccountLoginOpenBrowser": "Open browser",
"AccountLoginCopyLink": "Copy link",
"AccountLoginLink": "Sign-in link",
"AccountLoginSuccess": "Signed in to Codewhale. Your selected model is unchanged.",
"AccountLoginNeedsModel": "Signed in to Codewhale. Connect a model to start.",
"AccountLoginCancelled": "Sign-in cancelled.",
"AccountLoginExpired": "Sign-in timed out. Use {command} to try again.",
"AccountLoginFailed": "Couldn’t sign in. Use {command} to try again.",
"AccountLogoutTitle": "Sign out of Codewhale",
"AccountLogoutStarting": "Signing out of Codewhale…",
"AccountLogoutSuccess": "Signed out of Codewhale. Local models and conversations are unchanged.",
"AccountLogoutLocal": "Signed out on this computer. Server revocation couldn’t be confirmed.",
"AccountLogoutFailed": "Couldn’t sign out. Use {command} to try again.",
"AccountLogoutCancelled": "Sign-out cancelled.",
"ConnectionSetupTitle": "Connect a model",
"ConnectionSetupAccount": "Continue with Codewhale",
"ConnectionSetupAccountDetail": "Sign in and check your available account models.",
"ConnectionSetupLocal": "Connect on this computer",
"ConnectionSetupLocalDetail": "Use a provider account, API key, or local model.",
"AccountModelsTitle": "Choose an account model",
"AccountModelsLoading": "Checking account model access…",
"AccountModelsUnavailable": "No usable account models are available here. Connect a provider on this computer, or update your model connections in the Codewhale app.",
"AccountModelsReady": "Connected to {model} through your Codewhale account.",
"AccountModelsConnectFailed": "Couldn’t connect account models: {error}",
"AccountModelsCancelled": "Model setup cancelled. Your current model is unchanged.",
"AccountModelsLoadFailed": "Couldn’t check account models. Try again or connect a provider on this computer."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "Inspeccionar y controlar trabajos de shell en segundo plano",
"CmdLinksDescription": "Mostrar enlaces de Codewhale, la comunidad y los proveedores",
"CmdLoadDescription": "Cargar la sesión desde un archivo",
"CmdLogoutDescription": "Limpiar la clave de API y volver a la configuración",
"CmdLogoutDescription": "Cerrar sesión en Codewhale",
"CmdMcpDescription": "Abrir o gestionar servidores MCP; init agrega, doctor revisa",
"McpReloadAlreadyRunning": "La recarga de MCP ya está en curso; la barra de estado la sigue.",
"McpRecommendedUnknownId": "ID de MCP recomendado desconocido. Ejecuta {recommendations_command} para revisar la lista seleccionada.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "Alternar entre tema claro y oscuro",
"CmdProviderDescription": "Cambiar el proveedor o el modelo",
"CmdAuthDescription": "Gestionar flujos de autenticación de proveedores",
"CmdLoginDescription": "Iniciar sesión en Codewhale o guardar claves de proveedor",
"CmdLoginDescription": "Iniciar sesión en Codewhale",
"CmdQueueDescription": "Ver o editar mensajes en cola",
"CmdQueueUsage": "Uso: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Se restauraron {count} mensajes en cola de una sesión anterior. No se envían automáticamente: /queue send <n> envía uno, /queue clear los descarta.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Traspaso del turno",
"PagerCopyAnswerLabel": "Respuesta",
"PagerSearchMatch": "coincidencia {current}/{total} (n/N)",
"ToolOutputShortened": "Salida acortada · {key} abre los detalles"
"ToolOutputShortened": "Salida acortada · {key} abre los detalles",
"AccountLoginTitle": "Iniciar sesión en Codewhale",
"AccountLoginStarting": "Iniciando sesión…",
"AccountLoginWaiting": "Completa el inicio de sesión en tu navegador. También puedes abrir el enlace en otro dispositivo.",
"AccountLoginCode": "Código: {code}",
"AccountLoginOpenBrowser": "Abrir navegador",
"AccountLoginCopyLink": "Copiar enlace",
"AccountLoginLink": "Enlace de inicio de sesión",
"AccountLoginSuccess": "Iniciaste sesión en Codewhale. Se conserva el modelo seleccionado.",
"AccountLoginNeedsModel": "Iniciaste sesión en Codewhale. Conecta un modelo para comenzar.",
"AccountLoginCancelled": "Inicio de sesión cancelado.",
"AccountLoginExpired": "El inicio de sesión agotó el tiempo de espera. Usa {command} para reintentar.",
"AccountLoginFailed": "No se pudo iniciar sesión. Usa {command} para reintentar.",
"AccountLogoutTitle": "Cerrar sesión en Codewhale",
"AccountLogoutStarting": "Cerrando sesión en Codewhale…",
"AccountLogoutSuccess": "Cerraste sesión en Codewhale. Se conservan los modelos y las conversaciones locales.",
"AccountLogoutLocal": "Cerraste sesión en esta computadora. No se pudo confirmar la revocación en el servidor.",
"AccountLogoutFailed": "No se pudo cerrar sesión. Usa {command} para reintentar.",
"AccountLogoutCancelled": "Cierre de sesión cancelado.",
"ConnectionSetupTitle": "Conectar un modelo",
"ConnectionSetupAccount": "Continuar con Codewhale",
"ConnectionSetupAccountDetail": "Inicia sesión y consulta los modelos disponibles en tu cuenta.",
"ConnectionSetupLocal": "Conectar en esta computadora",
"ConnectionSetupLocalDetail": "Usa una cuenta de proveedor, una clave de API o un modelo local.",
"AccountModelsTitle": "Elegir un modelo de la cuenta",
"AccountModelsLoading": "Comprobando el acceso a los modelos de la cuenta…",
"AccountModelsUnavailable": "No hay modelos de la cuenta disponibles aquí. Conecta un proveedor en esta computadora o actualiza tus conexiones de modelos en la app de Codewhale.",
"AccountModelsReady": "Conectado a {model} mediante tu cuenta de Codewhale.",
"AccountModelsConnectFailed": "No se pudieron conectar los modelos de la cuenta: {error}",
"AccountModelsCancelled": "Configuración del modelo cancelada. Se conserva tu modelo actual.",
"AccountModelsLoadFailed": "No se pudieron consultar los modelos de la cuenta. Reintenta o conecta un proveedor en esta computadora."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Inspecter et contrôler les jobs Bash en arrière-plan",
"CmdLinksDescription": "Afficher les liens Codewhale, communauté et fournisseurs",
"CmdLoadDescription": "Charger une session depuis un fichier",
"CmdLogoutDescription": "Effacer la clé API et revenir à la configuration",
"CmdLogoutDescription": "Se déconnecter de Codewhale",
"CmdMcpDescription": "Ouvrir ou gérer les serveurs MCP ; init ajoute, doctor teste",
"McpReloadAlreadyRunning": "Un rechargement MCP est déjà en cours ; la barre d'état le suit.",
"McpRecommendedUnknownId": "ID MCP recommandé inconnu. Exécutez {recommendations_command} pour consulter la liste sélectionnée.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Changer de thème ou ouvrir le sélecteur de thème",
"CmdProviderDescription": "Changer le fournisseur et/ou le modèle actif",
"CmdAuthDescription": "Gérer les flux d'authentification des fournisseurs",
"CmdLoginDescription": "Se connecter à Codewhale ou enregistrer des clés fournisseur",
"CmdLoginDescription": "Se connecter à Codewhale",
"CmdQueueDescription": "Voir ou modifier les messages en file",
"CmdQueueUsage": "Usage : /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "{count} message(s) en file restauré(s) d'une session précédente. Ils ne partent pas automatiquement : /queue send <n> en envoie un, /queue clear les supprime.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Passation du tour",
"PagerCopyAnswerLabel": "Réponse",
"PagerSearchMatch": "résultat {current}/{total} (n/N)",
"ToolOutputShortened": "Sortie raccourcie · {key} ouvre les détails"
"ToolOutputShortened": "Sortie raccourcie · {key} ouvre les détails",
"AccountLoginTitle": "Se connecter à Codewhale",
"AccountLoginStarting": "Démarrage de la connexion…",
"AccountLoginWaiting": "Terminez la connexion dans votre navigateur. Vous pouvez aussi ouvrir le lien sur un autre appareil.",
"AccountLoginCode": "Code : {code}",
"AccountLoginOpenBrowser": "Ouvrir le navigateur",
"AccountLoginCopyLink": "Copier le lien",
"AccountLoginLink": "Lien de connexion",
"AccountLoginSuccess": "Connecté à Codewhale. Le modèle sélectionné reste inchangé.",
"AccountLoginNeedsModel": "Connecté à Codewhale. Connectez un modèle pour commencer.",
"AccountLoginCancelled": "Connexion annulée.",
"AccountLoginExpired": "La connexion a expiré. Utilisez {command} pour réessayer.",
"AccountLoginFailed": "Impossible de se connecter. Utilisez {command} pour réessayer.",
"AccountLogoutTitle": "Se déconnecter de Codewhale",
"AccountLogoutStarting": "Déconnexion de Codewhale…",
"AccountLogoutSuccess": "Déconnecté de Codewhale. Les modèles et conversations locaux restent inchangés.",
"AccountLogoutLocal": "Déconnecté sur cet ordinateur. La révocation sur le serveur n’a pas pu être confirmée.",
"AccountLogoutFailed": "Impossible de se déconnecter. Utilisez {command} pour réessayer.",
"AccountLogoutCancelled": "Déconnexion annulée.",
"ConnectionSetupTitle": "Connecter un modèle",
"ConnectionSetupAccount": "Continuer avec Codewhale",
"ConnectionSetupAccountDetail": "Connectez-vous et consultez les modèles disponibles sur votre compte.",
"ConnectionSetupLocal": "Connecter sur cet ordinateur",
"ConnectionSetupLocalDetail": "Utilisez un compte fournisseur, une clé API ou un modèle local.",
"AccountModelsTitle": "Choisir un modèle du compte",
"AccountModelsLoading": "Vérification de l’accès aux modèles du compte…",
"AccountModelsUnavailable": "Aucun modèle du compte n’est utilisable ici. Connectez un fournisseur sur cet ordinateur ou mettez à jour vos connexions de modèles dans l’application Codewhale.",
"AccountModelsReady": "Connecté à {model} via votre compte Codewhale.",
"AccountModelsConnectFailed": "Impossible de connecter les modèles du compte : {error}",
"AccountModelsCancelled": "Configuration du modèle annulée. Votre modèle actuel reste inchangé.",
"AccountModelsLoadFailed": "Impossible de vérifier les modèles du compte. Réessayez ou connectez un fournisseur sur cet ordinateur."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "बैकग्राउंड Bash जॉब देखें और नियंत्रित करें",
"CmdLinksDescription": "Codewhale, समुदाय और प्रोवाइडर लिंक दिखाएँ",
"CmdLoadDescription": "फ़ाइल से सत्र लोड करें",
"CmdLogoutDescription": "API कुंजी साफ़ करें और सेटअप पर लौटें",
"CmdLogoutDescription": "Codewhale से साइन आउट करें",
"CmdMcpDescription": "MCP सर्वर खोलें या सँभालें; init जोड़ता है, doctor जाँचता है",
"McpReloadAlreadyRunning": "MCP पुनः लोड पहले से चल रहा है; स्थिति पट्टी इसे ट्रैक करती है।",
"McpRecommendedUnknownId": "अज्ञात सुझाई गई MCP ID। चुनी हुई सूची देखने के लिए {recommendations_command} चलाएँ।",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "थीम बदलें या थीम चयनकर्ता खोलें",
"CmdProviderDescription": "सक्रिय प्रोवाइडर और/या मॉडल बदलें",
"CmdAuthDescription": "प्रोवाइडर प्रमाणीकरण प्रवाह प्रबंधित करें",
"CmdLoginDescription": "Codewhale में साइन इन करें या प्रदाता कुंजी सहेजें",
"CmdLoginDescription": "Codewhale में साइन इन करें",
"CmdQueueDescription": "कतारबद्ध संदेश देखें या संपादित करें",
"CmdQueueUsage": "उपयोग: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "पिछले सत्र के कतार के {count} संदेश बहाल किए गए। ये अपने आप नहीं भेजे जाते: /queue send <n> एक भेजता है, /queue clear इन्हें हटाता है।",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "टर्न का हस्तांतरण",
"PagerCopyAnswerLabel": "उत्तर",
"PagerSearchMatch": "मिलान {current}/{total} (n/N)",
"ToolOutputShortened": "आउटपुट छोटा किया गया · {key} से विवरण खोलें"
"ToolOutputShortened": "आउटपुट छोटा किया गया · {key} से विवरण खोलें",
"AccountLoginTitle": "Codewhale में साइन इन करें",
"AccountLoginStarting": "साइन इन शुरू हो रहा है…",
"AccountLoginWaiting": "अपने ब्राउज़र में साइन इन पूरा करें। आप यह लिंक किसी दूसरे डिवाइस पर भी खोल सकते हैं।",
"AccountLoginCode": "कोड: {code}",
"AccountLoginOpenBrowser": "ब्राउज़र खोलें",
"AccountLoginCopyLink": "लिंक कॉपी करें",
"AccountLoginLink": "साइन इन लिंक",
"AccountLoginSuccess": "Codewhale में साइन इन हो गया। आपका चुना हुआ मॉडल नहीं बदला है।",
"AccountLoginNeedsModel": "Codewhale में साइन इन हो गया। शुरू करने के लिए मॉडल कनेक्ट करें।",
"AccountLoginCancelled": "साइन इन रद्द किया गया।",
"AccountLoginExpired": "साइन इन का समय समाप्त हो गया। फिर से कोशिश करने के लिए {command} इस्तेमाल करें।",
"AccountLoginFailed": "साइन इन नहीं हो सका। फिर से कोशिश करने के लिए {command} इस्तेमाल करें।",
"AccountLogoutTitle": "Codewhale से साइन आउट करें",
"AccountLogoutStarting": "Codewhale से साइन आउट हो रहा है…",
"AccountLogoutSuccess": "Codewhale से साइन आउट हो गया। स्थानीय मॉडल और बातचीत नहीं बदले हैं।",
"AccountLogoutLocal": "इस कंप्यूटर पर साइन आउट हो गया। सर्वर पर सत्र रद्द होने की पुष्टि नहीं हो सकी।",
"AccountLogoutFailed": "साइन आउट नहीं हो सका। फिर से कोशिश करने के लिए {command} इस्तेमाल करें।",
"AccountLogoutCancelled": "साइन आउट रद्द किया गया।",
"ConnectionSetupTitle": "मॉडल कनेक्ट करें",
"ConnectionSetupAccount": "Codewhale के साथ जारी रखें",
"ConnectionSetupAccountDetail": "साइन इन करके अपने खाते में उपलब्ध मॉडल देखें।",
"ConnectionSetupLocal": "इस कंप्यूटर पर कनेक्ट करें",
"ConnectionSetupLocalDetail": "प्रदाता खाता, API कुंजी या स्थानीय मॉडल इस्तेमाल करें।",
"AccountModelsTitle": "खाते का मॉडल चुनें",
"AccountModelsLoading": "खाते के मॉडल की पहुँच जाँची जा रही है…",
"AccountModelsUnavailable": "यहाँ खाते का कोई उपयोग योग्य मॉडल उपलब्ध नहीं है। इस कंप्यूटर पर प्रदाता कनेक्ट करें या Codewhale ऐप में अपने मॉडल कनेक्शन अपडेट करें।",
"AccountModelsReady": "आपके Codewhale खाते से {model} कनेक्ट हो गया।",
"AccountModelsConnectFailed": "खाते के मॉडल कनेक्ट नहीं हो सके: {error}",
"AccountModelsCancelled": "मॉडल सेटअप रद्द किया गया। आपका मौजूदा मॉडल नहीं बदला है।",
"AccountModelsLoadFailed": "खाते के मॉडल जाँचे नहीं जा सके। फिर से कोशिश करें या इस कंप्यूटर पर प्रदाता कनेक्ट करें।"
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Periksa dan kendalikan job Bash latar belakang",
"CmdLinksDescription": "Tampilkan tautan Codewhale, komunitas, dan penyedia",
"CmdLoadDescription": "Muat sesi dari file",
"CmdLogoutDescription": "Hapus kunci API dan kembali ke penyiapan",
"CmdLogoutDescription": "Keluar dari Codewhale",
"CmdMcpDescription": "Buka atau kelola server MCP; init menambah, doctor memeriksa",
"McpReloadAlreadyRunning": "Pemuatan ulang MCP sedang berjalan; bilah status melacaknya.",
"McpRecommendedUnknownId": "ID MCP rekomendasi tidak dikenal. Jalankan {recommendations_command} untuk memeriksa daftar pilihan.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Ganti tema atau buka pemilih tema",
"CmdProviderDescription": "Ganti penyedia dan/atau model aktif",
"CmdAuthDescription": "Kelola alur autentikasi penyedia",
"CmdLoginDescription": "Masuk ke Codewhale atau simpan kunci penyedia",
"CmdLoginDescription": "Masuk ke Codewhale",
"CmdQueueDescription": "Lihat atau edit pesan dalam antrean",
"CmdQueueUsage": "Penggunaan: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "{count} pesan dalam antrean dari sesi sebelumnya dipulihkan. Pesan tidak dikirim otomatis: /queue send <n> mengirim satu, /queue clear membuangnya.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Serah terima giliran",
"PagerCopyAnswerLabel": "Jawaban",
"PagerSearchMatch": "kecocokan {current}/{total} (n/N)",
"ToolOutputShortened": "Keluaran dipersingkat · {key} membuka detail"
"ToolOutputShortened": "Keluaran dipersingkat · {key} membuka detail",
"AccountLoginTitle": "Masuk ke Codewhale",
"AccountLoginStarting": "Memulai proses masuk…",
"AccountLoginWaiting": "Selesaikan proses masuk di browser. Anda juga dapat membuka tautan di perangkat lain.",
"AccountLoginCode": "Kode: {code}",
"AccountLoginOpenBrowser": "Buka browser",
"AccountLoginCopyLink": "Salin tautan",
"AccountLoginLink": "Tautan masuk",
"AccountLoginSuccess": "Berhasil masuk ke Codewhale. Model yang dipilih tetap sama.",
"AccountLoginNeedsModel": "Berhasil masuk ke Codewhale. Hubungkan model untuk memulai.",
"AccountLoginCancelled": "Proses masuk dibatalkan.",
"AccountLoginExpired": "Waktu masuk habis. Gunakan {command} untuk mencoba lagi.",
"AccountLoginFailed": "Tidak dapat masuk. Gunakan {command} untuk mencoba lagi.",
"AccountLogoutTitle": "Keluar dari Codewhale",
"AccountLogoutStarting": "Keluar dari Codewhale…",
"AccountLogoutSuccess": "Berhasil keluar dari Codewhale. Model dan percakapan lokal tetap sama.",
"AccountLogoutLocal": "Berhasil keluar di komputer ini. Pencabutan sesi di server belum dapat dikonfirmasi.",
"AccountLogoutFailed": "Tidak dapat keluar. Gunakan {command} untuk mencoba lagi.",
"AccountLogoutCancelled": "Proses keluar dibatalkan.",
"ConnectionSetupTitle": "Hubungkan model",
"ConnectionSetupAccount": "Lanjutkan dengan Codewhale",
"ConnectionSetupAccountDetail": "Masuk dan periksa model yang tersedia di akun Anda.",
"ConnectionSetupLocal": "Hubungkan di komputer ini",
"ConnectionSetupLocalDetail": "Gunakan akun penyedia, kunci API, atau model lokal.",
"AccountModelsTitle": "Pilih model akun",
"AccountModelsLoading": "Memeriksa akses model akun…",
"AccountModelsUnavailable": "Tidak ada model akun yang dapat digunakan di sini. Hubungkan penyedia di komputer ini atau perbarui koneksi model Anda di aplikasi Codewhale.",
"AccountModelsReady": "Terhubung ke {model} melalui akun Codewhale Anda.",
"AccountModelsConnectFailed": "Tidak dapat menghubungkan model akun: {error}",
"AccountModelsCancelled": "Penyiapan model dibatalkan. Model Anda saat ini tetap sama.",
"AccountModelsLoadFailed": "Tidak dapat memeriksa model akun. Coba lagi atau hubungkan penyedia di komputer ini."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "バックグラウンドのシェルジョブを確認・制御",
"CmdLinksDescription": "Codewhale、コミュニティ、プロバイダーのリンクを表示",
"CmdLoadDescription": "ファイルからセッションを読み込み",
"CmdLogoutDescription": "API キーを消去してセットアップに戻る",
"CmdLogoutDescription": "Codewhale からサインアウト",
"CmdMcpDescription": "MCP サーバを開く・管理する — init で追加、doctor で点検",
"McpReloadAlreadyRunning": "MCP の再読み込みは実行中です。ステータスバーが進行状況を示します。",
"McpRecommendedUnknownId": "推奨 MCP ID が不明です。{recommendations_command} で精選リストを確認してください。",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "テーマを切り替え(ダーク/ライト/グレースケール/システム)",
"CmdProviderDescription": "プロバイダまたはモデルを切り替え",
"CmdAuthDescription": "プロバイダー認証フローを管理",
"CmdLoginDescription": "Codewhaleにサインインするか、プロバイダーキーを保存",
"CmdLoginDescription": "Codewhale にサインイン",
"CmdQueueDescription": "キューされたメッセージを確認・編集",
"CmdQueueUsage": "使用方法: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "前のセッションのキューのメッセージ {count} 件を復元しました。自動では送信されません。/queue send <n> で 1 件送信、/queue clear で破棄します。",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "ターンの引き継ぎ",
"PagerCopyAnswerLabel": "回答",
"PagerSearchMatch": "一致 {current}/{total}(n/N)",
"ToolOutputShortened": "出力を短縮しました · {key} で詳細を開く"
"ToolOutputShortened": "出力を短縮しました · {key} で詳細を開く",
"AccountLoginTitle": "Codewhale にサインイン",
"AccountLoginStarting": "サインインを開始しています…",
"AccountLoginWaiting": "ブラウザーでサインインを完了してください。別の端末でリンクを開くこともできます。",
"AccountLoginCode": "コード:{code}",
"AccountLoginOpenBrowser": "ブラウザーを開く",
"AccountLoginCopyLink": "リンクをコピー",
"AccountLoginLink": "サインイン用リンク",
"AccountLoginSuccess": "Codewhale にサインインしました。選択中のモデルは変わりません。",
"AccountLoginNeedsModel": "Codewhale にサインインしました。モデルを接続して始めましょう。",
"AccountLoginCancelled": "サインインをキャンセルしました。",
"AccountLoginExpired": "サインインがタイムアウトしました。{command} で再試行してください。",
"AccountLoginFailed": "サインインできませんでした。{command} で再試行してください。",
"AccountLogoutTitle": "Codewhale からサインアウト",
"AccountLogoutStarting": "Codewhale からサインアウトしています…",
"AccountLogoutSuccess": "Codewhale からサインアウトしました。ローカルのモデルと会話は変わりません。",
"AccountLogoutLocal": "このコンピューターからサインアウトしました。サーバー側のセッション失効は確認できませんでした。",
"AccountLogoutFailed": "サインアウトできませんでした。{command} で再試行してください。",
"AccountLogoutCancelled": "サインアウトをキャンセルしました。",
"ConnectionSetupTitle": "モデルを接続",
"ConnectionSetupAccount": "Codewhale で続ける",
"ConnectionSetupAccountDetail": "サインインして、アカウントで利用できるモデルを確認します。",
"ConnectionSetupLocal": "このコンピューターで接続",
"ConnectionSetupLocalDetail": "プロバイダーのアカウント、API キー、またはローカルモデルを使います。",
"AccountModelsTitle": "アカウントのモデルを選択",
"AccountModelsLoading": "アカウントのモデルアクセスを確認しています…",
"AccountModelsUnavailable": "ここでは利用できるアカウントモデルがありません。このコンピューターでプロバイダーを接続するか、Codewhale アプリでモデル接続を更新してください。",
"AccountModelsReady": "Codewhale アカウント経由で {model} に接続しました。",
"AccountModelsConnectFailed": "アカウントモデルに接続できませんでした:{error}",
"AccountModelsCancelled": "モデル設定をキャンセルしました。現在のモデルは変わりません。",
"AccountModelsLoadFailed": "アカウントモデルを確認できませんでした。再試行するか、このコンピューターでプロバイダーを接続してください。"
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "백그라운드 Bash 작업을 확인하고 제어합니다",
"CmdLinksDescription": "Codewhale, 커뮤니티 및 프로바이더 링크를 표시합니다",
"CmdLoadDescription": "파일에서 세션을 불러옵니다",
"CmdLogoutDescription": "API 키를 지우고 설정 화면으로 돌아갑니다",
"CmdLogoutDescription": "Codewhale 로그아웃",
"CmdMcpDescription": "MCP 서버를 열거나 관리합니다 — init 은 추가, doctor 는 점검",
"McpReloadAlreadyRunning": "MCP 다시 불러오기가 이미 실행 중입니다. 상태 표시줄에서 확인하세요.",
"McpRecommendedUnknownId": "알 수 없는 권장 MCP ID입니다. {recommendations_command} 명령으로 선별 목록을 확인하세요.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "테마를 전환하거나 테마 선택 화면을 엽니다",
"CmdProviderDescription": "현재 프로바이더 및/또는 모델을 전환합니다",
"CmdAuthDescription": "프로바이더 인증 흐름을 관리합니다",
"CmdLoginDescription": "Codewhale에 로그인하거나 프로바이더 키를 저장합니다",
"CmdLoginDescription": "Codewhale 로그인",
"CmdQueueDescription": "대기 중인 메시지를 확인하거나 편집합니다",
"CmdQueueUsage": "사용법: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "이전 세션의 대기 중인 메시지 {count}개를 복원했습니다. 자동으로 전송되지 않습니다. /queue send <n>으로 하나를 보내고 /queue clear로 삭제합니다.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "턴 인계 내용",
"PagerCopyAnswerLabel": "답변",
"PagerSearchMatch": "일치 {current}/{total} (n/N)",
"ToolOutputShortened": "출력이 줄어듦 · {key}로 세부 정보 열기"
"ToolOutputShortened": "출력이 줄어듦 · {key}로 세부 정보 열기",
"AccountLoginTitle": "Codewhale 로그인",
"AccountLoginStarting": "로그인을 시작하는 중…",
"AccountLoginWaiting": "브라우저에서 로그인을 완료하세요. 다른 기기에서 링크를 열어도 됩니다.",
"AccountLoginCode": "코드: {code}",
"AccountLoginOpenBrowser": "브라우저 열기",
"AccountLoginCopyLink": "링크 복사",
"AccountLoginLink": "로그인 링크",
"AccountLoginSuccess": "Codewhale에 로그인했습니다. 선택한 모델은 그대로 유지됩니다.",
"AccountLoginNeedsModel": "Codewhale에 로그인했습니다. 모델을 연결하여 시작하세요.",
"AccountLoginCancelled": "로그인을 취소했습니다.",
"AccountLoginExpired": "로그인 시간이 초과되었습니다. {command}로 다시 시도하세요.",
"AccountLoginFailed": "로그인하지 못했습니다. {command}로 다시 시도하세요.",
"AccountLogoutTitle": "Codewhale 로그아웃",
"AccountLogoutStarting": "Codewhale에서 로그아웃하는 중…",
"AccountLogoutSuccess": "Codewhale에서 로그아웃했습니다. 로컬 모델과 대화는 그대로 유지됩니다.",
"AccountLogoutLocal": "이 컴퓨터에서 로그아웃했습니다. 서버의 세션 해제는 확인하지 못했습니다.",
"AccountLogoutFailed": "로그아웃하지 못했습니다. {command}로 다시 시도하세요.",
"AccountLogoutCancelled": "로그아웃을 취소했습니다.",
"ConnectionSetupTitle": "모델 연결",
"ConnectionSetupAccount": "Codewhale로 계속",
"ConnectionSetupAccountDetail": "로그인하여 계정에서 사용할 수 있는 모델을 확인합니다.",
"ConnectionSetupLocal": "이 컴퓨터에서 연결",
"ConnectionSetupLocalDetail": "공급자 계정, API 키 또는 로컬 모델을 사용합니다.",
"AccountModelsTitle": "계정 모델 선택",
"AccountModelsLoading": "계정 모델 접근 권한을 확인하는 중…",
"AccountModelsUnavailable": "여기에서 사용할 수 있는 계정 모델이 없습니다. 이 컴퓨터에서 공급자를 연결하거나 Codewhale 앱에서 모델 연결을 업데이트하세요.",
"AccountModelsReady": "Codewhale 계정으로 {model}에 연결했습니다.",
"AccountModelsConnectFailed": "계정 모델을 연결하지 못했습니다: {error}",
"AccountModelsCancelled": "모델 설정을 취소했습니다. 현재 모델은 그대로 유지됩니다.",
"AccountModelsLoadFailed": "계정 모델을 확인하지 못했습니다. 다시 시도하거나 이 컴퓨터에서 공급자를 연결하세요."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "Inspecionar e controlar jobs de shell em segundo plano",
"CmdLinksDescription": "Exibir links do Codewhale, da comunidade e dos provedores",
"CmdLoadDescription": "Carregar a sessão de um arquivo",
"CmdLogoutDescription": "Limpar a chave de API e voltar à configuração",
"CmdLogoutDescription": "Sair do Codewhale",
"CmdMcpDescription": "Gerenciar servidores MCP; init adiciona um, doctor verifica",
"McpReloadAlreadyRunning": "A recarga do MCP já está em andamento; a barra de status a acompanha.",
"McpRecommendedUnknownId": "ID de MCP recomendado desconhecido. Execute {recommendations_command} para conferir a lista selecionada.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "Alternar tema: escuro, claro, tons de cinza ou sistema",
"CmdProviderDescription": "Trocar o provedor ou o modelo",
"CmdAuthDescription": "Gerenciar fluxos de autenticação de provedores",
"CmdLoginDescription": "Entrar no Codewhale ou salvar chaves de provedor",
"CmdLoginDescription": "Entrar no Codewhale",
"CmdQueueDescription": "Ver ou editar mensagens enfileiradas",
"CmdQueueUsage": "Uso: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "{count} mensagem(ns) enfileirada(s) de uma sessão anterior restaurada(s). Elas não são enviadas automaticamente: /queue send <n> envia uma, /queue clear as descarta.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Passagem do turno",
"PagerCopyAnswerLabel": "Resposta",
"PagerSearchMatch": "ocorrência {current}/{total} (n/N)",
"ToolOutputShortened": "Saída encurtada · {key} abre os detalhes"
"ToolOutputShortened": "Saída encurtada · {key} abre os detalhes",
"AccountLoginTitle": "Entrar no Codewhale",
"AccountLoginStarting": "Iniciando login…",
"AccountLoginWaiting": "Conclua o login no navegador. Você também pode abrir o link em outro dispositivo.",
"AccountLoginCode": "Código: {code}",
"AccountLoginOpenBrowser": "Abrir navegador",
"AccountLoginCopyLink": "Copiar link",
"AccountLoginLink": "Link de login",
"AccountLoginSuccess": "Você entrou no Codewhale. O modelo selecionado foi mantido.",
"AccountLoginNeedsModel": "Você entrou no Codewhale. Conecte um modelo para começar.",
"AccountLoginCancelled": "Login cancelado.",
"AccountLoginExpired": "O login expirou. Use {command} para tentar novamente.",
"AccountLoginFailed": "Não foi possível entrar. Use {command} para tentar novamente.",
"AccountLogoutTitle": "Sair do Codewhale",
"AccountLogoutStarting": "Saindo do Codewhale…",
"AccountLogoutSuccess": "Você saiu do Codewhale. Os modelos e conversas locais foram mantidos.",
"AccountLogoutLocal": "Você saiu neste computador. Não foi possível confirmar a revogação no servidor.",
"AccountLogoutFailed": "Não foi possível sair. Use {command} para tentar novamente.",
"AccountLogoutCancelled": "Saída cancelada.",
"ConnectionSetupTitle": "Conectar um modelo",
"ConnectionSetupAccount": "Continuar com Codewhale",
"ConnectionSetupAccountDetail": "Entre e confira os modelos disponíveis na sua conta.",
"ConnectionSetupLocal": "Conectar neste computador",
"ConnectionSetupLocalDetail": "Use uma conta de provedor, chave de API ou modelo local.",
"AccountModelsTitle": "Escolher um modelo da conta",
"AccountModelsLoading": "Verificando o acesso aos modelos da conta…",
"AccountModelsUnavailable": "Não há modelos da conta disponíveis aqui. Conecte um provedor neste computador ou atualize suas conexões de modelos no aplicativo Codewhale.",
"AccountModelsReady": "Conectado a {model} pela sua conta Codewhale.",
"AccountModelsConnectFailed": "Não foi possível conectar os modelos da conta: {error}",
"AccountModelsCancelled": "Configuração do modelo cancelada. Seu modelo atual foi mantido.",
"AccountModelsLoadFailed": "Não foi possível verificar os modelos da conta. Tente novamente ou conecte um provedor neste computador."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Просмотр и управление фоновыми заданиями Bash",
"CmdLinksDescription": "Показать ссылки на Codewhale, сообщество и провайдеров",
"CmdLoadDescription": "Загрузить сессию из файла",
"CmdLogoutDescription": "Удалить API-ключ и вернуться к настройке",
"CmdLogoutDescription": "Выйти из Codewhale",
"CmdMcpDescription": "Управление серверами MCP; init добавляет, doctor проверяет",
"McpReloadAlreadyRunning": "Перезагрузка MCP уже выполняется; строка состояния показывает прогресс.",
"McpRecommendedUnknownId": "Неизвестный идентификатор рекомендованного MCP. Выполните {recommendations_command}, чтобы просмотреть отобранный список.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Сменить тему или открыть выбор темы",
"CmdProviderDescription": "Сменить активного провайдера и/или модель",
"CmdAuthDescription": "Управление способами аутентификации провайдеров",
"CmdLoginDescription": "Войти в Codewhale или сохранить ключи провайдера",
"CmdLoginDescription": "Войти в Codewhale",
"CmdQueueDescription": "Просмотр и редактирование сообщений в очереди",
"CmdQueueUsage": "Использование: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Восстановлено сообщений в очереди из прошлого сеанса: {count}. Они не отправляются автоматически: /queue send <n> отправит одно, /queue clear удалит их.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Передача хода",
"PagerCopyAnswerLabel": "Ответ",
"PagerSearchMatch": "совпадение {current}/{total} (n/N)",
"ToolOutputShortened": "Вывод сокращён · {key} открывает подробности"
"ToolOutputShortened": "Вывод сокращён · {key} открывает подробности",
"AccountLoginTitle": "Войти в Codewhale",
"AccountLoginStarting": "Начинаем вход…",
"AccountLoginWaiting": "Завершите вход в браузере. Ссылку также можно открыть на другом устройстве.",
"AccountLoginCode": "Код: {code}",
"AccountLoginOpenBrowser": "Открыть браузер",
"AccountLoginCopyLink": "Копировать ссылку",
"AccountLoginLink": "Ссылка для входа",
"AccountLoginSuccess": "Вы вошли в Codewhale. Выбранная модель не изменилась.",
"AccountLoginNeedsModel": "Вы вошли в Codewhale. Подключите модель, чтобы начать.",
"AccountLoginCancelled": "Вход отменён.",
"AccountLoginExpired": "Время входа истекло. Повторите попытку с помощью {command}.",
"AccountLoginFailed": "Не удалось войти. Повторите попытку с помощью {command}.",
"AccountLogoutTitle": "Выйти из Codewhale",
"AccountLogoutStarting": "Выход из Codewhale…",
"AccountLogoutSuccess": "Вы вышли из Codewhale. Локальные модели и переписки не изменились.",
"AccountLogoutLocal": "Вы вышли на этом компьютере. Отзыв сеанса на сервере не подтверждён.",
"AccountLogoutFailed": "Не удалось выйти. Повторите попытку с помощью {command}.",
"AccountLogoutCancelled": "Выход отменён.",
"ConnectionSetupTitle": "Подключить модель",
"ConnectionSetupAccount": "Продолжить с Codewhale",
"ConnectionSetupAccountDetail": "Войдите и проверьте доступные модели вашего аккаунта.",
"ConnectionSetupLocal": "Подключить на этом компьютере",
"ConnectionSetupLocalDetail": "Используйте аккаунт провайдера, API-ключ или локальную модель.",
"AccountModelsTitle": "Выбрать модель аккаунта",
"AccountModelsLoading": "Проверяем доступ к моделям аккаунта…",
"AccountModelsUnavailable": "Здесь нет доступных моделей аккаунта. Подключите провайдера на этом компьютере или обновите подключения моделей в приложении Codewhale.",
"AccountModelsReady": "Модель {model} подключена через ваш аккаунт Codewhale.",
"AccountModelsConnectFailed": "Не удалось подключить модели аккаунта: {error}",
"AccountModelsCancelled": "Настройка модели отменена. Текущая модель не изменилась.",
"AccountModelsLoadFailed": "Не удалось проверить модели аккаунта. Повторите попытку или подключите провайдера на этом компьютере."
}
+33 -3
View File
@@ -553,7 +553,7 @@
"CmdJobsDescription": "Переглянути й керувати фоновими завданнями Bash",
"CmdLinksDescription": "Показати посилання Codewhale, спільноти та провайдерів",
"CmdLoadDescription": "Завантажити сеанс з файлу",
"CmdLogoutDescription": "Очистити ключ API та повернутися до налаштування",
"CmdLogoutDescription": "Вийти з Codewhale",
"CmdMcpDescription": "Керування серверами MCP; init додає, doctor перевіряє",
"McpReloadAlreadyRunning": "Перезавантаження MCP уже триває; рядок стану показує поступ.",
"McpRecommendedUnknownId": "Невідомий ідентифікатор рекомендованого MCP. Виконайте {recommendations_command}, щоб переглянути відібраний список.",
@@ -681,7 +681,7 @@
"CmdThemeDescription": "Змінити тему або відкрити вибір теми",
"CmdProviderDescription": "Змінити активного провайдера та/або модель",
"CmdAuthDescription": "Керувати потоками автентифікації провайдерів",
"CmdLoginDescription": "Увійти в Codewhale або зберегти ключі провайдера",
"CmdLoginDescription": "Увійти в Codewhale",
"CmdQueueDescription": "Переглянути або редагувати повідомлення в черзі",
"CmdQueueUsage": "Використання: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Відновлено повідомлень у черзі з попереднього сеансу: {count}. Вони не надсилаються автоматично: /queue send <n> надішле одне, /queue clear видалить їх.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Передача ходу",
"PagerCopyAnswerLabel": "Відповідь",
"PagerSearchMatch": "збіг {current}/{total} (n/N)",
"ToolOutputShortened": "Вивід скорочено · {key} відкриває подробиці"
"ToolOutputShortened": "Вивід скорочено · {key} відкриває подробиці",
"AccountLoginTitle": "Увійти в Codewhale",
"AccountLoginStarting": "Починаємо вхід…",
"AccountLoginWaiting": "Завершіть вхід у браузері. Посилання також можна відкрити на іншому пристрої.",
"AccountLoginCode": "Код: {code}",
"AccountLoginOpenBrowser": "Відкрити браузер",
"AccountLoginCopyLink": "Копіювати посилання",
"AccountLoginLink": "Посилання для входу",
"AccountLoginSuccess": "Ви увійшли в Codewhale. Вибрана модель не змінилася.",
"AccountLoginNeedsModel": "Ви увійшли в Codewhale. Підключіть модель, щоб почати.",
"AccountLoginCancelled": "Вхід скасовано.",
"AccountLoginExpired": "Час входу минув. Повторіть спробу за допомогою {command}.",
"AccountLoginFailed": "Не вдалося увійти. Повторіть спробу за допомогою {command}.",
"AccountLogoutTitle": "Вийти з Codewhale",
"AccountLogoutStarting": "Вихід із Codewhale…",
"AccountLogoutSuccess": "Ви вийшли з Codewhale. Локальні моделі та розмови не змінилися.",
"AccountLogoutLocal": "Ви вийшли на цьому комп’ютері. Відкликання сеансу на сервері не підтверджено.",
"AccountLogoutFailed": "Не вдалося вийти. Повторіть спробу за допомогою {command}.",
"AccountLogoutCancelled": "Вихід скасовано.",
"ConnectionSetupTitle": "Підключити модель",
"ConnectionSetupAccount": "Продовжити з Codewhale",
"ConnectionSetupAccountDetail": "Увійдіть і перевірте доступні моделі вашого облікового запису.",
"ConnectionSetupLocal": "Підключити на цьому комп’ютері",
"ConnectionSetupLocalDetail": "Використайте обліковий запис провайдера, API-ключ або локальну модель.",
"AccountModelsTitle": "Вибрати модель облікового запису",
"AccountModelsLoading": "Перевіряємо доступ до моделей облікового запису…",
"AccountModelsUnavailable": "Тут немає доступних моделей облікового запису. Підключіть провайдера на цьому комп’ютері або оновіть підключення моделей у застосунку Codewhale.",
"AccountModelsReady": "Модель {model} підключено через ваш обліковий запис Codewhale.",
"AccountModelsConnectFailed": "Не вдалося підключити моделі облікового запису: {error}",
"AccountModelsCancelled": "Налаштування моделі скасовано. Поточна модель не змінилася.",
"AccountModelsLoadFailed": "Не вдалося перевірити моделі облікового запису. Повторіть спробу або підключіть провайдера на цьому комп’ютері."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "Kiểm tra và kiểm soát các lệnh chạy ngầm",
"CmdLinksDescription": "Hiển thị liên kết Codewhale, cộng đồng và nhà cung cấp",
"CmdLoadDescription": "Tải phiên làm việc từ tệp",
"CmdLogoutDescription": "Xóa khóa API và quay lại bước thiết lập",
"CmdLogoutDescription": "Đăng xuất Codewhale",
"CmdMcpDescription": "Mở hoặc quản lý máy chủ MCP; init thêm, doctor kiểm tra",
"McpReloadAlreadyRunning": "MCP đang tải lại; thanh trạng thái theo dõi tiến trình.",
"McpRecommendedUnknownId": "ID MCP được đề xuất không xác định. Chạy {recommendations_command} để xem danh sách tuyển chọn.",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "Chuyển đổi giao diện hoặc mở bảng chọn giao diện",
"CmdProviderDescription": "Chuyển nhà cung cấp hoặc mô hình",
"CmdAuthDescription": "Quản lý quy trình xác thực nhà cung cấp",
"CmdLoginDescription": "Đăng nhập Codewhale hoặc lưu khóa nhà cung cấp",
"CmdLoginDescription": "Đăng nhập Codewhale",
"CmdQueueDescription": "Xem hoặc chỉnh sửa các tin nhắn đang chờ xử lý",
"CmdQueueUsage": "Cách dùng: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "Đã khôi phục {count} tin nhắn đang chờ từ phiên trước. Chúng không được gửi tự động: /queue send <n> gửi một tin, /queue clear để hủy chúng.",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "Bàn giao lượt",
"PagerCopyAnswerLabel": "Câu trả lời",
"PagerSearchMatch": "kết quả {current}/{total} (n/N)",
"ToolOutputShortened": "Đầu ra đã rút gọn · {key} mở chi tiết"
"ToolOutputShortened": "Đầu ra đã rút gọn · {key} mở chi tiết",
"AccountLoginTitle": "Đăng nhập Codewhale",
"AccountLoginStarting": "Đang bắt đầu đăng nhập…",
"AccountLoginWaiting": "Hoàn tất đăng nhập trong trình duyệt. Bạn cũng có thể mở liên kết trên thiết bị khác.",
"AccountLoginCode": "Mã: {code}",
"AccountLoginOpenBrowser": "Mở trình duyệt",
"AccountLoginCopyLink": "Sao chép liên kết",
"AccountLoginLink": "Liên kết đăng nhập",
"AccountLoginSuccess": "Đã đăng nhập Codewhale. Mô hình đã chọn được giữ nguyên.",
"AccountLoginNeedsModel": "Đã đăng nhập Codewhale. Kết nối một mô hình để bắt đầu.",
"AccountLoginCancelled": "Đã hủy đăng nhập.",
"AccountLoginExpired": "Đăng nhập đã hết thời gian chờ. Dùng {command} để thử lại.",
"AccountLoginFailed": "Không thể đăng nhập. Dùng {command} để thử lại.",
"AccountLogoutTitle": "Đăng xuất Codewhale",
"AccountLogoutStarting": "Đang đăng xuất Codewhale…",
"AccountLogoutSuccess": "Đã đăng xuất Codewhale. Mô hình và hội thoại cục bộ được giữ nguyên.",
"AccountLogoutLocal": "Đã đăng xuất trên máy tính này. Chưa xác nhận được việc thu hồi phiên trên máy chủ.",
"AccountLogoutFailed": "Không thể đăng xuất. Dùng {command} để thử lại.",
"AccountLogoutCancelled": "Đã hủy đăng xuất.",
"ConnectionSetupTitle": "Kết nối mô hình",
"ConnectionSetupAccount": "Tiếp tục với Codewhale",
"ConnectionSetupAccountDetail": "Đăng nhập và xem các mô hình có sẵn trong tài khoản.",
"ConnectionSetupLocal": "Kết nối trên máy tính này",
"ConnectionSetupLocalDetail": "Dùng tài khoản nhà cung cấp, khóa API hoặc mô hình cục bộ.",
"AccountModelsTitle": "Chọn mô hình của tài khoản",
"AccountModelsLoading": "Đang kiểm tra quyền truy cập mô hình tài khoản…",
"AccountModelsUnavailable": "Không có mô hình tài khoản khả dụng tại đây. Hãy kết nối nhà cung cấp trên máy tính này hoặc cập nhật kết nối mô hình trong ứng dụng Codewhale.",
"AccountModelsReady": "Đã kết nối {model} qua tài khoản Codewhale của bạn.",
"AccountModelsConnectFailed": "Không thể kết nối mô hình tài khoản: {error}",
"AccountModelsCancelled": "Đã hủy thiết lập mô hình. Mô hình hiện tại được giữ nguyên.",
"AccountModelsLoadFailed": "Không thể kiểm tra mô hình tài khoản. Hãy thử lại hoặc kết nối nhà cung cấp trên máy tính này."
}
+33 -3
View File
@@ -556,7 +556,7 @@
"CmdJobsDescription": "查看并管理后台 shell 作业",
"CmdLinksDescription": "显示 Codewhale、社区与提供商链接",
"CmdLoadDescription": "从文件加载会话",
"CmdLogoutDescription": "清除 API 密钥并返回设置",
"CmdLogoutDescription": "退出 Codewhale",
"CmdMcpDescription": "打开或管理 MCP 服务器;init 添加服务器,doctor 检查服务器",
"McpReloadAlreadyRunning": "MCP 重新加载正在进行中,状态栏会显示进度。",
"McpRecommendedUnknownId": "未知的推荐 MCP ID。运行 {recommendations_command} 查看精选列表。",
@@ -684,7 +684,7 @@
"CmdThemeDescription": "切换主题或打开主题选择器",
"CmdProviderDescription": "切换提供商或模型",
"CmdAuthDescription": "管理提供商身份验证流程",
"CmdLoginDescription": "登录 Codewhale 或保存提供商密钥",
"CmdLoginDescription": "登录 Codewhale",
"CmdQueueDescription": "查看或编辑已排队的消息",
"CmdQueueUsage": "用法: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "已恢复上一会话中的 {count} 条排队消息。它们不会自动发送:/queue send <n> 发送其中一条,/queue clear 将其丢弃。",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "轮次交接摘要",
"PagerCopyAnswerLabel": "答案",
"PagerSearchMatch": "匹配 {current}/{total}(n/N)",
"ToolOutputShortened": "输出已缩短 · 按 {key} 打开详情"
"ToolOutputShortened": "输出已缩短 · 按 {key} 打开详情",
"AccountLoginTitle": "登录 Codewhale",
"AccountLoginStarting": "正在开始登录…",
"AccountLoginWaiting": "请在浏览器中完成登录,也可以在其他设备上打开此链接。",
"AccountLoginCode": "验证码:{code}",
"AccountLoginOpenBrowser": "打开浏览器",
"AccountLoginCopyLink": "复制链接",
"AccountLoginLink": "登录链接",
"AccountLoginSuccess": "已登录 Codewhale,所选模型保持不变。",
"AccountLoginNeedsModel": "已登录 Codewhale,请连接模型以开始使用。",
"AccountLoginCancelled": "已取消登录。",
"AccountLoginExpired": "登录超时,请使用 {command} 重试。",
"AccountLoginFailed": "无法登录,请使用 {command} 重试。",
"AccountLogoutTitle": "退出 Codewhale",
"AccountLogoutStarting": "正在退出 Codewhale…",
"AccountLogoutSuccess": "已退出 Codewhale,本地模型和对话保持不变。",
"AccountLogoutLocal": "已在此电脑上退出登录,无法确认服务器端的会话撤销。",
"AccountLogoutFailed": "无法退出登录,请使用 {command} 重试。",
"AccountLogoutCancelled": "已取消退出登录。",
"ConnectionSetupTitle": "连接模型",
"ConnectionSetupAccount": "使用 Codewhale 继续",
"ConnectionSetupAccountDetail": "登录并查看账户中可用的模型。",
"ConnectionSetupLocal": "在此电脑上连接",
"ConnectionSetupLocalDetail": "使用提供商账户、API 密钥或本地模型。",
"AccountModelsTitle": "选择账户模型",
"AccountModelsLoading": "正在检查账户模型访问权限…",
"AccountModelsUnavailable": "此处没有可用的账户模型。请在此电脑上连接提供商,或在 Codewhale 应用中更新模型连接。",
"AccountModelsReady": "已通过 Codewhale 账户连接 {model}。",
"AccountModelsConnectFailed": "无法连接账户模型:{error}",
"AccountModelsCancelled": "已取消模型设置,当前模型保持不变。",
"AccountModelsLoadFailed": "无法检查账户模型。请重试或在此电脑上连接提供商。"
}
+33 -3
View File
@@ -554,7 +554,7 @@
"CmdJobsDescription": "檢視並管理後台 shell 作業",
"CmdLinksDescription": "顯示 Codewhale、社群與供應商連結",
"CmdLoadDescription": "從檔案載入工作階段",
"CmdLogoutDescription": "清除 API 金鑰並返回設定",
"CmdLogoutDescription": "登出 Codewhale",
"CmdMcpDescription": "開啟或管理 MCP 伺服器;init 新增伺服器,doctor 檢查伺服器",
"McpReloadAlreadyRunning": "MCP 重新載入正在進行中,狀態列會顯示進度。",
"McpRecommendedUnknownId": "未知的建議 MCP ID。執行 {recommendations_command} 查看精選清單。",
@@ -682,7 +682,7 @@
"CmdThemeDescription": "切換主題或開啟主題選擇器",
"CmdProviderDescription": "切換供應商或模型",
"CmdAuthDescription": "管理供應商驗證流程",
"CmdLoginDescription": "登入 Codewhale 或儲存供應商金鑰",
"CmdLoginDescription": "登入 Codewhale",
"CmdQueueDescription": "檢視或編輯已排隊的訊息",
"CmdQueueUsage": "用法: /queue [list|send <n>|edit <n>|drop <n>|clear]",
"QueueRestoredFromPreviousSession": "已還原上一個工作階段中的 {count} 則排隊訊息。它們不會自動送出:/queue send <n> 送出其中一則,/queue clear 將其捨棄。",
@@ -2784,5 +2784,35 @@
"PagerCopyHandoffLabel": "回合交接摘要",
"PagerCopyAnswerLabel": "答案",
"PagerSearchMatch": "符合 {current}/{total}(n/N)",
"ToolOutputShortened": "輸出已縮短 · 按 {key} 開啟詳情"
"ToolOutputShortened": "輸出已縮短 · 按 {key} 開啟詳情",
"AccountLoginTitle": "登入 Codewhale",
"AccountLoginStarting": "正在開始登入…",
"AccountLoginWaiting": "請在瀏覽器中完成登入,也可以在其他裝置上開啟此連結。",
"AccountLoginCode": "驗證碼:{code}",
"AccountLoginOpenBrowser": "開啟瀏覽器",
"AccountLoginCopyLink": "複製連結",
"AccountLoginLink": "登入連結",
"AccountLoginSuccess": "已登入 Codewhale,所選模型保持不變。",
"AccountLoginNeedsModel": "已登入 Codewhale,請連接模型以開始使用。",
"AccountLoginCancelled": "已取消登入。",
"AccountLoginExpired": "登入逾時,請使用 {command} 重試。",
"AccountLoginFailed": "無法登入,請使用 {command} 重試。",
"AccountLogoutTitle": "登出 Codewhale",
"AccountLogoutStarting": "正在登出 Codewhale…",
"AccountLogoutSuccess": "已登出 Codewhale,本機模型和對話保持不變。",
"AccountLogoutLocal": "已在此電腦上登出,無法確認伺服器端的工作階段撤銷。",
"AccountLogoutFailed": "無法登出,請使用 {command} 重試。",
"AccountLogoutCancelled": "已取消登出。",
"ConnectionSetupTitle": "連接模型",
"ConnectionSetupAccount": "使用 Codewhale 繼續",
"ConnectionSetupAccountDetail": "登入並查看帳戶中可用的模型。",
"ConnectionSetupLocal": "在此電腦上連接",
"ConnectionSetupLocalDetail": "使用供應商帳戶、API 金鑰或本機模型。",
"AccountModelsTitle": "選擇帳戶模型",
"AccountModelsLoading": "正在檢查帳戶模型存取權限…",
"AccountModelsUnavailable": "此處沒有可用的帳戶模型。請在此電腦上連接供應商,或在 Codewhale 應用程式中更新模型連接。",
"AccountModelsReady": "已透過 Codewhale 帳戶連接 {model}。",
"AccountModelsConnectFailed": "無法連接帳戶模型:{error}",
"AccountModelsCancelled": "已取消模型設定,目前的模型保持不變。",
"AccountModelsLoadFailed": "無法檢查帳戶模型。請重試或在此電腦上連接供應商。"
}
+62
View File
@@ -120,6 +120,38 @@ impl Locale {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageId {
AccountModelsLoadFailed,
ConnectionSetupTitle,
ConnectionSetupAccount,
ConnectionSetupAccountDetail,
ConnectionSetupLocal,
ConnectionSetupLocalDetail,
AccountModelsTitle,
AccountModelsLoading,
AccountModelsUnavailable,
AccountModelsReady,
AccountModelsConnectFailed,
AccountModelsCancelled,
AccountLoginTitle,
AccountLoginStarting,
AccountLoginWaiting,
AccountLoginCode,
AccountLoginOpenBrowser,
AccountLoginCopyLink,
AccountLoginLink,
AccountLoginSuccess,
AccountLoginNeedsModel,
AccountLoginCancelled,
AccountLoginExpired,
AccountLoginFailed,
AccountLogoutTitle,
AccountLogoutStarting,
AccountLogoutSuccess,
AccountLogoutLocal,
AccountLogoutFailed,
AccountLogoutCancelled,
ReasoningOnlyRetryBody,
OutputLimitAnswerRetryBody,
AnswerRetryActionBare,
@@ -3030,6 +3062,36 @@ pub enum MessageId {
}
pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::AccountModelsLoadFailed,
MessageId::ConnectionSetupTitle,
MessageId::ConnectionSetupAccount,
MessageId::ConnectionSetupAccountDetail,
MessageId::ConnectionSetupLocal,
MessageId::ConnectionSetupLocalDetail,
MessageId::AccountModelsTitle,
MessageId::AccountModelsLoading,
MessageId::AccountModelsUnavailable,
MessageId::AccountModelsReady,
MessageId::AccountModelsConnectFailed,
MessageId::AccountModelsCancelled,
MessageId::AccountLoginTitle,
MessageId::AccountLoginStarting,
MessageId::AccountLoginWaiting,
MessageId::AccountLoginCode,
MessageId::AccountLoginOpenBrowser,
MessageId::AccountLoginCopyLink,
MessageId::AccountLoginLink,
MessageId::AccountLoginSuccess,
MessageId::AccountLoginNeedsModel,
MessageId::AccountLoginCancelled,
MessageId::AccountLoginExpired,
MessageId::AccountLoginFailed,
MessageId::AccountLogoutTitle,
MessageId::AccountLogoutStarting,
MessageId::AccountLogoutSuccess,
MessageId::AccountLogoutLocal,
MessageId::AccountLogoutFailed,
MessageId::AccountLogoutCancelled,
MessageId::ReasoningOnlyRetryBody,
MessageId::OutputLimitAnswerRetryBody,
MessageId::AnswerRetryActionBare,
+1 -1
View File
@@ -18,7 +18,7 @@ path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
regex = "1.11"
rusqlite = { version = "0.40.2", features = ["bundled"] }
rusqlite = { version = "0.41.1", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.11"
+909 -42
View File
@@ -1,39 +1,451 @@
use anyhow::Result;
use std::io::ErrorKind;
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
/// Redact the userinfo segment (`username[:password]@…` portion) from
/// a proxy URL so it can be safely included in `tracing::warn!` output
/// without leaking the
/// password into the on-disk log. URLs without userinfo are returned
/// unchanged. Garbage input (no `://` scheme separator) is also returned
/// unchanged — the malformed-URL warning path is the only caller, so an
/// unparseable input is already the failure case.
/// How long the probe waits between checks once one finishes.
const LOOPBACK_PROXY_PROBE_INTERVAL: Duration = Duration::from_secs(2);
/// Upper bound for one loopback TCP probe. Windows only reports a refused
/// loopback connection after roughly two seconds, so a shorter bound would
/// turn every closed port into an inconclusive timeout and the fallback would
/// never engage.
const LOOPBACK_PROXY_PROBE_TIMEOUT: Duration = Duration::from_secs(4);
/// Keeps reqwest's snapshot behaviour when set to `0`.
const LOOPBACK_PROXY_FALLBACK_ENV: &str = "CODEWHALE_LOCAL_PROXY_FALLBACK";
/// Redact userinfo and omit paths, queries, and fragments from proxy log labels.
fn redact_proxy_userinfo(proxy_url: &str) -> String {
let Some(scheme_end) = proxy_url.find("://") else {
return proxy_url.to_string();
return "<invalid proxy URL>".to_string();
};
let after_scheme = scheme_end + 3;
// The userinfo segment ends at the next `@`, but only if that `@`
// comes before the next `/`, `?`, or `#` (otherwise the `@` is in a
// path / query and the URL has no userinfo at all).
let rest = &proxy_url[after_scheme..];
let at_idx = rest.find('@');
let path_idx = rest.find(['/', '?', '#']);
let userinfo_end = match (at_idx, path_idx) {
(Some(a), Some(p)) if a < p => Some(a),
(Some(a), None) => Some(a),
_ => None,
};
if let Some(end) = userinfo_end {
let mut out = String::with_capacity(proxy_url.len());
out.push_str(&proxy_url[..after_scheme]);
out.push_str("***@");
out.push_str(&rest[end + 1..]);
out
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
if let Some((_, host)) = authority.rsplit_once('@') {
format!("{}***@{host}", &proxy_url[..after_scheme])
} else {
proxy_url.to_string()
format!("{}{authority}", &proxy_url[..after_scheme])
}
}
/// Install the loopback proxy fallback on a client builder.
///
/// `no_proxy()` clears every proxy registered before it, so it must come first:
/// afterwards the fallback route is the only proxy source, and a request the
/// callback declines cannot fall back to the dead address reqwest reads when the
/// client is built.
pub fn install_loopback_proxy(builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
install_proxy_route(builder, loopback_proxy_fallback())
}
/// `no_proxy()` clears every proxy registered before it, so the route must be
/// installed after it. Getting this backwards leaves a builder with no proxies
/// at all, which looks like the fix works until the proxy is up again.
fn install_proxy_route(
builder: reqwest::ClientBuilder,
proxy: Option<reqwest::Proxy>,
) -> reqwest::ClientBuilder {
let Some(proxy) = proxy else {
return builder;
};
builder.no_proxy().proxy(proxy)
}
/// Route through the ambient loopback proxy while it answers and go direct
/// while its port refuses connections.
///
/// A long-lived session builds its reqwest clients once, and reqwest snapshots
/// `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` when the client is built. A local
/// proxy process (Clash, v2rayN, sing-box) that closes after startup would then
/// keep every request dialing a dead loopback port until the process restarts —
/// including providers that need no proxy at all. Return a proxy that is
/// re-probed in the background and bypasses the proxy while it is down.
///
/// The proxy environment is read once, on the first client this process builds;
/// changing `HTTPS_PROXY` afterwards needs a restart. That matches how reqwest
/// already snapshots the environment at build time, and it keeps one probe
/// thread for the process instead of one per client.
///
/// Each configured proxy is watched on its own port, so a live `HTTP_PROXY` does
/// not keep a dead `HTTPS_PROXY` in the path.
///
/// Only a definite `ECONNREFUSED` moves routing to direct. A probe that times
/// out, hits an fd limit or cannot resolve says nothing about the proxy, so the
/// previous state stands: routing the session direct on that evidence would put
/// traffic on a network path the operator deliberately avoided.
///
/// Known limitations: only loopback proxies are watched, and reachability is a
/// TCP connect rather than a proxy handshake, so a process that accepts the
/// port without forwarding traffic still counts as up. An unreachable remote
/// proxy is a network outage to report, not a boundary to route around, so
/// those keep reqwest's own behaviour.
///
/// Set `CODEWHALE_LOCAL_PROXY_FALLBACK=0` to keep reqwest's snapshot behaviour.
///
/// Returns `None` when no ambient proxy is configured, when none of them is
/// loopback, when no configured proxy is parseable, or when the fallback is
/// disabled; callers then leave the client builder untouched. An unparseable
/// variable next to a usable one is ignored rather than fatal. `new()` also
/// declines a request whose CGI environment marks the variables as request data,
/// and declines any configuration `NO_PROXY` disables outright.
pub fn loopback_proxy_fallback() -> Option<reqwest::Proxy> {
let watch = ambient_loopback_proxy()?;
Some(reqwest::Proxy::custom(move |url| watch.route(url)))
}
/// One probe thread per process, shared by every client built afterwards, so a
/// client rebuilt on a route change does not add another thread or another
/// connect every two seconds. The environment is consequently read once.
fn ambient_loopback_proxy() -> Option<Arc<LoopbackProxy>> {
static WATCH: OnceLock<Option<Arc<LoopbackProxy>>> = OnceLock::new();
WATCH
.get_or_init(|| {
let watch = LoopbackProxy::from_environment()?;
spawn_probe(&watch);
Some(watch)
})
.clone()
}
/// Ambient proxy routing plus the reachability of every loopback proxy target
/// it can select.
struct LoopbackProxy {
matcher: hyper_util::client::proxy::matcher::Matcher,
all: Option<reqwest::Url>,
http: Option<reqwest::Url>,
https: Option<reqwest::Url>,
targets: Vec<LoopbackTarget>,
}
struct LoopbackTarget {
host: String,
port: u16,
/// The configured URL that selects this target, kept for the one warning
/// that names the proxy that stopped answering.
configured: String,
/// Starts false: no probe has said this port is gone yet, so requests keep
/// reqwest's behaviour. Only a definite refusal sets it.
refused: AtomicBool,
}
enum Reachability {
/// At least one address accepted the connection.
Open,
/// Every address actively refused it: nothing is listening.
Refused,
/// Timeout, fd or resolver trouble. Not evidence about the proxy.
Unknown,
}
impl LoopbackProxy {
fn from_environment() -> Option<Arc<Self>> {
// A CGI request's HTTP_PROXY is request data, not proxy configuration.
if std::env::var_os("REQUEST_METHOD").is_some() {
return None;
}
if std::env::var(LOOPBACK_PROXY_FALLBACK_ENV).is_ok_and(|value| value.trim() == "0") {
return None;
}
let all = ambient_proxy_url(&["ALL_PROXY", "all_proxy"]);
let http = ambient_proxy_url(&["HTTP_PROXY", "http_proxy"]);
let https = ambient_proxy_url(&["HTTPS_PROXY", "https_proxy"]);
Self::new(all, http, https, &read_no_proxy())
}
/// Build the router for an explicit proxy configuration. `None` when there
/// is nothing to watch, when none of the configured proxies is loopback,
/// when a loopback proxy's port cannot be determined, or when `NO_PROXY`
/// disables proxying altogether. A remote proxy next to a loopback one is
/// simply never watched.
fn new(
all: Option<reqwest::Url>,
http: Option<reqwest::Url>,
https: Option<reqwest::Url>,
no_proxy: &str,
) -> Option<Arc<Self>> {
let configured: Vec<&reqwest::Url> = [all.as_ref(), http.as_ref(), https.as_ref()]
.into_iter()
.flatten()
.collect();
if configured.is_empty() {
return None;
}
// Watch only the loopback proxies. A remote proxy in the same
// configuration keeps reqwest's behaviour: it is never marked down, so
// traffic to it never diverts, and a local proxy next to it is still
// watched on its own.
let mut targets: Vec<LoopbackTarget> = Vec::new();
for url in configured {
let Some((host, port)) = loopback_target(url) else {
// A loopback proxy whose port cannot be determined is a
// misconfiguration, not a reason to quietly stop watching it.
if is_loopback_url(url) {
tracing::warn!(
target: "net",
proxy = %redact_proxy_userinfo(url.as_str()),
"cannot determine this local proxy's port; leaving proxy routing to reqwest"
);
return None;
}
continue;
};
let known = targets
.iter()
.any(|target| target.host == host && target.port == port);
if !known {
targets.push(LoopbackTarget {
host,
port,
configured: url.to_string(),
refused: AtomicBool::new(false),
});
}
}
if targets.is_empty() {
return None;
}
if no_proxy_disables_all(no_proxy) {
return None;
}
let mut builder = hyper_util::client::proxy::matcher::Matcher::builder();
if let Some(url) = &all {
builder = builder.all(url.as_str());
}
if let Some(url) = &http {
builder = builder.http(url.as_str());
}
if let Some(url) = &https {
builder = builder.https(url.as_str());
}
let matcher = builder.no(normalize_no_proxy(no_proxy)).build();
Some(Arc::new(Self {
matcher,
all,
http,
https,
targets,
}))
}
fn route(&self, url: &reqwest::Url) -> Option<reqwest::Url> {
// Only ask the matcher whether this destination is proxied (scheme plus
// NO_PROXY). The route itself comes from the configured URL so proxy
// credentials survive; the matcher strips userinfo into an auth header.
let port = url
.port()
.map(|port| format!(":{port}"))
.unwrap_or_default();
let destination: http::Uri = format!("{}://{}{}", url.scheme(), url.host_str()?, port)
.parse()
.ok()?;
self.matcher.intercept(&destination)?;
let source = match url.scheme() {
"http" => self.http.as_ref().or(self.all.as_ref()),
"https" => self.https.as_ref().or(self.all.as_ref()),
_ => None,
}?;
// Reachability is per proxy, not per watcher: with HTTP_PROXY up and
// HTTPS_PROXY down, only the https route may go direct. Every watched
// target is registered from the same URL the matcher selects, so an
// unregistered source is a remote proxy, which is never watched.
match self.target_for(source) {
Some(target) if target.refused.load(Ordering::Relaxed) => return None,
Some(_) | None => {}
}
let mut target = source.clone();
// Remote DNS stays with the proxy, exactly as configured_mcp_proxy
// keeps it for the same proxy URL.
let scheme = match target.scheme() {
"socks5" => Some("socks5h"),
"socks4" => Some("socks4a"),
_ => None,
};
if let Some(scheme) = scheme {
target.set_scheme(scheme).ok()?;
}
Some(target)
}
/// The watched target behind a configured proxy URL, matched on the same
/// host and port the probe uses.
fn target_for(&self, source: &reqwest::Url) -> Option<&LoopbackTarget> {
let (host, port) = loopback_target(source)?;
self.targets
.iter()
.find(|target| target.host == host && target.port == port)
}
fn refresh(&self) {
for target in &self.targets {
let reachability = probe_target(target);
let was_refused = target.refused.load(Ordering::Relaxed);
let refused = match reachability {
// A timeout or an fd limit says nothing about the proxy, so the
// last definite answer stands.
Reachability::Unknown => was_refused,
Reachability::Open => false,
Reachability::Refused => true,
};
if was_refused == refused {
continue;
}
target.refused.store(refused, Ordering::Relaxed);
if refused {
tracing::warn!(
target: "net",
proxy = %redact_proxy_userinfo(&target.configured),
"local proxy refused connections; requests go direct until it returns"
);
} else {
tracing::info!(
target: "net",
proxy = %redact_proxy_userinfo(&target.configured),
"local proxy is reachable again; resuming proxy routing"
);
}
}
}
}
fn spawn_probe(watch: &Arc<LoopbackProxy>) {
// A Weak probe handle keeps the thread from owning the router. The process
// keeps one watch in a static, so this normally never expires; a probe that
// stops instead of pinning a watcher is the safer default if that changes.
let weak = Arc::downgrade(watch);
let spawned = std::thread::Builder::new()
.name("codewhale-proxy-probe".to_string())
.spawn(move || {
loop {
// Probe first, then rest: one check may take seconds, and
// sleeping between checks would put two probes back to back.
let Some(watch) = weak.upgrade() else {
return;
};
watch.refresh();
drop(watch);
std::thread::sleep(LOOPBACK_PROXY_PROBE_INTERVAL);
}
});
if let Err(error) = spawned {
tracing::warn!(
target: "net",
%error,
"could not start the local proxy probe thread"
);
}
}
fn probe_target(target: &LoopbackTarget) -> Reachability {
let Ok(addresses) = (target.host.as_str(), target.port).to_socket_addrs() else {
return Reachability::Unknown;
};
// Only a refusal from every resolved address is evidence the port is gone.
// `localhost` resolving to `::1` (refused) and `127.0.0.1` (dropped) is the
// shape of "cannot tell", not the shape of "nothing is listening".
let mut resolved = 0_u32;
let mut refusals = 0_u32;
for address in addresses {
resolved += 1;
match TcpStream::connect_timeout(&address, LOOPBACK_PROXY_PROBE_TIMEOUT) {
Ok(_) => return Reachability::Open,
Err(error) if error.kind() == ErrorKind::ConnectionRefused => refusals += 1,
Err(_) => {}
}
}
if resolved > 0 && refusals == resolved {
Reachability::Refused
} else {
Reachability::Unknown
}
}
/// `NO_PROXY` entries separated by whitespace mean the same as comma-separated
/// ones, and every Rust proxy path in this process reads the list this way so a
/// value cannot mean "direct" to one subsystem and "proxied" to another. Child
/// processes still inherit the variable verbatim.
fn normalize_no_proxy(raw: &str) -> String {
raw.replace(char::is_whitespace, ",")
}
fn no_proxy_disables_all(no_proxy: &str) -> bool {
normalize_no_proxy(no_proxy)
.split(',')
.any(|entry| entry == "*")
}
fn read_no_proxy() -> String {
std::env::var("NO_PROXY")
.or_else(|_| std::env::var("no_proxy"))
.unwrap_or_default()
}
fn ambient_proxy_url(names: &[&str]) -> Option<reqwest::Url> {
let raw = names.iter().find_map(|name| {
std::env::var(name)
.ok()
.filter(|value| !value.trim().is_empty())
})?;
reqwest::Url::parse(&raw)
.ok()
.filter(|url| url.has_host())
.or_else(|| {
reqwest::Url::parse(&format!("http://{raw}"))
.ok()
.filter(|url| url.has_host())
})
}
/// The bare loopback address behind a proxy URL's host, or `None` when it is
/// not loopback. `Url::host_str` returns an IPv6 literal with its brackets
/// (`[::1]`), so the brackets come off before the address is parsed and before
/// the host is stored for a probe.
fn loopback_host(url: &reqwest::Url) -> Option<String> {
let host = url.host_str()?;
let unbracketed = host
.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or(host);
if unbracketed.eq_ignore_ascii_case("localhost") {
return Some(unbracketed.to_string());
}
unbracketed
.parse::<std::net::IpAddr>()
.ok()
.filter(is_loopback_address)
.map(|address| address.to_string())
}
/// `Ipv6Addr::is_loopback` only accepts `::1`, so an IPv4-mapped local proxy
/// (`[::ffff:127.0.0.1]`) is unwrapped before the check. It is the same host
/// with a different spelling, and a proxy bound that way is still local.
fn is_loopback_address(address: &std::net::IpAddr) -> bool {
match address {
std::net::IpAddr::V4(v4) => v4.is_loopback(),
std::net::IpAddr::V6(v6) => {
v6.is_loopback() || v6.to_ipv4_mapped().is_some_and(|v4| v4.is_loopback())
}
}
}
fn is_loopback_url(url: &reqwest::Url) -> bool {
loopback_host(url).is_some()
}
fn loopback_target(url: &reqwest::Url) -> Option<(String, u16)> {
let host = loopback_host(url)?;
// reqwest defaults a portless socks proxy to 1080; matching that here keeps
// `ALL_PROXY=socks5://127.0.0.1` watched instead of silently unwatched.
let port = url.port_or_known_default().or(match url.scheme() {
"socks4" | "socks4a" | "socks5" | "socks5h" => Some(1080),
_ => None,
})?;
Some((host, port))
}
/// Resolve the operator's proxy route for this exact request using the same
/// matcher as reqwest. A NO_PROXY match returns None: direct requests must keep
/// their public DNS validation and pins. Model and reviewed-plugin requests
@@ -66,14 +478,15 @@ where
.filter(|url| url.has_host())
.or_else(|| reqwest::Url::parse(&format!("http://{proxy_url}")).ok());
let Some(normalized) = normalized else {
tracing::warn!(target: "mcp", proxy = %redact_proxy_userinfo(&proxy_url), "ignoring malformed HTTP(S)_PROXY URL");
tracing::warn!(target: "mcp", "ignoring malformed HTTP(S)_PROXY URL");
return Ok(None);
};
let no_proxy = read_environment("NO_PROXY")
.or_else(|_| read_environment("no_proxy"))
.unwrap_or_default()
.replace(char::is_whitespace, ",");
if no_proxy.split(',').any(|entry| entry == "*") {
let no_proxy = normalize_no_proxy(
&read_environment("NO_PROXY")
.or_else(|_| read_environment("no_proxy"))
.unwrap_or_default(),
);
if no_proxy_disables_all(&no_proxy) {
return Ok(None);
}
let matcher = hyper_util::client::proxy::matcher::Matcher::builder()
@@ -105,6 +518,465 @@ where
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Read, Write};
use std::net::TcpListener;
fn loopback_listener() -> TcpListener {
TcpListener::bind("127.0.0.1:0").expect("bind loopback proxy")
}
fn port_of(listener: &TcpListener) -> u16 {
listener.local_addr().expect("loopback address").port()
}
/// A port that was bound and released, so nothing answers it. The window
/// before the OS reuses the port only has to outlast one probe.
fn closed_port() -> u16 {
let listener = loopback_listener();
let port = port_of(&listener);
drop(listener);
port
}
fn proxy_url(port: u16) -> reqwest::Url {
reqwest::Url::parse(&format!("http://127.0.0.1:{port}")).expect("proxy url")
}
fn engine_target() -> reqwest::Url {
reqwest::Url::parse("https://api.deepseek.com/v1/chat/completions").unwrap()
}
fn watch(https_port: u16) -> Arc<LoopbackProxy> {
LoopbackProxy::new(None, None, Some(proxy_url(https_port)), "")
.expect("loopback proxy is watched")
}
/// A byte-for-byte HTTP server that answers every request with `200 ok`.
fn spawn_target_server() -> (u16, std::thread::JoinHandle<()>) {
let listener = loopback_listener();
let port = port_of(&listener);
let handle = std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { return };
let mut buffer = [0u8; 2048];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
);
}
});
(port, handle)
}
/// A server that answers like a proxy refusing to forward: `502`.
fn spawn_refusing_proxy() -> (u16, std::thread::JoinHandle<()>) {
let listener = loopback_listener();
let port = port_of(&listener);
let handle = std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { return };
let mut buffer = [0u8; 2048];
let _ = stream.read(&mut buffer);
let _ = stream.write_all(
b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
}
});
(port, handle)
}
#[test]
fn a_portless_socks_proxy_defaults_to_1080() {
let socks = reqwest::Url::parse("socks5://127.0.0.1").unwrap();
let watch = LoopbackProxy::new(Some(socks), None, None, "").expect("watched");
assert_eq!(watch.targets[0].port, 1080);
}
/// A socks5 route keeps remote DNS: building the matcher for that scheme
/// must not panic, and the returned route must name socks5h so the proxy
/// resolves the hostname rather than the client.
#[test]
fn a_socks5_proxy_route_keeps_remote_dns() {
let listener = loopback_listener();
let port = port_of(&listener);
let socks = reqwest::Url::parse(&format!("socks5://127.0.0.1:{port}")).unwrap();
let watch = LoopbackProxy::new(Some(socks), None, None, "").expect("watched");
let route = watch.route(&engine_target()).expect("socks route");
assert_eq!(route.scheme(), "socks5h");
assert_eq!(route.port(), Some(port));
drop(listener);
}
#[test]
fn each_proxy_is_watched_separately() {
let http_listener = loopback_listener();
let http_port = port_of(&http_listener);
let dead_https = closed_port();
let watch = LoopbackProxy::new(
None,
Some(proxy_url(http_port)),
Some(proxy_url(dead_https)),
"",
)
.expect("watched");
watch.refresh();
let http_target = reqwest::Url::parse("http://example.com/").unwrap();
let https_target = reqwest::Url::parse("https://example.com/").unwrap();
assert!(
watch.route(&http_target).is_some(),
"a live http proxy must keep routing"
);
assert!(
watch.route(&https_target).is_none(),
"a dead https proxy must not swallow https traffic"
);
drop(http_listener);
}
/// Proves the install order: with a live proxy the request must reach the
/// proxy (502), not the target (200). Installing `no_proxy()` after
/// `proxy()` would clear the route and let the request go direct.
///
/// Coverage boundary: this pins the order, not the presence of
/// `no_proxy()`. Dropping that call would leave reqwest's ambient matcher in
/// place, which is only observable when the process environment carries a
/// proxy — and mutating the process environment from a test is what this
/// suite stopped doing.
#[tokio::test]
async fn the_route_is_installed_after_the_proxy_list_is_cleared() {
let (target_port, target) = spawn_target_server();
let (proxy_port, refusing) = spawn_refusing_proxy();
let watch =
LoopbackProxy::new(Some(proxy_url(proxy_port)), None, None, "").expect("watched");
let route = reqwest::Proxy::custom({
let watch = Arc::clone(&watch);
move |url| watch.route(url)
});
let client = install_proxy_route(
codewhale_release::tls::reqwest_client_builder(),
Some(route),
)
.timeout(Duration::from_secs(5))
.build()
.expect("client");
let response = client
.get(format!("http://127.0.0.1:{target_port}/"))
.send()
.await
.expect("request");
assert_eq!(
response.status(),
502,
"the request must reach the proxy, not the target"
);
drop(target);
drop(refusing);
}
#[test]
fn no_configured_proxy_is_not_watched() {
assert!(LoopbackProxy::new(None, None, None, "").is_none());
}
#[test]
fn remote_proxy_is_not_watched() {
let remote = reqwest::Url::parse("http://proxy.example.test:8080").unwrap();
assert!(
LoopbackProxy::new(None, None, Some(remote), "").is_none(),
"a remote proxy's outage is a network outage, not a routing decision"
);
}
#[test]
fn a_remote_proxy_in_a_mixed_configuration_leaves_the_local_one_watched() {
let local_listener = loopback_listener();
let local_port = port_of(&local_listener);
let remote = reqwest::Url::parse("http://proxy.example.test:8080").unwrap();
let watch = LoopbackProxy::new(None, Some(proxy_url(local_port)), Some(remote), "")
.expect("the local proxy is still watched");
watch.refresh();
assert_eq!(watch.targets.len(), 1, "only loopback proxies are probed");
let http_target = reqwest::Url::parse("http://example.com/").unwrap();
let https_target = reqwest::Url::parse("https://example.com/").unwrap();
assert!(
watch.route(&http_target).is_some(),
"the live local http proxy keeps routing"
);
assert_eq!(
watch.route(&https_target).map(|url| url.to_string()),
Some("http://proxy.example.test:8080/".to_string()),
"a remote proxy is routed through, never watched"
);
drop(local_listener);
}
#[test]
fn ipv6_loopback_is_watched() {
// Skipped rather than failed on a host with IPv6 disabled: the feature
// under test needs a local IPv6 listener to probe.
let listener = match TcpListener::bind("[::1]:0") {
Ok(listener) => listener,
Err(_) => return,
};
let port = port_of(&listener);
let url = reqwest::Url::parse(&format!("http://[::1]:{port}")).expect("ipv6 url");
let watch = LoopbackProxy::new(None, None, Some(url), "").expect("watched");
assert_eq!(watch.targets[0].host, "::1");
assert_eq!(watch.targets[0].port, port);
let route = watch.route(&engine_target()).expect("proxy route");
assert_eq!(
route.host_str(),
Some("[::1]"),
"reqwest needs the brackets"
);
drop(listener);
}
/// The route a request takes must agree with the matcher's decision and with
/// the documented variable precedence. With `ALL_PROXY`, `HTTP_PROXY` and
/// `HTTPS_PROXY` on three live ports, each scheme follows its own variable
/// and `ALL_PROXY` is only the fallback.
#[test]
fn the_route_follows_the_same_precedence_as_the_matcher() {
let all_listener = loopback_listener();
let all_port = port_of(&all_listener);
let http_listener = loopback_listener();
let http_port = port_of(&http_listener);
let https_listener = loopback_listener();
let https_port = port_of(&https_listener);
let watch = LoopbackProxy::new(
Some(proxy_url(all_port)),
Some(proxy_url(http_port)),
Some(proxy_url(https_port)),
"",
)
.expect("watched");
let http_route = watch
.route(&reqwest::Url::parse("http://example.com/").unwrap())
.expect("http route");
let https_route = watch
.route(&reqwest::Url::parse("https://example.com/").unwrap())
.expect("https route");
assert_eq!(
http_route.port(),
Some(http_port),
"HTTP_PROXY outranks ALL_PROXY"
);
assert_eq!(
https_route.port(),
Some(https_port),
"HTTPS_PROXY outranks ALL_PROXY"
);
drop(all_listener);
drop(http_listener);
drop(https_listener);
}
/// `ALL_PROXY` alone still covers both schemes.
#[test]
fn all_proxy_alone_covers_both_schemes() {
let all_listener = loopback_listener();
let all_port = port_of(&all_listener);
let watch = LoopbackProxy::new(Some(proxy_url(all_port)), None, None, "").expect("watched");
for target in ["http://example.com/", "https://example.com/"] {
let route = watch
.route(&reqwest::Url::parse(target).unwrap())
.expect("route from ALL_PROXY");
assert_eq!(route.port(), Some(all_port));
}
drop(all_listener);
}
/// With an https proxy configured and no http one, a plain-http target must
/// go direct: `HTTPS_PROXY` does not proxy http destinations, and nothing
/// else may quietly stand in for it.
#[test]
fn an_https_only_proxy_leaves_http_targets_direct() {
let https_listener = loopback_listener();
let https_port = port_of(&https_listener);
let watch =
LoopbackProxy::new(None, None, Some(proxy_url(https_port)), "").expect("watched");
assert!(
watch
.route(&reqwest::Url::parse("http://example.com/").unwrap())
.is_none(),
"HTTPS_PROXY must not intercept http destinations"
);
assert_eq!(
watch
.route(&reqwest::Url::parse("https://example.com/").unwrap())
.expect("https route")
.port(),
Some(https_port)
);
drop(https_listener);
}
/// `[::ffff:127.0.0.1]` is the same local host spelled as IPv6, and
/// `Ipv6Addr::is_loopback` alone would call it remote.
#[test]
fn an_ipv4_mapped_ipv6_loopback_is_watched() {
let url = reqwest::Url::parse("http://[::ffff:127.0.0.1]:7890").expect("url");
let watch = LoopbackProxy::new(None, None, Some(url), "").expect("watched");
assert_eq!(watch.targets[0].port, 7890);
}
#[test]
fn no_proxy_star_disables_the_watcher() {
let local = proxy_url(closed_port());
assert!(LoopbackProxy::new(None, None, Some(local), "*").is_none());
}
#[test]
fn whitespace_separated_no_proxy_entries_match_commas() {
assert_eq!(
normalize_no_proxy("api.deepseek.com other.example"),
"api.deepseek.com,other.example"
);
assert_eq!(
normalize_no_proxy(" a.example,\n b.example "),
",a.example,,,b.example,"
);
assert!(no_proxy_disables_all("a.example *"));
assert!(!no_proxy_disables_all("a.example"));
}
#[test]
fn answering_loopback_port_keeps_proxy_routing() {
let listener = loopback_listener();
let port = port_of(&listener);
let watch = watch(port);
let route = watch.route(&engine_target()).expect("proxy route");
assert_eq!(route.host_str(), Some("127.0.0.1"));
assert_eq!(route.port(), Some(port));
drop(listener);
}
#[test]
fn loopback_proxy_credentials_survive_the_route() {
let listener = loopback_listener();
let port = port_of(&listener);
let url = reqwest::Url::parse(&format!(
"http://placeholder-user:[email protected]:{port}"
))
.unwrap();
let watch = LoopbackProxy::new(None, None, Some(url), "").expect("watched");
let route = watch.route(&engine_target()).expect("proxy route");
assert_eq!(route.username(), "placeholder-user");
assert_eq!(route.password(), Some("placeholder-pass"));
drop(listener);
}
#[test]
fn no_proxy_hosts_bypass_the_watched_proxy() {
let listener = loopback_listener();
let port = port_of(&listener);
let url = proxy_url(port);
let watch = LoopbackProxy::new(None, None, Some(url), "api.deepseek.com").expect("watched");
assert!(
watch.route(&engine_target()).is_none(),
"NO_PROXY must keep winning while the proxy is up"
);
drop(listener);
}
#[test]
fn a_refused_port_moves_routing_to_direct() {
let watch = watch(closed_port());
assert!(
watch.route(&engine_target()).is_some(),
"an unprobed proxy keeps reqwest's behaviour"
);
watch.refresh();
assert!(
watch.route(&engine_target()).is_none(),
"a refused port must stop intercepting requests"
);
}
#[test]
fn a_returning_port_moves_routing_back_to_the_proxy() {
let listener = loopback_listener();
let port = port_of(&listener);
let watch = watch(port);
drop(listener);
watch.refresh();
assert!(watch.route(&engine_target()).is_none(), "proxy is gone");
let listener = TcpListener::bind(("127.0.0.1", port)).expect("rebind proxy port");
watch.refresh();
assert!(
watch.route(&engine_target()).is_some(),
"a answering port must restore proxy routing"
);
drop(listener);
}
/// End to end over a real client: while the configured proxy refuses
/// connections the request must reach the target directly, and while it
/// answers the request must go to the proxy instead.
#[tokio::test]
async fn a_closed_proxy_no_longer_blocks_the_request() {
let (target_port, target) = spawn_target_server();
let target_url = format!("http://127.0.0.1:{target_port}/");
// ALL_PROXY so the plain-http target is proxied too; an HTTPS_PROXY
// entry deliberately does not intercept http destinations.
let watch = LoopbackProxy::new(Some(proxy_url(closed_port())), None, None, "")
.expect("watched loopback proxy");
let client = codewhale_release::tls::reqwest_client_builder()
.no_proxy()
.proxy(reqwest::Proxy::custom({
let watch = Arc::clone(&watch);
move |url| watch.route(url)
}))
.timeout(Duration::from_secs(5))
.build()
.expect("client");
assert!(
client.get(&target_url).send().await.is_err(),
"before a probe the dead proxy is still selected"
);
watch.refresh();
let response = client
.get(&target_url)
.send()
.await
.expect("direct request");
assert_eq!(response.status(), 200);
drop(target);
}
#[test]
fn reviewed_plugin_remote_proxy_policy_never_reads_ambient_environment() {
@@ -164,7 +1036,7 @@ mod tests {
let (placeholder_user, placeholder_pass) = ("PLACEHOLDER_USER", "PLACEHOLDER_PASS");
let with_creds = format!("http://{placeholder_user}:{placeholder_pass}@proxy.example/");
let redacted = redact_proxy_userinfo(&with_creds);
assert_eq!(redacted, "http://***@proxy.example/");
assert_eq!(redacted, "http://***@proxy.example");
assert!(!redacted.contains(placeholder_pass));
assert!(!redacted.contains(placeholder_user));
@@ -173,18 +1045,13 @@ mod tests {
let redacted = redact_proxy_userinfo(&with_user_only);
assert_eq!(redacted, "https://***@proxy.example:8080");
// No userinfo segment — pass through.
let redacted = redact_proxy_userinfo("http://proxy.example:3128/");
assert_eq!(redacted, "http://proxy.example:3128/");
assert_eq!(redacted, "http://proxy.example:3128");
// `@` appears only in the path, not as userinfo separator —
// must not be mistaken for credentials.
let redacted = redact_proxy_userinfo("http://proxy.example/path@thing");
assert_eq!(redacted, "http://proxy.example/path@thing");
let redacted =
redact_proxy_userinfo("http://proxy.example/path@thing?token=PLACEHOLDER#fragment");
assert_eq!(redacted, "http://proxy.example");
// Garbage input (no `://`) returned unchanged — the
// surrounding warning log is the only caller and is already
// handling the malformed-URL case.
assert_eq!(redact_proxy_userinfo("not-a-url"), "not-a-url");
assert_eq!(redact_proxy_userinfo("not-a-url"), "<invalid proxy URL>");
}
}
+86 -6
View File
@@ -54,6 +54,11 @@ pub enum DaemonSocketError {
/// `CODEWHALE_HOME` is set but not a usable absolute path.
#[error("invalid CODEWHALE_HOME override: {0}")]
InvalidHomeOverride(String),
#[error("cannot use {variable} for the runtime endpoint: {reason}")]
InvalidRuntimeRoot {
variable: &'static str,
reason: &'static str,
},
/// Unix socket paths are limited to roughly one hundred bytes.
#[error("daemon socket path {} is {len} bytes; this platform allows at most {max}", path.display())]
PathTooLong {
@@ -110,7 +115,7 @@ pub enum DaemonSocketError {
}
/// Inputs to [`resolve_socket_path`], separated from the environment so the
/// precedence rules are a pure, testable function.
/// precedence rules can be verified independently of process-wide variables.
#[derive(Debug, Clone, Default)]
pub struct SocketPathInputs {
/// `--socket-path`.
@@ -133,30 +138,79 @@ impl SocketPathInputs {
let xdg_runtime_dir = std::env::var_os("XDG_RUNTIME_DIR")
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let user_home = crate::user_home();
Ok(Self {
explicit,
codewhale_home_override,
xdg_runtime_dir,
user_home: crate::user_home(),
user_home,
macos: cfg!(target_os = "macos"),
})
}
}
/// Apply the precedence rules documented at the module level and enforce the
/// kernel's path-length limit.
fn implicit_endpoint_root(
_variable: &'static str,
root: PathBuf,
) -> Result<PathBuf, DaemonSocketError> {
#[cfg(unix)]
{
if !root.is_absolute()
|| root
.components()
.any(|part| matches!(part, std::path::Component::ParentDir))
{
return Err(DaemonSocketError::InvalidRuntimeRoot {
variable: _variable,
reason: "expected an absolute directory path without '..' components",
});
}
let root = crate::resolve_root_owned_ancestors(&root);
for ancestor in root.ancestors() {
match std::fs::symlink_metadata(ancestor) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(DaemonSocketError::InvalidRuntimeRoot {
variable: _variable,
reason: "a non-system symbolic link is not allowed",
});
}
Err(error) if error.kind() != std::io::ErrorKind::NotFound => {
return Err(DaemonSocketError::Io {
context: "cannot inspect runtime endpoint directory",
path: ancestor.to_path_buf(),
source: error,
});
}
_ => {}
}
}
Ok(root)
}
#[cfg(not(unix))]
{
Ok(root)
}
}
/// Apply the precedence rules documented at the module level, shortening only
/// implicit endpoints to leave room for store-specific control sockets.
pub fn resolve_socket_path(inputs: &SocketPathInputs) -> Result<PathBuf, DaemonSocketError> {
let path = if let Some(explicit) = inputs.explicit.clone() {
explicit
} else if let Some(home) = inputs.codewhale_home_override.clone() {
home.join("run").join(DAEMON_SOCKET_FILE_NAME)
implicit_endpoint_root("CODEWHALE_HOME", home)?
.join("run")
.join(DAEMON_SOCKET_FILE_NAME)
} else if let Some(runtime_dir) = inputs.xdg_runtime_dir.clone() {
runtime_dir.join("codewhale").join(DAEMON_SOCKET_FILE_NAME)
implicit_endpoint_root("XDG_RUNTIME_DIR", runtime_dir)?
.join("codewhale")
.join(DAEMON_SOCKET_FILE_NAME)
} else {
let user_home = inputs
.user_home
.clone()
.ok_or(DaemonSocketError::RuntimeDirUnavailable)?;
let user_home = implicit_endpoint_root("HOME", user_home)?;
if inputs.macos {
user_home
.join("Library")
@@ -170,6 +224,13 @@ pub fn resolve_socket_path(inputs: &SocketPathInputs) -> Result<PathBuf, DaemonS
.join(DAEMON_SOCKET_FILE_NAME)
}
};
#[cfg(unix)]
if inputs.explicit.is_none() {
let store_name = format!("store-{}.sock", "0".repeat(DERIVED_STORE_HASH_BYTES * 2));
if path.with_file_name(store_name).as_os_str().len() > MAX_SOCKET_PATH_BYTES {
return Ok(short_socket_directory(&path).join(DAEMON_SOCKET_FILE_NAME));
}
}
let len = path.as_os_str().len();
if len > MAX_SOCKET_PATH_BYTES {
return Err(DaemonSocketError::PathTooLong {
@@ -181,6 +242,25 @@ pub fn resolve_socket_path(inputs: &SocketPathInputs) -> Result<PathBuf, DaemonS
Ok(path)
}
#[cfg(unix)]
fn short_socket_directory(scope: &Path) -> PathBuf {
use sha2::Digest as _;
let mut digest = sha2::Sha256::new();
digest.update(b"Codewhale/short-socket/v1\0");
digest.update(scope.as_os_str().as_encoded_bytes());
let suffix: String = digest.finalize()[..16]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect();
let root = if cfg!(target_os = "macos") {
"/private/tmp"
} else {
"/tmp"
};
PathBuf::from(root).join(format!("codewhale-{suffix}"))
}
/// The socket path this host would use with no explicit override.
#[cfg(unix)]
pub fn default_socket_path() -> Result<PathBuf, DaemonSocketError> {
Loaded 100 of 305 files, more files were not shown because too many files have changed in this diff. Show more