0.10.2 candidate: MCP connects at start, background promotion, shell inspector, Full Access confirmation, Daytona and Sprite removal, composer-first launch, QA fixes

One integrated slice of the 0.10.2 release work, three-way merged onto
64bb073a4. User-visible changes (details and per-item evidence in CHANGELOG.md):

- MCP: user-configured servers connect in the background at session start
  (`lazy = true` opts out, `required` still blocks, plugin-contributed servers
  stay on demand); a finished OAuth login reconnects that one server;
  `/mcp reload` redials every non-lazy server; the reload summary reports a
  not-connected count and the configured total.
- #6944: bash promotes itself to the background after 30 s with elapsed time,
  last-output age and a cargo-shaped phase hint; kills say how far it got;
  task_shell_start is removed in favour of one background path.
- #6931: a live shell inspector (cwd, owner, state, bounded output, stop);
  Alt+V opens the newest running shell when nothing is selected; dock rows
  share the formatter.
- #6470: entering Full Access asks first (session by default, per-repo opt-in
  full_access_repos), chip text carries the scope, permission changes are
  receipted in the transcript and as a runtime status item; a saved
  full-access posture migrates to Ask once. Runtime API: PATCH into Full
  Access without full_access_confirmation is a 400 (documented).
- Daytona and Sprites are gone from the engine: own-sandbox dispatch
  (`--own-sandbox`, `/dispatch`, `agent runtime:"cloud"`, cloud-agent alias),
  cloud_dispatch.rs, dispatch_runner.rs, cloud_proposal.rs, computer_meter and
  the computer-use Sprite task-hold are deleted (archived in
  codewhale-hq/codewhale-sandboxes b68803e); logout sweeps a stored legacy
  sandbox token. `codewhale dispatch` is account-only, requires an actionable
  or queued intent, prints the Message ID, and prompts only when stdin and
  stdout are terminals. The CLI never prints a code or prose naming the VM
  vendor or retired providers.
- CW-7167: a keyless first launch opens on the composer; the first send opens
  the provider picker with the draft kept; the first-run picker shows the typed
  filter (CW-7170). A local runtime is offered only when Ollama answers.
- QA fixes: unknown --profile and a missing explicit --config fail loudly, -C
  on a missing directory errors, `codewhale web` works under a root-owned
  symlinked HOME ancestor (user-controlled symlinks are still rejected),
  `/exi` Enter fills instead of running destructive commands, Shift+Tab hint
  stays in Auto/Full Access, a crashed session shows an interrupted marker,
  help/exit-code fixes, README exec examples, zh docs. #6728: the pet owner
  stops building frames while nobody views.
- Credit: @xiaoray-blip (0.9.8) and @xyzs996 (0.9.11) added to AUTHOR_MAP,
  CONTRIBUTORS and the CHANGELOG; 0.9.9 placement for @Lstarsky0 and
  @SparkofSpike.

Evidence (2026-10-10, local only, on this exact tree):
- cargo clippy --workspace --all-targets --all-features --locked with CI's
  flags: exit 0.
- cargo nextest run --workspace --locked: 18981 tests, 18978 passed, 3 failed;
  the three are tools::test_runner::tests::* (environment-only: nested cargo
  inherits this machine's build-dir wrapper; they pass in CI).
- scripts/check-dead-code-budget.py PASS 221; check-blocking-calls-budget.py
  within budget (537 sites; private_directory.rs records one canonicalize and
  one std_fs: sync startup path resolution, not on the async runtime);
  check-command-crate-boundaries.py PASS; module_graph.py --check PASS;
  check-contributor-credit.py v0.10.1 clean; check-versions.sh OK (0.10.2).
- cargo fmt --all -- --check clean. Conformance goldens regenerated for the
  tool-catalog change (task_shell_start removed, bash description).
Not verified: hosted CI on this commit, a real-TTY run of composer-first
launch, MCP default connect, the shell inspector and the Full Access modal,
bwrap on Linux, a real provider turn, the live ChatGPT sign-in (#6925), a real
dispatch against the account service, and re-measuring
runtime-contract-budget on Linux.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-10-10 03:19:16 -07:00
1 parent 2d7f70a01b
commit 34bf6568f2
151 files changed
+2293 -9579

No files matched your search

+5
View File
@@ -258,3 +258,8 @@ Wu Shuwen = dajiaohuang <[email protected]>
[email protected] = dajiaohuang <[email protected]>
harryvgiunta = Harry Giunta <[email protected]>
[email protected] = Harry Giunta <[email protected]>
xiaoray-blip = Arlo <[email protected]>
Arlo = Arlo <[email protected]>
xyzs996 = Mark Yan <[email protected]>
[email protected] = Mark Yan <[email protected]>
Mark Yan = Mark Yan <[email protected]>
+65
View File
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- A first launch with no key or route now opens the composer. Codewhale asks
for a provider when you send your first message, keeps the message in the
box, and offers a local model only when a local Ollama server answers. The provider list
now shows what you type to filter it during first-run setup.
- Removed the own-sandbox cloud dispatch (`--own-sandbox`, `/dispatch`,
`agent runtime:"cloud"`, the `cloud-agent` alias, and the `--confirm`,
`--remote`, `--branch`, `--cwd`, `--status` and `--list` options).
`codewhale dispatch "<task>"` now runs only through the signed-in account,
with its quote and EU-compute consent.
- `codewhale dispatch` uses the account's neutral wording for cloud errors,
runs for the quoted seconds unless `--seconds` is given, and asks for consent
only when both stdin and stdout are terminals. It refuses a message that was
not read as new Work, and prints the Message ID.
- MCP servers you configure now connect when a session starts; set
`lazy = true` on a server to connect it on demand instead. Servers that come
from plugins stay on demand. Finishing an OAuth login reconnects just that
server, and `/mcp reload` redials every server that is not `lazy` and reports
how many are not connected out of the configured total.
- Running shell commands open an inspector with their output and status, and
`Alt+V` opens the newest running shell (#6931).
- `/resume`, `/load` and other session changes that cannot run yet now list
what is still busy and how to stop it.
- Sessions that ended without a clean exit show `interrupted` with their age
in the session list.
- `codewhale logout` also deletes a stored legacy sandbox token.
- `codewhale lane list` prints an empty list instead of failing when no lane
has been started, `codewhale completion` no longer errors when its output
pipe closes early, and `session-diagnostics`, `receipts` and `features` help
and errors say what to pass and where to find saved sessions.
- A missing `--config` file, a missing `-C` workspace directory and an unknown
`--profile` now fail at startup with a clear message.
- `codewhale web` works when `$HOME` sits under a root-owned symlink.
- Typing `/exi` and pressing Enter now completes the command instead of running
a different destructive command (`/exit`, `/clear`, `/new`, `/logout`,
`/restore`).
- The Shift+Tab permission hint stays in the footer while in Auto-Review and
Full Access.
- An idle pet view no longer builds animation frames while nobody is
looking at it (#6728).
- README: `codewhale exec --auto` is shown for headless runs that edit files
or run commands.
- `bash` now moves a foreground command to the background after 30 s and
reports the elapsed time and how long ago it last printed output.
`task_shell_start` is removed; use `bash` with `background=true`.
- Full Access now asks before turning on (Shift+Tab, Alt+Y, `/config
approval_mode full-access`, the High-trust setup preset) and lasts one
session. `R` in the popup keeps it on for this repo (`full_access_repos` in
`settings.toml`); `--yolo` and `CODEWHALE_YOLO` skip the question. A saved
`permission_posture = "full-access"` stays on for that session with a
one-time notice; later sessions start in Ask.
The footer chip reads `Full Access · session` or `Full Access · this repo`,
and every change of effective permission writes a `Permissions changed to …`
line (a `status` item and `thread.permissions_changed` event in runtime
threads). Runtime clients that `PATCH /v1/threads/{id}` into Full Access
must now send `full_access_confirmation` (`session` or `repo`) or get a 400.
- The bundled first-party catalog pins marketplace revision
`9b5f9d614ef69702be9944a61e89aeb3df14c047`; the 19 catalog entries are
unchanged, and installed bundles now come from a revision carrying the
@@ -3661,6 +3717,9 @@ item-level change record is retained below the categorized release highlights.
### Contributors
- **xyzs996 ([@xyzs996](https://github.com/xyzs996))** — DeepSeek V4 whole
Beijing weekends are billed as off-peak, and the scorecard window comment now
matches (#5545).
- **Sun Zhenyuan ([@bistack](https://github.com/bistack))** — tool-call stage
extraction with the existing execution and policy contracts preserved
(#5523).
@@ -4893,6 +4952,10 @@ locales grow to 18 and 8.
### Contributors
- Lstarsky0 (@Lstarsky0) — compact rows no longer reserve a hidden
session-metrics strip (#5486); docs shell and shared component pick-text
moved onto the typed dictionary spine (#5488, #5490).
- Sh1Zuku (@SparkofSpike) — topbar session title (#5481).
- hexin (@h3c-hexin) — a concrete route/offering output limit outranks the
8,192-token compatibility guess for an uncatalogued model (#5461, closes
#5460); web tool results use the noisy soft limit (#5474); owned direct
@@ -5259,6 +5322,8 @@ a published OpenAI-compatible host ship here (#5350).
### Contributors
- Arlo (@xiaoray-blip) — MCP list responses omit `nextCursor` when there
are no further pages (#5336, reported in #5335).
- EvanProgramming (@EvanProgramming) — webhook client panic fallback
(#5381); session-index JSONL mutex (#5382).
- Lstarsky0 (@Lstarsky0) — session peek hides internal runtime events
+3 -3
View File
@@ -77,7 +77,7 @@ Fix the failing tests and explain what changed.
The same task runs headless from a script or CI job:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale exec --auto "fix the failing tests and explain what changed"
```
Run `/help` for commands and keyboard shortcuts.
@@ -90,7 +90,7 @@ sessions, tools, and permissions behave the same everywhere.
| Command | What it does |
| --- | --- |
| `codewhale` | The interactive terminal interface |
| `codewhale exec "…"` | One headless turn from a script or CI, streaming JSON |
| `codewhale exec "…"` | One headless turn from a script or CI; `--auto` allows file and shell tools, `--output-format stream-json` streams JSON |
| `codewhale web` | The bundled [local browser client](docs/WEB.md) on `127.0.0.1` |
| `codewhale review --pr N` | An advisory [pull request review](docs/GITHUB_ACTION.md); posting is opt-in |
| Runtime API | A [local HTTP API](docs/RUNTIME_API.md) for threads, events, and approvals |
@@ -137,7 +137,7 @@ Full Access still respects hard policy boundaries. The
Codewhale runs on your machine with the access you grant it. Approval postures
and repository rules limit what the agent may do, and commands run inside an OS
sandbox where supported (Seatbelt on macOS; bubblewrap on Linux is opt-in).
sandbox where supported (Seatbelt on macOS; bubblewrap on Linux when installed and working).
`/preview-request` shows the exact redacted request before anything is sent.
Unknown model prices stay unknown instead of being reported as free.
-97
View File
@@ -1,97 +0,0 @@
# syntax=docker/dockerfile:1
# codewhale-cloud-agent — Daytona snapshot image for Codewhale cloud agents.
#
# Product truth first: this image installs the RELEASED v0.9.11 Linux x86_64
# binary (static musl, no glibc floor) from the GitHub release by exact URL,
# verifies its sha256 against codewhale-artifacts-sha256.txt, and records the
# commit + digest as OCI labels (PRD 4.5: commit- and digest-pinned Linux
# binary inside the Computer). No secrets are baked in. Daytona create-time
# environment is server-visible, so a provider key must never be injected at
# create time. A future dispatcher must deliver provider secrets only after
# creation, over a post-create execution channel from stdin (never argv), and
# remove them during teardown. `CODEWHALE_API_KEY` is an account/machine token,
# not an inference-provider credential; current cloud dispatch has no
# server-side account-token-to-provider-key resolution.
#
# Build (Daytona, amd64 only; daytona snapshot create has no --build-arg, so
# every pin is inline):
# daytona snapshot create codewhale-cloud-agent \
# -f Dockerfile --cpu 4 --memory 8 --disk 10 (plan max; resources bind to the snapshot)
#
# Dispatcher source selects this snapshot name, sends account identity, and
# uses the toolbox to clone and execute commands. That wiring does not prove
# provider-credential resolution or end-to-end Cloud Agent acceptance. See
# README.md beside this Dockerfile for the source and image evidence limits.
# Never substitute a create-time provider-key shortcut for that missing bridge.
FROM debian:bookworm-slim
ARG DEBIAN_FRONTEND=noninteractive
# ---- pins (release v0.9.11, tag commit 96d13a0bc3f40280ea3865280ad5ccf0e2845e6f)
ENV CODEWHALE_VERSION=0.9.11 \
CODEWHALE_COMMIT=96d13a0bc3f40280ea3865280ad5ccf0e2845e6f \
CODEWHALE_ASSET_URL=https://github.com/codewhale-hq/CodeWhale/releases/download/v0.9.11/codewhale-linux-x64 \
CODEWHALE_ASSET_SHA256=c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416 \
NODE_MAJOR=22
# Base toolchain for an agent doing code work: git, curl, TLS roots, ripgrep,
# python3, node LTS (22), build-essential. procps for `ps`/`pkill` used by the
# engine's process tooling; sudo is deliberately NOT installed.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl git gnupg ripgrep procps \
python3 python3-pip python3-venv \
build-essential pkg-config \
jq unzip xz-utils less \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Download the released binary by exact URL and refuse anything whose sha256
# does not match the release checksum. Static musl: no interpreter, no glibc.
RUN set -eu; \
curl -fsSL --retry 3 -o /tmp/codewhale "${CODEWHALE_ASSET_URL}"; \
echo "${CODEWHALE_ASSET_SHA256} /tmp/codewhale" | sha256sum -c -; \
install -m 0755 -o root -g root /tmp/codewhale /usr/local/bin/codewhale; \
ln -s /usr/local/bin/codewhale /usr/local/bin/codew; \
rm -f /tmp/codewhale; \
codewhale --version | tee /etc/codewhale-version; \
grep -qx "codewhale 0.9.11 (96d13a0bc3f4)" /etc/codewhale-version
# Non-root agent user. /work is the generic Computer mount; /workspace is the
# path #5712's runner clones into and runs the harness from — both owned by
# the agent so a non-root toolbox user can write them.
RUN groupadd --gid 1000 agent \
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/agent --shell /bin/bash agent \
&& mkdir -p /work /workspace /home/agent/.codewhale \
&& chown -R agent:agent /work /workspace /home/agent
ENV HOME=/home/agent \
CODEWHALE_HOME=/home/agent/.codewhale \
PATH=/home/agent/.local/bin:/usr/local/bin:/usr/bin:/bin \
GIT_TERMINAL_PROMPT=0 \
CI=1 \
TERM=xterm-256color
LABEL org.opencontainers.image.title="codewhale-cloud-agent" \
org.opencontainers.image.description="Codewhale cloud agent Computer: released codewhale CLI preinstalled for dispatched turns" \
org.opencontainers.image.version="0.9.11" \
org.opencontainers.image.revision="96d13a0bc3f40280ea3865280ad5ccf0e2845e6f" \
org.opencontainers.image.source="https://github.com/codewhale-hq/CodeWhale" \
net.codewhale.binary.asset="codewhale-linux-x64" \
net.codewhale.binary.sha256="c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416" \
net.codewhale.binary.commit="96d13a0bc3f40280ea3865280ad5ccf0e2845e6f" \
net.codewhale.binary.version="0.9.11" \
net.codewhale.release.image="ghcr.io/codewhale-hq/codewhale:0.9.11@sha256:6de13fe5e62fb3cb815c423bcb17455bef4d9f7db2107888beb88fe4b7c9ac14"
USER agent
WORKDIR /work
# Daytona injects its own toolbox daemon; keep the container alive for it.
ENTRYPOINT ["sleep", "infinity"]
-139
View File
@@ -1,139 +0,0 @@
# `codewhale-cloud-agent` — Daytona Computer snapshot
This directory is the product-owned definition of the Daytona snapshot that a
Cloud Agent acquires as its Computer (PRODUCT_PRD §4.5). The Codewhale Engine
is the sole runtime inside the Computer and is installed as a commit- and
digest-pinned Linux binary; nothing else in the image runs agent logic.
## What the image is
- Base: `debian:bookworm-slim` (linux/amd64 — Daytona builds amd64 only).
- Engine: released `codewhale-linux-x64` from GitHub release `v0.9.11`,
fetched by exact URL and verified against the release checksum before
install:
- commit `96d13a0bc3f40280ea3865280ad5ccf0e2845e6f` (tag `v0.9.11`)
- sha256 `c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416`
- static musl build (no glibc floor), installed at `/usr/local/bin/codewhale`
with a `codew` symlink; the build fails if `codewhale --version` does not
print `codewhale 0.9.11 (96d13a0bc3f4)`.
- Toolchain for agent work: git, curl, CA roots, ripgrep, procps, python3
(+pip, venv), build-essential, pkg-config, jq, unzip, xz-utils, less,
Node.js 22 (NodeSource). No sudo.
- User: non-root `agent` (uid/gid 1000), `HOME=/home/agent`,
`CODEWHALE_HOME=/home/agent/.codewhale`. `/work` and `/workspace` exist and
are owned by `agent`.
- Entrypoint: `sleep infinity` (Daytona injects its own toolbox daemon).
- No provider credentials are baked in. Provider credentials must not be
supplied at sandbox create time.
Daytona create-time environment is server-visible, so a provider secret
must never appear in `daytona create -e …` or an SDK `envVars` payload.
The pins are recorded as OCI labels (`org.opencontainers.image.revision`,
`net.codewhale.binary.sha256`, ...) so a running Computer can be audited
against the release it claims to run.
## Dispatcher wiring and acceptance limits
The image definition lives only in this directory. The launcher in
`crates/tui/src/cloud_dispatch.rs` selects `codewhale-cloud-agent` (or
`CODEWHALE_DISPATCH_SNAPSHOT`), sends the account machine token in
`CODEWHALE_API_KEY`, applies job labels, and uses the Daytona toolbox to clone
and execute commands. `crates/tui/src/dispatch_runner.rs` drives that lifecycle.
See the [dispatch guide](../../../docs/DAYTONA_CLOUD_DISPATCH.md) for the
command surface.
This describes source wiring. The image remains pinned to the Engine version
listed above, and this repository does not establish a server-side
account-token-to-provider-credential resolution path. A snapshot build or
manual `codewhale exec` is evidence for that specific operation; it does not
qualify account entitlement, provider credential custody, dispatcher execution,
metering or customer use. The historical manual image receipt below is not
acceptance of the current dispatcher.
## Provider credentials inside the Computer
> **Credential exposure note (verified 2026-08-30).** Daytona persists
> `daytona create -e KEY=VALUE` / SDK `envVars` server-side and returns the
> environment through its API (`GET /sandbox/{id}`). Create-time environment is
> therefore server-visible. Provider secrets must be injected only after the
> Computer is created, through a post-create execution channel from stdin
> (never argv and never create-time environment), then removed at teardown.
> This image and the current dispatcher do not implement that bridge.
>
> `api_key_env` accepts the **name of an environment variable**, not a file
> path or file contents. Do not point it at a `0600` secret file. If a future
> product-owned bridge uses a temporary file, it must separately and explicitly
> map the stdin-delivered secret into the engine process without placing the
> secret in Daytona create-time environment.
>
> The #5712 `CODEWHALE_API_KEY` account/machine-token caveat remains: it is not
> an inference-provider credential and current cloud dispatch does not resolve
> it server-side into one. A machine token alone cannot make this image run a
> provider-backed Engine turn.
The following are configuration references for a future supported post-create
bridge, not current dispatcher wiring and not permission to use create-time
environment:
| Provider (config name) | Env var | Example model identifiers |
|----------------------------|----------------------------------------|------------------------------|
| `modelstudio-token-plan` | `MODELSTUDIO_API_KEY` (or `DASHSCOPE_API_KEY`) | `qwen3.8-flash`, `deepseek-v4-pro` |
| `deepseek` | `DEEPSEEK_API_KEY` | `deepseek-v4-pro` |
`CODEWHALE_PROVIDER` / `CODEWHALE_MODEL` select an Engine route when the Engine
is launched; they do not make the current dispatcher launch this snapshot or
deliver a provider credential.
## Build
`daytona snapshot create` (CLI v0.205.x) has no `--build-arg`, so every pin is
inline in the Dockerfile. Resources are set at snapshot creation and are the
plan maximum:
```sh
cd computer/snapshots/cloud-agent
daytona snapshot create codewhale-cloud-agent -f Dockerfile --cpu 4 --memory 8 --disk 10
```
To roll the engine forward: bump `CODEWHALE_VERSION`, `CODEWHALE_COMMIT`,
`CODEWHALE_ASSET_URL`, `CODEWHALE_ASSET_SHA256`, the `grep -qx` version
assertion, and the OCI labels together, then rebuild under a new snapshot
name (snapshots are immutable once active).
## Probe a Computer (manual image evidence only)
```sh
daytona create --snapshot codewhale-cloud-agent \
-l owner=cw-integrator -l lane=cloud-agent-e2e --ttl 30 --auto-delete 0 --name cw-probe
daytona exec cw-probe -- sh -c 'id -u; codewhale --version; git --version; node --version; df -h /; sha256sum /usr/local/bin/codewhale'
daytona delete cw-probe
```
The sha256 printed by the probe must equal the pinned
`c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416`. This is
not product-dispatch or launch acceptance evidence.
## Image build and manual probe receipt (2026-08-30; not launch proof)
Built with the command above; snapshot id `b9275f82-0ead-4855-9707-21859aa186b4`,
state ACTIVE, 0.70 GB, cpu 4 / memory 8 / disk 10. Probe sandbox
(`daytona create --snapshot codewhale-cloud-agent`, labels
`owner=cw-integrator,lane=cloud-agent-e2e`, ttl 30, auto-delete 0) reported:
```
uid=1000 user=agent HOME=/home/agent CODEWHALE_HOME=/home/agent/.codewhale PWD=/work
codewhale 0.9.11 (96d13a0bc3f4)
git version 2.39.5
v22.23.2 (node)
Python 3.11.2
ripgrep 13.0.0
overlay 10G used 24K avail 10G (/ , /work, /workspace)
cpu.max 400000 100000 ; memory.max 8589934592
c02969556e51e138afa3fe9c97a1359878cd3d1986b1ce1f5fa96c93c6909416 /usr/local/bin/codewhale
/workspace writable ; /work writable
```
This shows only that the Daytona toolbox executed the listed manual commands
as the image `USER` (uid 1000) with the image `ENV` honored. It does not prove
current product dispatcher wiring, provider-secret custody, Engine execution,
or any launch acceptance condition.
+69 -6
View File
@@ -357,7 +357,7 @@ enum CloudAgentsCommand {
confirm_eu_compute: bool,
#[arg(
long,
help = "Exact whole seconds printed by work-quote; legacy trial default is 300"
help = "Exact whole seconds printed by work-quote; default is what your current offer allows"
)]
seconds: Option<u64>,
#[arg(long, help = "Exact failed workspace ID used for the recovery quote")]
@@ -2575,7 +2575,7 @@ fn run_agents<T: CloudTransport, W: Write>(
validate_operation_key(&message_id)?;
validate_operation_key(&operation_key)?;
let selected = prepare_work_agent(client, agent.as_deref())?;
let interactive = io::stdin().is_terminal();
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
work::dispatch(
client,
out,
@@ -3606,17 +3606,80 @@ impl CloudHttpError {
impl std::fmt::Display for CloudHttpError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.code {
Some(code) => write!(
match self.code.as_deref().and_then(neutral_error_text) {
Some(text) => write!(
f,
"Codewhale account request failed (HTTP {}, code {code})",
"Codewhale account request failed (HTTP {}): {text}",
self.status
),
None => write!(f, "Codewhale account request failed (HTTP {})", self.status),
None => match self.code.as_deref().filter(|code| !is_vendor_code(code)) {
Some(code) => write!(
f,
"Codewhale account request failed (HTTP {}, code {code})",
self.status
),
None => write!(f, "Codewhale account request failed (HTTP {})", self.status),
},
}
}
}
pub(crate) fn vendor_safe_prose(text: String) -> String {
if is_vendor_code(&text) {
"The cloud service reported an unrecognised condition".to_string()
} else {
text
}
}
fn is_vendor_code(code: &str) -> bool {
code.split(|c: char| !c.is_ascii_alphanumeric())
.any(|token| {
matches!(
token.to_ascii_lowercase().as_str(),
"boat" | "daytona" | "sprite" | "sprites" | "agentbay" | "alibaba"
)
})
}
fn neutral_error_text(code: &str) -> Option<&'static str> {
Some(match code {
"boat_task_replay_expired" | "work_task_replay_expired" => {
"The earlier request can no longer be replayed and needs operator reconciliation"
}
"boat_task_create_in_progress" | "work_task_create_in_progress" => {
"The cloud computer is still being created"
}
"boat_task_cleanup_pending" | "work_task_cleanup_pending" => {
"The cloud computer is still being cleaned up"
}
"boat_task_receipt_invalid" | "work_task_receipt_invalid" => {
"The cloud computer's receipt could not be verified"
}
"boat_task_authority_changed" | "work_task_authority_changed" => {
"Your account authority changed while the cloud computer was starting"
}
"boat_task_stop_unconfirmed" | "work_task_stop_unconfirmed" => {
"The cloud computer stopped but its state is unconfirmed"
}
"boat_task_usage_pending" | "work_task_usage_pending" => {
"Usage for the cloud computer is still being finalized"
}
"boat_task_outcome_unknown" | "work_task_outcome_unknown" => {
"The cloud computer outcome is unknown"
}
_ => return None,
})
}
pub(crate) fn display_error_code(code: &str) -> String {
match neutral_error_text(code) {
Some(text) => text.to_string(),
None if is_vendor_code(code) => "unrecognised cloud error".to_string(),
None => code.to_string(),
}
}
impl std::error::Error for CloudHttpError {}
/// The request may or may not have been processed: it never reached the
+1 -1
View File
@@ -502,7 +502,7 @@ fn sanitize_code(code: &str) -> String {
/// Server prose is untrusted output. Strip control characters and bound it so
/// a hostile or broken body cannot rewrite the terminal.
fn sanitize_message(message: &str) -> String {
printable(message)
super::vendor_safe_prose(printable(message))
}
// ---------------------------------------------------------------------------
+2 -2
View File
@@ -4004,7 +4004,7 @@ fn work_result_summarises_evidence_pr_route_and_boat_usage() {
"Provider VM: stopped",
"Provider cleanup: confirmed",
"Attempts: 2",
"#1 launch failed (error boat_task_stop_unconfirmed)",
"#1 launch failed (error: The cloud computer stopped but its state is unconfirmed)",
"#2 recovery settled",
"Evidence through event 57",
] {
@@ -4937,7 +4937,7 @@ fn account_errors_stay_typed_so_callers_can_tell_refusals_from_unknown_outcomes(
));
assert_eq!(
refusal.to_string(),
"Codewhale account request failed (HTTP 422, code boat_work_trial_seconds_invalid)"
"Codewhale account request failed (HTTP 422)"
);
assert!(!outcome_unknown(&refusal));
for status in [500, 502, 503, 504, 408] {
+11 -2
View File
@@ -695,7 +695,7 @@ fn write_work_result<W: Write>(
text_at(attempt, &["status"]).unwrap_or_else(|| "unknown".to_string())
);
if let Some(code) = text_at(attempt, &["errorCode"]) {
line.push_str(&format!(" (error {code})"));
line.push_str(&format!(" (error: {})", display_error_code(&code)));
}
writeln!(out, "{line}")?;
}
@@ -1142,7 +1142,7 @@ pub(super) fn quote<T: CloudTransport, W: Write>(
writeln!(out, "{title}")?;
}
if let Some(copy) = str_at(&quote, &["confirmCopy", "body"])
.map(|body| printable_max(body, 800))
.map(|body| vendor_safe_prose(printable_max(body, 800)))
.filter(|body| !body.is_empty())
{
writeln!(out, "{copy}")?;
@@ -1205,6 +1205,15 @@ pub(super) fn dispatch<T: CloudTransport, W: Write>(
out.write_all(&recorded)?;
bail!("This message did not create exactly one new Work, so nothing was quoted or started");
};
let recorded_text = String::from_utf8_lossy(&recorded);
if !recorded_text
.lines()
.any(|line| matches!(line.trim(), "Intent: actionable" | "Intent: queued"))
{
out.write_all(&recorded)?;
bail!("This message was not read as new Work, so nothing was quoted or started");
}
writeln!(out, "Message ID: {}", options.message_id)?;
writeln!(out, "Work ID: {id}")?;
let quoted = quote(
client,
+40 -375
View File
@@ -1,69 +1,20 @@
//! `codewhale dispatch`: run an objective on a cloud computer through the
//! signed-in Codewhale account, or on your own sandbox with `--own-sandbox`.
use std::io::{self, Write};
use std::path::PathBuf;
use anyhow::{Result, bail};
use clap::{Args, ValueEnum};
use clap::Args;
use codewhale_config::ConfigStore;
use codewhale_tui::cloud_dispatch::{
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
discover_credentials, discover_machine_token, discover_remotes, execute_dispatch, format_job,
format_job_list, format_status, plan_dispatch,
};
use codewhale_tui::dispatch_runner::spawn_confirmed_runner;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum ForgeArg {
Github,
Cnb,
Gitee,
}
impl From<ForgeArg> for Forge {
fn from(value: ForgeArg) -> Self {
match value {
ForgeArg::Github => Forge::Github,
ForgeArg::Cnb => Forge::Cnb,
ForgeArg::Gitee => Forge::Gitee,
}
}
}
#[derive(Debug, Args)]
pub(crate) struct DispatchArgs {
/// Task for the remote agent. Required unless listing or inspecting a job.
/// Task for the remote agent. Required unless inspecting or cancelling a job.
#[arg(value_name = "PROMPT")]
prompt: Vec<String>,
/// Forge that should receive the branch and PR: github, cnb, or gitee.
#[arg(long, value_enum)]
remote: Option<ForgeArg>,
/// Branch the remote agent will raise (default: codewhale/cloud-<unix>).
#[arg(long)]
branch: Option<String>,
/// Required to create Codewhale cloud-agent spend or push. Without this, only a proposal is written.
#[arg(long)]
confirm: bool,
/// Show remotes and whether Codewhale cloud-agent credentials are present (never prints secrets).
#[arg(long)]
status: bool,
/// List first-class cloud jobs (same kind shown by `/jobs`).
#[arg(long)]
list: bool,
/// Inspect one cloud job.
/// Inspect one account job.
#[arg(long, value_name = "ID")]
show: Option<String>,
/// Cancel one cloud job.
/// Cancel one account job.
#[arg(long, value_name = "ID")]
cancel: Option<String>,
/// Workspace whose git remotes are classified (default: current directory).
#[arg(long)]
cwd: Option<PathBuf>,
/// Use your own sandbox keys instead of your Codewhale account. This
/// bypasses the account quote, billing and EU consent.
#[arg(long)]
own_sandbox: bool,
/// Account dispatch: Agent name or ID (default: the repository-free default Agent).
#[arg(long)]
agent: Option<String>,
@@ -90,8 +41,8 @@ pub(crate) struct DispatchArgs {
message_id: Option<String>,
}
fn account_id(id: &str) -> bool {
!id.starts_with("cloud_")
fn unknown_job(id: &str) -> anyhow::Error {
anyhow::anyhow!("Unknown job {id}: it is not a Codewhale account job ID.")
}
pub(crate) fn run(
@@ -100,209 +51,44 @@ pub(crate) fn run(
config: &mut ConfigStore,
) -> Result<()> {
use crate::cloud::{AccountDispatch, run_dispatch};
if !args.own_sandbox {
if let Some(id) = args.show.as_deref().filter(|id| account_id(id)) {
return run_dispatch(AccountDispatch::Status(id.to_string()), profile, config);
}
if let Some(id) = args.cancel.as_deref().filter(|id| account_id(id)) {
return run_dispatch(AccountDispatch::Cancel(id.to_string()), profile, config);
}
let single_token_job = args.prompt.len() == 1 && args.prompt[0].starts_with("cloud_");
if !args.prompt.is_empty() && !single_token_job {
if args.confirm || args.remote.is_some() || args.branch.is_some() || args.cwd.is_some()
{
bail!(
"--confirm, --remote, --branch and --cwd belong to your own sandbox; add --own-sandbox, or drop them to dispatch through your Codewhale account"
);
}
return run_dispatch(
AccountDispatch::Run {
objective: args.prompt.join(" "),
agent: args.agent,
seconds: args.seconds,
context_file: args.context_file,
file_refs: args.file_refs,
operation_key: args.operation_key,
message_id: args.message_id,
yes: args.yes,
confirm_eu_compute: args.confirm_eu_compute,
},
profile,
config,
);
}
}
run_own_sandbox(args)
}
fn run_own_sandbox(args: DispatchArgs) -> Result<()> {
let mut out = io::stdout().lock();
run_with(args, &mut out)
}
fn run_with<W: Write>(args: DispatchArgs, out: &mut W) -> Result<()> {
if [
args.status,
args.list,
let modes = [
args.show.is_some(),
args.cancel.is_some(),
!args.prompt.is_empty(),
]
.iter()
.filter(|flag| **flag)
.count()
> 1
{
bail!("Use one of: a prompt, --status, --list, --show <id>, or --cancel <id>.");
}
let workspace = args
.cwd
.clone()
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
let remotes = discover_remotes(&workspace);
let credentials = discover_credentials();
let store = CloudJobStore::from_env()?;
if args.status
|| (args.prompt.is_empty() && args.show.is_none() && args.cancel.is_none() && !args.list)
{
writeln!(
out,
"{}",
format_status(&remotes, &credentials, &recent_jobs(&store))
)?;
return Ok(());
}
if args.list {
writeln!(out, "{}", format_job_list(&store.list()?))?;
return Ok(());
];
if modes.iter().filter(|mode| **mode).count() > 1 {
bail!("Use one of: a prompt, --show <id>, or --cancel <id>.");
}
if let Some(id) = args.show.as_deref() {
writeln!(out, "{}", format_job(&store.load(id)?))?;
return Ok(());
if id.starts_with("cloud_") {
return Err(unknown_job(id));
}
return run_dispatch(AccountDispatch::Status(id.to_string()), profile, config);
}
if let Some(id) = args.cancel.as_deref() {
writeln!(
out,
"{}",
format_job(&cancel_job(&store, id, &LiveDaytonaLauncher)?)
)?;
return Ok(());
}
let prompt = args.prompt.join(" ");
if prompt.starts_with("cloud_") && args.confirm && prompt.split_whitespace().count() == 1 {
let outcome = confirm_job(
&store,
prompt.trim(),
&credentials,
&discover_machine_token(),
)?;
let runner = spawn_accepted(&store, &outcome);
return write_then_join(out, &store, prompt.trim(), outcome, runner);
}
let plan = plan_dispatch(
&remotes,
&prompt,
args.remote.map(Forge::from),
args.branch.as_deref(),
)?;
let outcome = execute_dispatch(
&store,
plan,
args.confirm,
&credentials,
&discover_machine_token(),
)?;
let runner = spawn_accepted(&store, &outcome);
let job_id = outcome_job_id(&outcome).unwrap_or_default();
write_then_join(out, &store, &job_id, outcome, runner)
}
/// Print the outcome card, then stay attached to the runner — joining it even
/// when the print failed. A closed stdout (`| head`, a dead terminal) used to
/// return before the join, ending the process and the runner thread with it,
/// which orphaned the paid sandbox this wait exists to supervise. The print
/// error still wins the exit status.
fn write_then_join<W: Write>(
out: &mut W,
store: &CloudJobStore,
id: &str,
outcome: DispatchOutcome,
runner: Option<std::thread::JoinHandle<()>>,
) -> Result<()> {
let written = write_outcome(out, outcome);
let joined = join_runner(out, store, id, runner);
written.and(joined)
}
/// The CLI stays attached to a confirmed run: the card prints immediately,
/// then the process waits for the runner so a paid sandbox is never
/// orphaned by an early exit. Ctrl-C exits the wait; the job stays recorded
/// and `--cancel` tears the sandbox down.
fn join_runner<W: Write>(
out: &mut W,
store: &CloudJobStore,
id: &str,
runner: Option<std::thread::JoinHandle<()>>,
) -> Result<()> {
if let Some(runner) = runner {
runner
.join()
.map_err(|_| anyhow::anyhow!("the cloud agent runner panicked"))?;
if !id.is_empty()
&& let Ok(job) = store.load(id)
{
writeln!(out, "{}", format_job(&job))?;
if id.starts_with("cloud_") {
return Err(unknown_job(id));
}
return run_dispatch(AccountDispatch::Cancel(id.to_string()), profile, config);
}
Ok(())
}
fn outcome_job_id(outcome: &DispatchOutcome) -> Option<String> {
match outcome {
DispatchOutcome::Proposal(job)
| DispatchOutcome::Refused(job)
| DispatchOutcome::Accepted(job) => Some(job.id.clone()),
}
}
/// Newest jobs for the status card's receipts section (best effort — an
/// unreadable store must not hide the card).
fn recent_jobs(store: &CloudJobStore) -> Vec<codewhale_tui::cloud_dispatch::CloudJob> {
store
.list()
.unwrap_or_default()
.into_iter()
.take(5)
.collect()
}
/// Start the background runner for a just-accepted confirm. The sandbox,
/// harness turn, branch push, PR open, and teardown all happen there.
fn spawn_accepted(
store: &CloudJobStore,
outcome: &DispatchOutcome,
) -> Option<std::thread::JoinHandle<()>> {
match outcome {
DispatchOutcome::Accepted(job) => spawn_confirmed_runner(store.clone(), job.id.clone()),
_ => None,
}
}
fn write_outcome<W: Write>(out: &mut W, outcome: DispatchOutcome) -> Result<()> {
match outcome {
DispatchOutcome::Proposal(job) | DispatchOutcome::Accepted(job) => {
writeln!(out, "{}", format_job(&job))?;
Ok(())
}
DispatchOutcome::Refused(job) => {
writeln!(out, "{}", format_job(&job))?;
bail!("{}", job.note);
}
if args.prompt.is_empty() {
bail!("Give a task to dispatch, or use --show <id> or --cancel <id>.");
}
run_dispatch(
AccountDispatch::Run {
objective: args.prompt.join(" "),
agent: args.agent,
seconds: args.seconds,
context_file: args.context_file,
file_refs: args.file_refs,
operation_key: args.operation_key,
message_id: args.message_id,
yes: args.yes,
confirm_eu_compute: args.confirm_eu_compute,
},
profile,
config,
)
}
#[cfg(test)]
@@ -327,131 +113,12 @@ mod tests {
"fix",
"the",
"flake",
"--remote",
"github",
"--seconds",
"600",
]);
assert_eq!(parsed.prompt, ["fix", "the", "flake"]);
assert_eq!(parsed.remote, Some(ForgeArg::Github));
assert!(!parsed.confirm);
assert!(args(&["codewhale", "cloud-agent", "--status"]).status);
assert!(Cli::try_parse_from(["codewhale", "dispatch", "--remote", "gitlab"]).is_err());
}
#[test]
fn refused_confirmation_is_a_nonzero_error() {
use codewhale_tui::cloud_dispatch::{CloudJob, CloudJobStatus};
let job = CloudJob {
id: "cloud_00000000000000dd".to_string(),
kind: "cloud".to_string(),
status: CloudJobStatus::Refused,
prompt: "fix".to_string(),
forge: Forge::Github,
remote_name: "github".to_string(),
remote_url: "https://github.com/org/repo.git".to_string(),
branch: "codewhale/cloud-x".to_string(),
confirmed: true,
sandbox_id: None,
pr_url: None,
refusal: Some("no credentials".to_string()),
note: "Refused: cloud agents are not available.".to_string(),
created_unix: 1,
base_branch: None,
head_sha: None,
agent_summary: None,
finished_unix: Some(1),
sandbox_pending: false,
};
let error = write_outcome(&mut Vec::new(), DispatchOutcome::Refused(job)).unwrap_err();
assert!(
error.to_string().contains("Refused"),
"refused confirmations must not exit 0: {error}"
);
}
#[test]
fn status_is_fail_closed_and_never_prints_secrets() {
let temp = tempfile::tempdir().unwrap();
let mut output = Vec::new();
run_with(
DispatchArgs {
prompt: Vec::new(),
remote: None,
branch: None,
confirm: false,
status: true,
list: false,
show: None,
cancel: None,
cwd: Some(temp.path().to_path_buf()),
own_sandbox: false,
agent: None,
seconds: None,
context_file: None,
file_refs: Vec::new(),
yes: false,
confirm_eu_compute: false,
operation_key: None,
message_id: None,
},
&mut output,
)
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Codewhale cloud dispatch"));
assert!(!text.contains("Daytona"));
assert!(!text.contains("sk-"));
assert!(!text.contains("Bearer"));
}
/// Audit R02-09: a stdout failure must not skip the runner join.
#[test]
fn a_failed_card_write_still_joins_the_runner() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
struct ClosedPipe;
impl Write for ClosedPipe {
fn write(&mut self, _: &[u8]) -> io::Result<usize> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}
fn flush(&mut self) -> io::Result<()> {
Err(io::Error::from(io::ErrorKind::BrokenPipe))
}
}
let finished = Arc::new(AtomicBool::new(false));
let runner = {
let finished = Arc::clone(&finished);
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
finished.store(true, Ordering::SeqCst);
})
};
let job: codewhale_tui::cloud_dispatch::CloudJob =
serde_json::from_value(serde_json::json!({
"id": "cloud_test", "kind": "cloud", "status": "running",
"prompt": "p", "forge": "github", "remote_name": "origin",
"remote_url": "https://example.invalid/r.git", "branch": "b",
"confirmed": true, "sandbox_id": null, "pr_url": null,
"refusal": null, "note": "", "created_unix": 0
}))
.expect("job fixture");
let dir = tempfile::tempdir().unwrap();
let store = CloudJobStore::from_path(dir.path().to_path_buf());
let result = write_then_join(
&mut ClosedPipe,
&store,
"cloud_test",
DispatchOutcome::Accepted(job),
Some(runner),
);
assert!(result.is_err(), "the print failure is still reported");
assert!(
finished.load(Ordering::SeqCst),
"returned before the runner finished"
);
assert_eq!(parsed.seconds, Some(600));
assert!(Cli::try_parse_from(["codewhale", "dispatch", "--remote", "github"]).is_err());
}
#[test]
@@ -462,10 +129,8 @@ mod tests {
.expect("dispatch subcommand exists")
.render_help()
.to_string();
// The reworded cloud-agent copy must actually land in --help…
assert!(help.contains("cloud-agent"), "{help}");
assert!(help.contains("--confirm"), "{help}");
// …and no provider brand may leak into it.
assert!(help.contains("--show"), "{help}");
assert!(!help.contains("--own-sandbox"), "{help}");
for banned in ["Daytona", "daytona"] {
assert!(
!help.contains(banned),
+69 -81
View File
@@ -195,6 +195,10 @@ enum Commands {
/// Run Codewhale diagnostics.
Doctor(TuiPassthroughArgs),
/// Summarize local session failure signals without raw content.
#[command(
override_usage = "codewhale session-diagnostics <JSONL>",
after_help = "JSONL is a saved session log; `codewhale sessions` lists saved sessions."
)]
SessionDiagnostics(TuiPassthroughArgs),
/// Score recorded turn metrics against an optional baseline.
Scorecard(TuiPassthroughArgs),
@@ -210,7 +214,11 @@ enum Commands {
Sessions(TuiPassthroughArgs),
/// Show what a session did: files, commands, web and MCP calls, agents,
/// approvals, and failures. `codewhale receipts [ID|--last] [--format md|json]`.
#[command(visible_alias = "receipt")]
#[command(
visible_alias = "receipt",
override_usage = "codewhale receipts [ID|--last] [--format <md|json>]",
after_help = "Options:\n --last Show the most recent session\n --format <FORMAT> Output format: md or json\n\nID is a saved session id or prefix; `codewhale sessions` lists them."
)]
Receipts(TuiPassthroughArgs),
/// Resume a saved session.
Resume(TuiPassthroughArgs),
@@ -319,6 +327,10 @@ lifecycle generation you observed.
#[command(name = "pet", hide = true)]
Pet(TuiPassthroughArgs),
/// Inspect feature flags.
#[command(
override_usage = "codewhale features <COMMAND>",
after_help = "Commands:\n list List feature flags and their state"
)]
Features(TuiPassthroughArgs),
/// Connect third-party harnesses through Codewhale (e.g. `integrations dsh status`).
Integrations(TuiPassthroughArgs),
@@ -360,8 +372,7 @@ New integrations should prefer `codewhale app-server`.")]
/// Manage your Codewhale account and centrally stored provider keys.
#[command(visible_alias = "cloud")]
Account(cloud::CloudArgs),
/// Offload a coding agent to the Codewhale cloud. Never spends or pushes without --confirm.
#[command(visible_alias = "cloud-agent")]
/// Offload a coding agent to the Codewhale cloud through your account.
Dispatch(dispatch::DispatchArgs),
/// Run MCP server mode over stdio.
McpServer,
@@ -616,13 +627,23 @@ struct MetricsArgs {
#[derive(Debug, Args)]
struct RunArgs {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
#[arg(
value_name = "ARGS",
help = "Arguments and options forwarded to the command",
trailing_var_arg = true,
allow_hyphen_values = true
)]
args: Vec<String>,
}
#[derive(Debug, Args, Clone)]
struct TuiPassthroughArgs {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
#[arg(
value_name = "ARGS",
help = "Arguments and options forwarded to the command",
trailing_var_arg = true,
allow_hyphen_values = true
)]
args: Vec<String>,
}
@@ -976,6 +997,16 @@ fn run_lane_control(
lane_id: Option<&str>,
json: bool,
) -> Result<()> {
if matches!(operation, codewhale_lane::ControlOperation::LaneList)
&& codewhale_lane::lane_registry_root().is_ok_and(|root| !root.exists())
{
if json {
println!("[]");
} else {
println!("No lanes yet. Start one with `codewhale lane start`.");
}
return Ok(());
}
let receipt = codewhale_lane::control::execute_lane_control(
codewhale_lane::ControlSurface::Cli,
operation,
@@ -2222,6 +2253,14 @@ fn run() -> Result<()> {
if let Some(warning) = argv_secret_warning(&cli, command.as_ref()) {
eprintln!("{warning}");
}
if matches!(command, None | Some(Commands::Run(_) | Commands::Exec(_))) {
codewhale_config::require_explicit_config_exists(cli.config.clone())?;
if let Some(dir) = cli.workspace.as_ref()
&& !dir.exists()
{
bail!("workspace directory does not exist: {}", dir.display());
}
}
let pipe_api_key_handoff = matches!(
&command,
@@ -2395,6 +2434,11 @@ fn run() -> Result<()> {
run_tui_in_process(&cli, &resolved_runtime, tui_args("eval", args))
}
Some(Commands::SessionDiagnostics(args)) => {
if args.args.is_empty() {
bail!(
"session-diagnostics needs a session log path (<JSONL>); run `codewhale sessions` to list saved sessions"
);
}
let resolved_runtime =
resolve_runtime_for_diagnostic_dispatch(&store, &runtime_overrides);
run_tui_in_process(
@@ -2623,9 +2667,13 @@ fn run() -> Result<()> {
}
Some(Commands::Completion { shell }) => {
let mut stdout = io::stdout();
stdout.write_all(render_completion_script(shell).as_bytes())?;
stdout.flush()?;
Ok(())
let written = stdout
.write_all(render_completion_script(shell).as_bytes())
.and_then(|()| stdout.flush());
match written {
Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(()),
other => other.map_err(Into::into),
}
}
Some(Commands::Metrics(args)) => run_metrics_command(args),
Some(Commands::Update(args)) => {
@@ -3042,10 +3090,10 @@ fn run_logout_command_with_secrets_unlocked(
if let Err(error) = codewhale_config::clear_all_chatgpt_oauth_credentials_locked() {
keyring_failures.push(format!("chatgpt oauth: {error}"));
}
if let Err(error) = clear_daytona_slot(secrets) {
if let Err(error) = clear_legacy_sandbox_slot(secrets) {
keyring_failures.push(format!(
"{}: {error}",
codewhale_secrets::DAYTONA_TOKEN_SLOT
codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT
));
}
if let Err(error) = clear_account_session(profile) {
@@ -3056,12 +3104,12 @@ fn run_logout_command_with_secrets_unlocked(
Ok(keyring_failures)
}
fn clear_daytona_slot(secrets: &Secrets) -> Result<(), codewhale_secrets::SecretsError> {
fn clear_legacy_sandbox_slot(secrets: &Secrets) -> Result<(), codewhale_secrets::SecretsError> {
if secrets
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)?
.get(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT)?
.is_some_and(|value| !value.trim().is_empty())
{
secrets.delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)?;
secrets.delete(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT)?;
}
Ok(())
}
@@ -9707,29 +9755,6 @@ verbosity = "concise"
);
}
#[test]
fn auth_parses_daytona_slot_commands_as_unknown() {
// The internal cloud-agent slot must not be a user command: parsing
// rejects it and `auth --help` never teaches it.
for argv in [
vec![
"codewhale",
"auth",
"set-slot",
"daytona",
"--api-key-stdin",
],
vec!["codewhale", "auth", "clear-slot", "daytona"],
] {
let error = Cli::try_parse_from(argv).expect_err("slot commands must not parse");
assert_eq!(error.kind(), ErrorKind::InvalidSubcommand, "{error}");
}
let help = help_for(&["codewhale", "auth", "--help"]);
assert!(!help.contains("set-slot"), "{help}");
assert!(!help.contains("clear-slot"), "{help}");
assert!(!help.to_lowercase().contains("daytona"), "{help}");
}
/// #5198: `auth set` shares the login resolver — provider auth markers go
/// user-global even when the ambient config is workspace-scoped.
#[test]
@@ -10705,8 +10730,6 @@ verbosity = "concise"
assert!(output.contains("account:"), "{output}");
assert!(output.contains("codewhale login"), "{output}");
// No-brand invariant: the internal cloud-agent slot is not user
// surface, so status never names it or teaches a set-slot command.
assert!(!output.to_lowercase().contains("daytona"), "{output}");
assert!(!output.contains("set-slot"), "{output}");
@@ -12206,7 +12229,7 @@ verbosity = "concise"
for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
keyring.set_value(provider_slot(provider), "test-credential");
}
keyring.set_value(codewhale_secrets::DAYTONA_TOKEN_SLOT, "test-daytona");
keyring.set_value(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT, "test-daytona");
let secrets = Secrets::new(std::sync::Arc::new(keyring));
let error = run_logout_command_with_secrets(&mut store, &secrets, None)
.expect_err("partial logout must fail");
@@ -12227,7 +12250,7 @@ verbosity = "concise"
);
assert!(
secrets
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
.get(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT)
.unwrap()
.is_none()
);
@@ -12310,7 +12333,7 @@ verbosity = "concise"
}
#[test]
fn logout_clears_account_session_and_daytona_slot() {
fn logout_clears_account_session_and_legacy_sandbox_slot() {
use codewhale_secrets::account::{
AccountAuthBundle, AccountSession, AccountSessionStore, AccountUser,
DEFAULT_ACCOUNT_API_BASE, secure_account_session_secrets,
@@ -12329,8 +12352,8 @@ verbosity = "concise"
let secrets = no_keyring_secrets();
secrets
.set(codewhale_secrets::DAYTONA_TOKEN_SLOT, "dtn_logout")
.expect("seed daytona token");
.set(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT, "dtn_logout")
.expect("seed legacy sandbox token");
let account = secure_account_session_secrets().expect("account store");
AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
@@ -12353,10 +12376,10 @@ verbosity = "concise"
assert!(
secrets
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
.expect("read daytona")
.get(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT)
.expect("read legacy sandbox slot")
.is_none(),
"daytona slot survived logout"
"legacy sandbox slot survived logout"
);
let account = secure_account_session_secrets().expect("account store after logout");
assert!(
@@ -12370,41 +12393,6 @@ verbosity = "concise"
let _ = std::fs::remove_file(path);
}
#[test]
fn auth_set_slot_daytona_has_no_user_surface() {
// The internal cloud-agent credential is managed by Codewhale, not
// users: no CLI command may write or clear it, and no help text may
// teach it. Membership (`codewhale login`) is the only door.
use codewhale_secrets::InMemoryKeyringStore;
use std::sync::Arc;
for argv in [
vec![
"codewhale",
"auth",
"set-slot",
"daytona",
"--api-key",
"dtn_saved",
],
vec!["codewhale", "auth", "clear-slot", "daytona"],
] {
assert!(
Cli::try_parse_from(argv).is_err(),
"slot commands must not parse"
);
}
let inner = Arc::new(InMemoryKeyringStore::new());
let secrets = Secrets::new(inner);
assert!(
secrets
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
.expect("read slot")
.is_none()
);
}
#[test]
fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
+10
View File
@@ -6328,6 +6328,16 @@ pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
default_config_path()
}
pub fn require_explicit_config_exists(explicit: Option<PathBuf>) -> Result<()> {
if let Some(path) = explicit {
let path = normalize_config_file_path(path)?;
if !checked_path_exists(&path)? {
bail!("config file does not exist: {}", quote_os_path(&path));
}
}
Ok(())
}
/// Whether `path` names a workspace-scoped config document —
/// `<repo>/.codewhale/config.toml` (or the legacy `.deepseek` layout) inside a
/// checkout — rather than a user-global config file.
+18
View File
@@ -85,6 +85,22 @@ 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, protect: bool, create: bool) -> Result<PrivateDirectory> {
use std::os::fd::FromRawFd as _;
@@ -94,6 +110,8 @@ fn open_owned_directory(directory: &Path, protect: bool, create: bool) -> Result
directory.is_absolute(),
"xAI OAuth credentials directory must be absolute"
);
let resolved = resolve_ancestors(directory);
let directory = resolved.as_path();
// SAFETY: the literal root path contains no interior NUL and the returned
// descriptor is immediately owned by `File`.
let root_fd = unsafe {
+7
View File
@@ -1504,6 +1504,13 @@ pub const SETTINGS_SCHEMA: &[SettingDef] = &[
"false",
None,
),
def("full_access_repos", SettingKind::String, "", None),
def(
"full_access_migration_shown",
SettingKind::Bool(ON_OFF),
"false",
None,
),
// Round 3 work-bar placement migration ran once (`top` → `bottom`).
def(
"work_surface_bottom_migrated",
+2 -2
View File
@@ -1795,7 +1795,7 @@ pub fn analyze_command(command: &str) -> SafetyAnalysis {
vec![
"Run one command at a time".to_string(),
"Write multiline scripts to a file first, then execute the script".to_string(),
"Use task_shell_start or background shell for long interactive flows".to_string(),
"Use the shell tool with background=true for long interactive flows".to_string(),
],
);
}
@@ -3732,7 +3732,7 @@ mod tests {
analysis
.suggestions
.iter()
.any(|suggestion| suggestion.contains("task_shell_start")),
.any(|suggestion| suggestion.contains("background=true")),
"{:?}",
analysis.suggestions
);
+13 -2
View File
@@ -498,7 +498,6 @@
"CmdLspDescription": "Activa o desactiva els diagnòstics LSP",
"CmdShareDescription": "Exporta la sessió actual com a URL web compartible",
"CmdJobsDescription": "Inspecciona i controla els treballs Bash en segon pla",
"CmdDispatchDescription": "Envia un agent cloud de Codewhale per crear una branca i obrir un PR",
"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ó",
@@ -1536,8 +1535,20 @@
"ChipPermissionReadOnly": "només lectura",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · sessió",
"ChipPermissionFullAccessRepo": "Full Access · aquest repo",
"ChipPermissionNever": "mai",
"FullAccessConfirmTitle": "Activar Full Access?",
"FullAccessConfirmRisk": "Codewhale pot editar qualsevol fitxer i executar qualsevol ordre, amb xarxa, sense preguntar. El sandbox de Codewhale està desactivat. Les regles de la constitució i la política gestionada continuen aplicant-se.",
"FullAccessConfirmSession": "Activa per a aquesta sessió",
"FullAccessConfirmRepo": "Mantén per a aquest repo",
"FullAccessConfirmCancel": "Cancel·la",
"FullAccessConfirmKeys": "Enter sessió · R aquest repo · Esc cancel·la",
"FullAccessMigrationTitle": "Full Access ara dura una sessió",
"FullAccessMigrationBody": "Tenies Full Access desat per a tots els projectes. Està actiu en aquesta sessió. Prem R per mantenir-lo en aquest repo; si no, les sessions noves comencen en Ask.",
"FullAccessMigrationKeys": "R aquest repo · Enter OK",
"PermissionsChangedReceipt": "Permisos canviats a {label}",
"ReceiptFullAccessForgotten": "Full Access ja no es manté per a aquest repo",
"FooterHintKeys": "tecles",
"FooterHintOutput": "sortida",
"FooterHintContext": "context",
+13 -2
View File
@@ -498,7 +498,6 @@
"CmdLspDescription": "LSP-Diagnosen ein- oder ausschalten",
"CmdShareDescription": "Aktuelle Sitzung als teilbare Web-URL exportieren",
"CmdJobsDescription": "Bash-Hintergrundjobs ansehen und steuern",
"CmdDispatchDescription": "Codewhale-Cloud-Agenten senden, um einen Branch und PR zu öffnen",
"CmdLinksDescription": "Codewhale-, Community- und Provider-Links anzeigen",
"CmdLoadDescription": "Sitzung aus Datei laden",
"CmdLogoutDescription": "API-Schlüssel löschen und zum Setup zurückkehren",
@@ -1536,8 +1535,20 @@
"ChipPermissionReadOnly": "nur lesen",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · Sitzung",
"ChipPermissionFullAccessRepo": "Full Access · dieses Repo",
"ChipPermissionNever": "nie",
"FullAccessConfirmTitle": "Full Access aktivieren?",
"FullAccessConfirmRisk": "Codewhale kann jede Datei bearbeiten und jeden Befehl mit Netzwerkzugriff ausführen, ohne nachzufragen. Die Codewhale-Sandbox ist aus. Constitution-Regeln und verwaltete Richtlinien gelten weiterhin.",
"FullAccessConfirmSession": "Für diese Sitzung aktivieren",
"FullAccessConfirmRepo": "Für dieses Repo beibehalten",
"FullAccessConfirmCancel": "Abbrechen",
"FullAccessConfirmKeys": "Enter Sitzung · R dieses Repo · Esc abbrechen",
"FullAccessMigrationTitle": "Full Access gilt jetzt nur für eine Sitzung",
"FullAccessMigrationBody": "Full Access war für alle Projekte gespeichert. Es ist für diese Sitzung aktiv. Mit R behältst du es für dieses Repo; neue Sitzungen starten sonst in Ask.",
"FullAccessMigrationKeys": "R dieses Repo · Enter OK",
"PermissionsChangedReceipt": "Berechtigungen geändert auf {label}",
"ReceiptFullAccessForgotten": "Full Access wird für dieses Repo nicht mehr beibehalten",
"FooterHintKeys": "Tasten",
"FooterHintOutput": "Ausgabe",
"FooterHintContext": "Kontext",
+13 -2
View File
@@ -501,7 +501,6 @@
"CmdLspDescription": "Toggle LSP diagnostics",
"CmdShareDescription": "Share this session as a web URL",
"CmdJobsDescription": "Manage background shell jobs",
"CmdDispatchDescription": "Send a Codewhale cloud agent to raise a branch and open a PR",
"CmdLinksDescription": "Show Codewhale, community, and provider links",
"CmdLoadDescription": "Load a session from file",
"CmdLogoutDescription": "Sign out and return to setup",
@@ -1559,8 +1558,20 @@
"ChipPermissionReadOnly": "read only",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · session",
"ChipPermissionFullAccessRepo": "Full Access · this repo",
"ChipPermissionNever": "never",
"FullAccessConfirmTitle": "Turn on Full Access?",
"FullAccessConfirmRisk": "Codewhale can edit any file and run any command, with network, without asking. Codewhale's sandbox is off. Constitution rules and managed policy still apply.",
"FullAccessConfirmSession": "Turn on for this session",
"FullAccessConfirmRepo": "Keep on for this repo",
"FullAccessConfirmCancel": "Cancel",
"FullAccessConfirmKeys": "Enter session · R this repo · Esc cancel",
"FullAccessMigrationTitle": "Full Access now lasts one session",
"FullAccessMigrationBody": "You had Full Access saved for every project. It's on for this session. Press R to keep it on for this repo; otherwise new sessions start in Ask.",
"FullAccessMigrationKeys": "R this repo · Enter OK",
"PermissionsChangedReceipt": "Permissions changed to {label}",
"ReceiptFullAccessForgotten": "Full Access no longer kept for this repo",
"FooterHintKeys": "keys",
"FooterHintOutput": "output",
"FooterHintContext": "context",
+13 -2
View File
@@ -501,7 +501,6 @@
"CmdLspDescription": "Alternar diagnóstico LSP encendido o apagado",
"CmdShareDescription": "Exportar la sesión actual como una URL web compartible",
"CmdJobsDescription": "Inspeccionar y controlar trabajos de shell en segundo plano",
"CmdDispatchDescription": "Enviar un agente cloud de Codewhale para crear una rama y abrir un PR",
"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",
@@ -1559,8 +1558,20 @@
"ChipPermissionReadOnly": "solo lectura",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · sesión",
"ChipPermissionFullAccessRepo": "Full Access · este repo",
"ChipPermissionNever": "never",
"FullAccessConfirmTitle": "¿Activar Full Access?",
"FullAccessConfirmRisk": "Codewhale puede editar cualquier archivo y ejecutar cualquier comando, con red, sin preguntar. El sandbox de Codewhale está desactivado. Las reglas de la constitución y la política administrada siguen aplicando.",
"FullAccessConfirmSession": "Activar para esta sesión",
"FullAccessConfirmRepo": "Mantener para este repo",
"FullAccessConfirmCancel": "Cancelar",
"FullAccessConfirmKeys": "Enter sesión · R este repo · Esc cancelar",
"FullAccessMigrationTitle": "Full Access ahora dura una sesión",
"FullAccessMigrationBody": "Tenías Full Access guardado para todos los proyectos. Está activo en esta sesión. Pulsa R para mantenerlo en este repo; si no, las sesiones nuevas empiezan en Ask.",
"FullAccessMigrationKeys": "R este repo · Enter OK",
"PermissionsChangedReceipt": "Permisos cambiados a {label}",
"ReceiptFullAccessForgotten": "Full Access ya no se mantiene para este repo",
"FooterHintKeys": "teclas",
"FooterHintOutput": "salida",
"FooterHintContext": "contexto",
+13 -2
View File
@@ -498,7 +498,6 @@
"CmdLspDescription": "Activer ou désactiver les diagnostics LSP",
"CmdShareDescription": "Exporter la session en cours en URL web partageable",
"CmdJobsDescription": "Inspecter et contrôler les jobs Bash en arrière-plan",
"CmdDispatchDescription": "Envoyer un agent cloud Codewhale pour créer une branche et ouvrir une PR",
"CmdLinksDescription": "Afficher les liens Codewhale, communauté et fournisseurs",
"CmdLoadDescription": "Charger une session depuis un fichier",
"CmdLogoutDescription": "Effacer la clé API et revenir à la configuration",
@@ -1536,8 +1535,20 @@
"ChipPermissionReadOnly": "lecture seule",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · session",
"ChipPermissionFullAccessRepo": "Full Access · ce dépôt",
"ChipPermissionNever": "jamais",
"FullAccessConfirmTitle": "Activer Full Access ?",
"FullAccessConfirmRisk": "Codewhale peut modifier n'importe quel fichier et exécuter n'importe quelle commande, avec accès réseau, sans demander. Le bac à sable de Codewhale est désactivé. Les règles de la constitution et la politique gérée s'appliquent toujours.",
"FullAccessConfirmSession": "Activer pour cette session",
"FullAccessConfirmRepo": "Garder pour ce dépôt",
"FullAccessConfirmCancel": "Annuler",
"FullAccessConfirmKeys": "Entrée session · R ce dépôt · Échap annuler",
"FullAccessMigrationTitle": "Full Access ne dure plus qu'une session",
"FullAccessMigrationBody": "Full Access était enregistré pour tous les projets. Il est actif pour cette session. Appuyez sur R pour le garder pour ce dépôt ; sinon les nouvelles sessions démarrent en Ask.",
"FullAccessMigrationKeys": "R ce dépôt · Entrée OK",
"PermissionsChangedReceipt": "Permissions changées en {label}",
"ReceiptFullAccessForgotten": "Full Access n'est plus conservé pour ce dépôt",
"FooterHintKeys": "touches",
"FooterHintOutput": "sortie",
"FooterHintContext": "contexte",
+13 -2
View File
@@ -498,7 +498,6 @@
"CmdLspDescription": "LSP डायग्नोस्टिक्स चालू/बंद करें",
"CmdShareDescription": "वर्तमान सत्र को साझा करने योग्य वेब URL के रूप में एक्सपोर्ट करें",
"CmdJobsDescription": "बैकग्राउंड Bash जॉब देखें और नियंत्रित करें",
"CmdDispatchDescription": "शाखा बनाने और PR खोलने के लिए Codewhale क्लाउड एजेंट भेजें",
"CmdLinksDescription": "Codewhale, समुदाय और प्रोवाइडर लिंक दिखाएँ",
"CmdLoadDescription": "फ़ाइल से सत्र लोड करें",
"CmdLogoutDescription": "API कुंजी साफ़ करें और सेटअप पर लौटें",
@@ -1536,8 +1535,20 @@
"ChipPermissionReadOnly": "केवल पठन",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · सत्र",
"ChipPermissionFullAccessRepo": "Full Access · यह रेपो",
"ChipPermissionNever": "कभी नहीं",
"FullAccessConfirmTitle": "Full Access चालू करें?",
"FullAccessConfirmRisk": "Codewhale बिना पूछे कोई भी फ़ाइल बदल सकता है और नेटवर्क के साथ कोई भी कमांड चला सकता है। Codewhale का सैंडबॉक्स बंद है। Constitution नियम और प्रबंधित नीति अब भी लागू रहते हैं।",
"FullAccessConfirmSession": "इस सत्र के लिए चालू करें",
"FullAccessConfirmRepo": "इस रेपो के लिए चालू रखें",
"FullAccessConfirmCancel": "रद्द करें",
"FullAccessConfirmKeys": "Enter सत्र · R यह रेपो · Esc रद्द",
"FullAccessMigrationTitle": "Full Access अब केवल एक सत्र तक रहता है",
"FullAccessMigrationBody": "आपने सभी प्रोजेक्ट के लिए Full Access सहेजा था। यह इस सत्र के लिए चालू है। इस रेपो के लिए चालू रखने हेतु R दबाएँ; वरना नए सत्र Ask में शुरू होंगे।",
"FullAccessMigrationKeys": "R यह रेपो · Enter ठीक",
"PermissionsChangedReceipt": "अनुमतियाँ बदलकर {label} हुईं",
"ReceiptFullAccessForgotten": "Full Access अब इस रेपो के लिए नहीं रखा गया",
"FooterHintKeys": "कुंजियाँ",
"FooterHintOutput": "आउटपुट",
"FooterHintContext": "कॉन्टेक्स्ट",
+13 -2
View File
@@ -498,7 +498,6 @@
"CmdLspDescription": "Aktifkan atau nonaktifkan diagnostik LSP",
"CmdShareDescription": "Ekspor sesi saat ini sebagai URL web yang dapat dibagikan",
"CmdJobsDescription": "Periksa dan kendalikan job Bash latar belakang",
"CmdDispatchDescription": "Kirim agen cloud Codewhale untuk membuat branch dan membuka PR",
"CmdLinksDescription": "Tampilkan tautan Codewhale, komunitas, dan penyedia",
"CmdLoadDescription": "Muat sesi dari file",
"CmdLogoutDescription": "Hapus kunci API dan kembali ke penyiapan",
@@ -1536,8 +1535,20 @@
"ChipPermissionReadOnly": "baca saja",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · sesi",
"ChipPermissionFullAccessRepo": "Full Access · repo ini",
"ChipPermissionNever": "never",
"FullAccessConfirmTitle": "Aktifkan Full Access?",
"FullAccessConfirmRisk": "Codewhale dapat mengedit file apa pun dan menjalankan perintah apa pun, dengan jaringan, tanpa bertanya. Sandbox Codewhale mati. Aturan konstitusi dan kebijakan terkelola tetap berlaku.",
"FullAccessConfirmSession": "Aktifkan untuk sesi ini",
"FullAccessConfirmRepo": "Pertahankan untuk repo ini",
"FullAccessConfirmCancel": "Batal",
"FullAccessConfirmKeys": "Enter sesi · R repo ini · Esc batal",
"FullAccessMigrationTitle": "Full Access kini hanya berlaku satu sesi",
"FullAccessMigrationBody": "Full Access sebelumnya tersimpan untuk semua proyek. Kini aktif untuk sesi ini. Tekan R untuk mempertahankannya di repo ini; jika tidak, sesi baru dimulai dengan Ask.",
"FullAccessMigrationKeys": "R repo ini · Enter OK",
"PermissionsChangedReceipt": "Izin diubah ke {label}",
"ReceiptFullAccessForgotten": "Full Access tidak lagi dipertahankan untuk repo ini",
"FooterHintKeys": "tombol",
"FooterHintOutput": "output",
"FooterHintContext": "konteks",
+13 -2
View File
@@ -501,7 +501,6 @@
"CmdLspDescription": "LSP 診断のオン・オフを切り替え",
"CmdShareDescription": "現在のセッションを共有可能な Web URL としてエクスポート",
"CmdJobsDescription": "バックグラウンドのシェルジョブを確認・制御",
"CmdDispatchDescription": "Codewhale クラウドエージェントを送り、ブランチと PR を作成",
"CmdLinksDescription": "Codewhale、コミュニティ、プロバイダーのリンクを表示",
"CmdLoadDescription": "ファイルからセッションを読み込み",
"CmdLogoutDescription": "API キーを消去してセットアップに戻る",
@@ -1559,8 +1558,20 @@
"ChipPermissionReadOnly": "読み取り専用",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · セッション",
"ChipPermissionFullAccessRepo": "Full Access · このリポジトリ",
"ChipPermissionNever": "never",
"FullAccessConfirmTitle": "Full Access を有効にしますか?",
"FullAccessConfirmRisk": "Codewhale は確認なしで任意のファイルを編集し、ネットワークを使って任意のコマンドを実行できます。Codewhale のサンドボックスはオフです。Constitution のルールと管理ポリシーは引き続き適用されます。",
"FullAccessConfirmSession": "このセッションだけ有効にする",
"FullAccessConfirmRepo": "このリポジトリで有効のままにする",
"FullAccessConfirmCancel": "キャンセル",
"FullAccessConfirmKeys": "Enter セッション · R このリポジトリ · Esc キャンセル",
"FullAccessMigrationTitle": "Full Access は1セッションのみになりました",
"FullAccessMigrationBody": "すべてのプロジェクトで Full Access を保存していました。このセッションでは有効です。このリポジトリで維持するには R を押してください。それ以外は新しいセッションが Ask で始まります。",
"FullAccessMigrationKeys": "R このリポジトリ · Enter OK",
"PermissionsChangedReceipt": "権限を {label} に変更しました",
"ReceiptFullAccessForgotten": "このリポジトリでの Full Access の維持を解除しました",
"FooterHintKeys": "キー",
"FooterHintOutput": "出力",
"FooterHintContext": "コンテキスト",
+13 -2
View File
@@ -501,7 +501,6 @@
"CmdLspDescription": "LSP 진단을 켜거나 끕니다",
"CmdShareDescription": "현재 세션을 공유 가능한 웹 URL로 내보냅니다",
"CmdJobsDescription": "백그라운드 Bash 작업을 확인하고 제어합니다",
"CmdDispatchDescription": "Codewhale 클라우드 에이전트를 보내 브랜치를 만들고 PR을 엽니다",
"CmdLinksDescription": "Codewhale, 커뮤니티 및 프로바이더 링크를 표시합니다",
"CmdLoadDescription": "파일에서 세션을 불러옵니다",
"CmdLogoutDescription": "API 키를 지우고 설정 화면으로 돌아갑니다",
@@ -1559,8 +1558,20 @@
"ChipPermissionReadOnly": "읽기 전용",
"ChipPermissionAsk": "확인",
"ChipPermissionAuto": "자동",
"ChipPermissionFullAccess": "전체 권한",
"ChipPermissionFullAccessSession": "전체 권한 · 세션",
"ChipPermissionFullAccessRepo": "전체 권한 · 이 저장소",
"ChipPermissionNever": "묻지 않음",
"FullAccessConfirmTitle": "전체 권한을 켤까요?",
"FullAccessConfirmRisk": "Codewhale가 묻지 않고 모든 파일을 수정하고 네트워크로 모든 명령을 실행할 수 있습니다. Codewhale 샌드박스는 꺼져 있습니다. Constitution 규칙과 관리형 정책은 계속 적용됩니다.",
"FullAccessConfirmSession": "이 세션에서만 켜기",
"FullAccessConfirmRepo": "이 저장소에서 계속 켜 두기",
"FullAccessConfirmCancel": "취소",
"FullAccessConfirmKeys": "Enter 세션 · R 이 저장소 · Esc 취소",
"FullAccessMigrationTitle": "전체 권한은 이제 한 세션만 유지됩니다",
"FullAccessMigrationBody": "모든 프로젝트에 전체 권한이 저장되어 있었습니다. 이번 세션에서는 켜져 있습니다. 이 저장소에서 유지하려면 R을 누르세요. 아니면 새 세션은 Ask로 시작합니다.",
"FullAccessMigrationKeys": "R 이 저장소 · Enter 확인",
"PermissionsChangedReceipt": "권한이 {label}(으)로 변경되었습니다",
"ReceiptFullAccessForgotten": "이 저장소에서 전체 권한 유지를 해제했습니다",
"FooterHintKeys": "단축키",
"FooterHintOutput": "출력",
"FooterHintContext": "컨텍스트",
+13 -2
View File
@@ -501,7 +501,6 @@
"CmdLspDescription": "Alternar diagnóstico LSP ligado ou desligado",
"CmdShareDescription": "Exportar a sessão atual como uma URL web compartilhável",
"CmdJobsDescription": "Inspecionar e controlar jobs de shell em segundo plano",
"CmdDispatchDescription": "Enviar um agente cloud da Codewhale para criar um branch e abrir um PR",
"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",
@@ -1559,8 +1558,20 @@
"ChipPermissionReadOnly": "só leitura",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · sessão",
"ChipPermissionFullAccessRepo": "Full Access · este repo",
"ChipPermissionNever": "nunca",
"FullAccessConfirmTitle": "Ativar Full Access?",
"FullAccessConfirmRisk": "O Codewhale pode editar qualquer arquivo e executar qualquer comando, com rede, sem perguntar. O sandbox do Codewhale está desligado. As regras da constituição e a política gerenciada continuam valendo.",
"FullAccessConfirmSession": "Ativar para esta sessão",
"FullAccessConfirmRepo": "Manter para este repo",
"FullAccessConfirmCancel": "Cancelar",
"FullAccessConfirmKeys": "Enter sessão · R este repo · Esc cancelar",
"FullAccessMigrationTitle": "Full Access agora dura uma sessão",
"FullAccessMigrationBody": "Você tinha o Full Access salvo para todos os projetos. Ele está ativo nesta sessão. Pressione R para mantê-lo neste repo; caso contrário, novas sessões começam em Ask.",
"FullAccessMigrationKeys": "R este repo · Enter OK",
"PermissionsChangedReceipt": "Permissões alteradas para {label}",
"ReceiptFullAccessForgotten": "Full Access não é mais mantido para este repo",
"FooterHintKeys": "teclas",
"FooterHintOutput": "saída",
"FooterHintContext": "contexto",
+13 -2
View File
@@ -498,7 +498,6 @@
"CmdLspDescription": "Включить или выключить диагностику LSP",
"CmdShareDescription": "Экспортировать текущую сессию как веб-ссылку для общего доступа",
"CmdJobsDescription": "Просмотр и управление фоновыми заданиями Bash",
"CmdDispatchDescription": "Отправить облачного агента Codewhale, чтобы создать ветку и открыть PR",
"CmdLinksDescription": "Показать ссылки на Codewhale, сообщество и провайдеров",
"CmdLoadDescription": "Загрузить сессию из файла",
"CmdLogoutDescription": "Удалить API-ключ и вернуться к настройке",
@@ -1536,8 +1535,20 @@
"ChipPermissionReadOnly": "только чтение",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · сессия",
"ChipPermissionFullAccessRepo": "Full Access · этот репозиторий",
"ChipPermissionNever": "никогда",
"FullAccessConfirmTitle": "Включить Full Access?",
"FullAccessConfirmRisk": "Codewhale сможет изменять любые файлы и выполнять любые команды с доступом к сети без запроса. Песочница Codewhale отключена. Правила конституции и управляемая политика по-прежнему действуют.",
"FullAccessConfirmSession": "Включить на эту сессию",
"FullAccessConfirmRepo": "Оставить для этого репозитория",
"FullAccessConfirmCancel": "Отмена",
"FullAccessConfirmKeys": "Enter сессия · R этот репозиторий · Esc отмена",
"FullAccessMigrationTitle": "Full Access теперь действует одну сессию",
"FullAccessMigrationBody": "Full Access был сохранён для всех проектов. Он включён на эту сессию. Нажмите R, чтобы оставить его для этого репозитория; иначе новые сессии начнутся в Ask.",
"FullAccessMigrationKeys": "R этот репозиторий · Enter ОК",
"PermissionsChangedReceipt": "Разрешения изменены: {label}",
"ReceiptFullAccessForgotten": "Full Access больше не сохраняется для этого репозитория",
"FooterHintKeys": "клавиши",
"FooterHintOutput": "вывод",
"FooterHintContext": "контекст",
+13 -2
View File
@@ -498,7 +498,6 @@
"CmdLspDescription": "Увімкнути або вимкнути діагностику LSP",
"CmdShareDescription": "Експортувати поточний сеанс як веб-URL для поширення",
"CmdJobsDescription": "Переглянути й керувати фоновими завданнями Bash",
"CmdDispatchDescription": "Надіслати хмарного агента Codewhale, щоб створити гілку й відкрити PR",
"CmdLinksDescription": "Показати посилання Codewhale, спільноти та провайдерів",
"CmdLoadDescription": "Завантажити сеанс з файлу",
"CmdLogoutDescription": "Очистити ключ API та повернутися до налаштування",
@@ -1536,8 +1535,20 @@
"ChipPermissionReadOnly": "лише читання",
"ChipPermissionAsk": "питати",
"ChipPermissionAuto": "авто",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · сесія",
"ChipPermissionFullAccessRepo": "Full Access · цей репозиторій",
"ChipPermissionNever": "ніколи",
"FullAccessConfirmTitle": "Увімкнути Full Access?",
"FullAccessConfirmRisk": "Codewhale зможе змінювати будь-які файли та виконувати будь-які команди з доступом до мережі без запиту. Пісочницю Codewhale вимкнено. Правила конституції та керована політика й надалі діють.",
"FullAccessConfirmSession": "Увімкнути на цю сесію",
"FullAccessConfirmRepo": "Залишити для цього репозиторію",
"FullAccessConfirmCancel": "Скасувати",
"FullAccessConfirmKeys": "Enter сесія · R цей репозиторій · Esc скасувати",
"FullAccessMigrationTitle": "Full Access тепер діє одну сесію",
"FullAccessMigrationBody": "Full Access було збережено для всіх проєктів. Його ввімкнено на цю сесію. Натисніть R, щоб залишити його для цього репозиторію; інакше нові сесії почнуться в Ask.",
"FullAccessMigrationKeys": "R цей репозиторій · Enter OK",
"PermissionsChangedReceipt": "Дозволи змінено: {label}",
"ReceiptFullAccessForgotten": "Full Access більше не зберігається для цього репозиторію",
"FooterHintKeys": "клавіші",
"FooterHintOutput": "вивід",
"FooterHintContext": "контекст",
+13 -2
View File
@@ -501,7 +501,6 @@
"CmdLspDescription": "Bật hoặc tắt tính năng chẩn đoán LSP",
"CmdShareDescription": "Xuất phiên hiện tại thành một liên kết web có thể chia sẻ",
"CmdJobsDescription": "Kiểm tra và kiểm soát các lệnh chạy ngầm",
"CmdDispatchDescription": "Gửi tác nhân đám mây Codewhale để tạo nhánh và mở PR",
"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",
@@ -1559,8 +1558,20 @@
"ChipPermissionReadOnly": "chỉ đọc",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "Full Access · phiên",
"ChipPermissionFullAccessRepo": "Full Access · repo này",
"ChipPermissionNever": "never",
"FullAccessConfirmTitle": "Bật Full Access?",
"FullAccessConfirmRisk": "Codewhale có thể sửa mọi tệp và chạy mọi lệnh, có mạng, mà không hỏi. Sandbox của Codewhale đã tắt. Quy tắc hiến chương và chính sách được quản lý vẫn áp dụng.",
"FullAccessConfirmSession": "Bật cho phiên này",
"FullAccessConfirmRepo": "Giữ bật cho repo này",
"FullAccessConfirmCancel": "Huỷ",
"FullAccessConfirmKeys": "Enter phiên · R repo này · Esc huỷ",
"FullAccessMigrationTitle": "Full Access giờ chỉ kéo dài một phiên",
"FullAccessMigrationBody": "Bạn đã lưu Full Access cho mọi dự án. Nó đang bật cho phiên này. Nhấn R để giữ bật cho repo này; nếu không, các phiên mới bắt đầu ở Ask.",
"FullAccessMigrationKeys": "R repo này · Enter OK",
"PermissionsChangedReceipt": "Quyền đã đổi thành {label}",
"ReceiptFullAccessForgotten": "Full Access không còn được giữ cho repo này",
"FooterHintKeys": "phím",
"FooterHintOutput": "đầu ra",
"FooterHintContext": "ngữ cảnh",
+13 -2
View File
@@ -501,7 +501,6 @@
"CmdLspDescription": "切换 LSP 诊断的开启或关闭",
"CmdShareDescription": "将当前会话导出为可共享的 Web URL",
"CmdJobsDescription": "查看并管理后台 shell 作业",
"CmdDispatchDescription": "派遣 Codewhale 云端智能体以创建分支并打开拉取请求",
"CmdLinksDescription": "显示 Codewhale、社区与提供商链接",
"CmdLoadDescription": "从文件加载会话",
"CmdLogoutDescription": "清除 API 密钥并返回设置",
@@ -1559,8 +1558,20 @@
"ChipPermissionReadOnly": "只读",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "完全访问 · 本会话",
"ChipPermissionFullAccessRepo": "完全访问 · 此仓库",
"ChipPermissionNever": "never",
"FullAccessConfirmTitle": "开启完全访问?",
"FullAccessConfirmRisk": "Codewhale 可以不经询问编辑任何文件、联网运行任何命令。Codewhale 沙箱已关闭。Constitution 规则和托管策略仍然生效。",
"FullAccessConfirmSession": "仅本会话开启",
"FullAccessConfirmRepo": "在此仓库保持开启",
"FullAccessConfirmCancel": "取消",
"FullAccessConfirmKeys": "Enter 本会话 · R 此仓库 · Esc 取消",
"FullAccessMigrationTitle": "完全访问现在仅限一个会话",
"FullAccessMigrationBody": "你之前为所有项目保存了完全访问。本会话中它已开启。按 R 在此仓库保持开启;否则新会话将从 Ask 开始。",
"FullAccessMigrationKeys": "R 此仓库 · Enter 确定",
"PermissionsChangedReceipt": "权限已更改为 {label}",
"ReceiptFullAccessForgotten": "此仓库不再保持完全访问",
"FooterHintKeys": "快捷键",
"FooterHintOutput": "输出",
"FooterHintContext": "上下文",
+13 -2
View File
@@ -499,7 +499,6 @@
"CmdLspDescription": "切換 LSP 診斷的開啟或關閉",
"CmdShareDescription": "將目前工作階段匯出為可共享的 Web URL",
"CmdJobsDescription": "檢視並管理後台 shell 作業",
"CmdDispatchDescription": "派遣 Codewhale 雲端代理以建立分支並開啟拉取請求",
"CmdLinksDescription": "顯示 Codewhale、社群與供應商連結",
"CmdLoadDescription": "從檔案載入工作階段",
"CmdLogoutDescription": "清除 API 金鑰並返回設定",
@@ -1557,8 +1556,20 @@
"ChipPermissionReadOnly": "唯讀",
"ChipPermissionAsk": "ask",
"ChipPermissionAuto": "auto",
"ChipPermissionFullAccess": "Full Access",
"ChipPermissionFullAccessSession": "完整存取 · 本工作階段",
"ChipPermissionFullAccessRepo": "完整存取 · 此儲存庫",
"ChipPermissionNever": "never",
"FullAccessConfirmTitle": "開啟完整存取?",
"FullAccessConfirmRisk": "Codewhale 可以不經詢問編輯任何檔案、連網執行任何指令。Codewhale 沙箱已關閉。Constitution 規則與受管政策仍然生效。",
"FullAccessConfirmSession": "僅本工作階段開啟",
"FullAccessConfirmRepo": "在此儲存庫保持開啟",
"FullAccessConfirmCancel": "取消",
"FullAccessConfirmKeys": "Enter 本工作階段 · R 此儲存庫 · Esc 取消",
"FullAccessMigrationTitle": "完整存取現在僅限一個工作階段",
"FullAccessMigrationBody": "你先前為所有專案儲存了完整存取。本工作階段中已開啟。按 R 在此儲存庫保持開啟;否則新工作階段將從 Ask 開始。",
"FullAccessMigrationKeys": "R 此儲存庫 · Enter 確定",
"PermissionsChangedReceipt": "權限已變更為 {label}",
"ReceiptFullAccessForgotten": "此儲存庫不再保持完整存取",
"FooterHintKeys": "快捷鍵",
"FooterHintOutput": "輸出",
"FooterHintContext": "上下文",
+26 -4
View File
@@ -544,7 +544,6 @@ pub enum MessageId {
GoalContinuationStopped,
CmdInitDescription,
CmdJobsDescription,
CmdDispatchDescription,
CmdLinksDescription,
CmdLoadDescription,
CmdLogoutDescription,
@@ -1849,8 +1848,20 @@ pub enum MessageId {
ChipPermissionReadOnly,
ChipPermissionAsk,
ChipPermissionAuto,
ChipPermissionFullAccess,
ChipPermissionFullAccessSession,
ChipPermissionFullAccessRepo,
ChipPermissionNever,
FullAccessConfirmTitle,
FullAccessConfirmRisk,
FullAccessConfirmSession,
FullAccessConfirmRepo,
FullAccessConfirmCancel,
FullAccessConfirmKeys,
FullAccessMigrationTitle,
FullAccessMigrationBody,
FullAccessMigrationKeys,
PermissionsChangedReceipt,
ReceiptFullAccessForgotten,
// Underwater footer right-hand hint words (keys stay literal in code).
FooterHintKeys,
FooterHintOutput,
@@ -3178,7 +3189,6 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CmdAgentDescription,
MessageId::CmdInitDescription,
MessageId::CmdJobsDescription,
MessageId::CmdDispatchDescription,
MessageId::CmdLinksDescription,
MessageId::CmdLoadDescription,
MessageId::CmdLogoutDescription,
@@ -4413,8 +4423,20 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::ChipPermissionReadOnly,
MessageId::ChipPermissionAsk,
MessageId::ChipPermissionAuto,
MessageId::ChipPermissionFullAccess,
MessageId::ChipPermissionFullAccessSession,
MessageId::ChipPermissionFullAccessRepo,
MessageId::ChipPermissionNever,
MessageId::FullAccessConfirmTitle,
MessageId::FullAccessConfirmRisk,
MessageId::FullAccessConfirmSession,
MessageId::FullAccessConfirmRepo,
MessageId::FullAccessConfirmCancel,
MessageId::FullAccessConfirmKeys,
MessageId::FullAccessMigrationTitle,
MessageId::FullAccessMigrationBody,
MessageId::FullAccessMigrationKeys,
MessageId::PermissionsChangedReceipt,
MessageId::ReceiptFullAccessForgotten,
MessageId::FooterHintKeys,
MessageId::FooterHintOutput,
MessageId::FooterHintContext,
-772
View File
@@ -1,772 +0,0 @@
//! Provider-accepted Computer active-second receipts.
//!
//! **Scope: this module is not billing input.** The control plane in
//! codewhale-apps is the billing authority. It writes sandbox state intervals
//! itself and derives receipts server-side from them; it never accepts a
//! receipt from a client, correctly, because the CLI runs on the customer's
//! machine. Anything issued here is a local display or self-check.
//!
//! "Codewhale is the billing authority" below is a statement about *what is
//! billable*, not about which service decides: entitlement moves for
//! provider-accepted **active** seconds rather than Daytona's provisioned wall
//! clock. An audit read it as core-versus-control-plane and carried that
//! misreading into codewhale-apps, where it shaped a PR before it was caught.
//!
//! Codewhale is the billing authority over the *measure*. Daytona (or a future
//! adapter) supplies infrastructure only. Entitlement moves solely for
//! provider-accepted *active* seconds, per second, multiplied by the selected
//! profile's 1x/2x/4x rate.
//!
//! **Unwired as of 2026-09-01.** The only consumer, `cloud_dispatch::
//! meter_cloud_job`, is itself called only from `#[cfg(test)] mod tests`
//! (cloud_dispatch.rs:1210 and :1227, inside the module opening at :917). No
//! `ComputerMeterReceipt` is transmitted anywhere. This is a complete
//! implementation that was never connected; it must be deliberately wired as a
//! local self-check or deleted, because a dead module that reads as
//! authoritative is exactly how the drift above happened.
//!
//! Wall-clock-if-idle, requested, queued, rejected, stopped, suspended,
//! archived, failed-before-acceptance, and teardown-tail time cannot mint a
//! receipt. Provider-observed CPU/RAM/disk must equal the admitted profile.
//! Corrections are append-only and preserve the original receipt.
use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::hashing::sha256_hex;
/// Pinned v3 meter revision. Bump only with a catalog owner change.
pub const COMPUTER_METER_REVISION: &str = "computer-meter-v3.20260831";
/// Pinned v3 profile catalog revision.
pub const COMPUTER_CATALOG_REVISION: &str = "computer-profiles-v3.20260831";
/// Admission record schema.
pub const COMPUTER_ADMISSION_SCHEMA: &str = "codewhale.computer-admission/v1";
/// Meter receipt schema.
pub const COMPUTER_METER_RECEIPT_SCHEMA: &str = "codewhale.computer-meter-receipt/v1";
/// Receipt kind for one accepted active interval.
pub const COMPUTER_METER_RECEIPT_KIND: &str = "computer.meter.active_seconds";
const ADMISSION_DIGEST_NS: &str = "codewhale/computer-admission/v1";
const RECEIPT_DIGEST_NS: &str = "codewhale/computer-meter-receipt/v1";
const MAX_REF_CHARS: usize = 240;
/// Ratified v3 launch profiles. Historic `standard`/`large`/`xl` decode only.
pub const COMPUTER_PROFILES: [ComputerProfile; 3] = [
ComputerProfile {
id: ComputerProfileId::Standard8,
label: "8 GB",
cpu: 2,
memory_gib: 8,
disk_gib: 8,
multiplier: 1,
},
ComputerProfile {
id: ComputerProfileId::Standard16,
label: "16 GB",
cpu: 4,
memory_gib: 16,
disk_gib: 16,
multiplier: 2,
},
ComputerProfile {
id: ComputerProfileId::Standard32,
label: "32 GB",
cpu: 8,
memory_gib: 32,
disk_gib: 32,
multiplier: 4,
},
];
/// Selectable, quotable, creatable v3 Computer profile.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ComputerProfileId {
/// 2 vCPU / 8 GiB RAM / 8 GiB disk at 1x.
#[serde(rename = "standard-8")]
Standard8,
/// 4 vCPU / 16 GiB RAM / 16 GiB disk at 2x.
#[serde(rename = "standard-16")]
Standard16,
/// 8 vCPU / 32 GiB RAM / 32 GiB disk at 4x.
#[serde(rename = "standard-32")]
Standard32,
}
impl ComputerProfileId {
/// Stable catalog id.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Standard8 => "standard-8",
Self::Standard16 => "standard-16",
Self::Standard32 => "standard-32",
}
}
}
/// Fixed resource envelope and allowance multiplier.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ComputerProfile {
/// Catalog id.
pub id: ComputerProfileId,
/// Customer label.
pub label: &'static str,
/// vCPU count.
pub cpu: u32,
/// RAM in GiB.
#[serde(rename = "memoryGiB")]
pub memory_gib: u32,
/// Disk in GiB.
#[serde(rename = "diskGiB")]
pub disk_gib: u32,
/// Standard-equivalent multiplier (1, 2, or 4).
pub multiplier: u32,
}
/// How an interval claims to have been measured.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MeterBasis {
/// Provider confirmed the allocation was actively running.
ProviderAcceptedActive,
/// Wall-clock elapsed time. Never entitlement.
WallClock,
}
/// Immutable pre-dispatch binding. CWC remains the commercial owner; Engine
/// refuses to meter anything that is not this record.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComputerAdmission {
/// Schema id.
pub schema_id: String,
/// Caller-supplied admission identity.
pub admission_id: String,
/// Account that funds the Computer.
pub account_id: String,
/// Durable Computer id.
pub computer_id: String,
/// Optional run this admission authorizes.
#[serde(default)]
pub run_id: String,
/// Infrastructure provider (currently `daytona`).
pub provider: String,
/// Selected v3 profile.
pub profile_id: ComputerProfileId,
/// Bound vCPU.
pub cpu: u32,
/// Bound RAM GiB.
#[serde(rename = "memoryGiB")]
pub memory_gib: u32,
/// Bound disk GiB.
#[serde(rename = "diskGiB")]
pub disk_gib: u32,
/// Bound multiplier.
pub multiplier: u32,
/// Meter revision at bind time.
pub meter_revision: String,
/// Catalog revision at bind time.
pub catalog_revision: String,
/// Funding authority (membership included seconds or a time pack).
pub funding_authority: String,
/// Quote identity bound before dispatch.
pub quote_id: String,
/// Admission expiry (inclusive bound is refused).
pub expires_at: String,
/// Digest of the bound fields.
pub binding_digest: String,
}
/// Provider-observed allocation at one instant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderAllocation {
/// Provider name.
pub provider: String,
/// Provider sandbox / allocation id.
pub provider_sandbox_id: String,
/// Observed vCPU.
pub cpu: u32,
/// Observed RAM GiB.
#[serde(rename = "memoryGiB", alias = "memoryGb")]
pub memory_gib: u32,
/// Observed disk GiB.
#[serde(rename = "diskGiB", alias = "diskGb")]
pub disk_gib: u32,
/// Provider lifecycle state.
pub state: String,
/// Whether the provider accepted this as a live allocation.
pub accepted: bool,
}
/// One closed provider observation used to mint a receipt.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderObservation {
/// Infrastructure provider.
pub provider: String,
/// Provider sandbox / allocation id.
pub provider_sandbox_id: String,
/// Idempotent provider event / interval reference.
pub provider_event_ref: String,
/// Provider lifecycle state.
pub state: String,
/// True when the allocation is allocated but idle.
#[serde(default)]
pub idle: bool,
/// True only after the provider accepted the live allocation.
#[serde(default)]
pub provider_accepted: bool,
/// Measurement basis. Wall-clock is never entitlement.
pub meter_basis: MeterBasis,
/// Observed vCPU.
pub cpu: u32,
/// Observed RAM GiB.
#[serde(rename = "memoryGiB", alias = "memoryGb")]
pub memory_gib: u32,
/// Observed disk GiB.
#[serde(rename = "diskGiB", alias = "diskGb")]
pub disk_gib: u32,
/// Interval start (inclusive).
pub started_at: String,
/// Interval end (exclusive).
pub ended_at: String,
}
/// Immutable receipt for one provider-accepted active interval.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComputerMeterReceipt {
/// Schema id.
pub schema_id: String,
/// Receipt identity derived from the bound work.
pub receipt_id: String,
/// Receipt kind.
pub kind: String,
/// Funding account.
pub account_id: String,
/// Run, when the interval is run-scoped.
#[serde(default)]
pub run_id: String,
/// Computer id.
pub computer_id: String,
/// Admission this interval was authorized under.
pub admission_id: String,
/// Provider name.
pub provider: String,
/// Profile billed at.
pub profile_id: ComputerProfileId,
/// Multiplier billed at.
pub multiplier: u32,
/// Bound vCPU.
pub cpu: u32,
/// Bound RAM GiB.
#[serde(rename = "memoryGiB")]
pub memory_gib: u32,
/// Bound disk GiB.
#[serde(rename = "diskGiB")]
pub disk_gib: u32,
/// Meter revision.
pub meter_revision: String,
/// Catalog revision.
pub catalog_revision: String,
/// Funding authority copied from admission.
pub funding_authority: String,
/// Quote identity copied from admission.
pub quote_id: String,
/// Interval start.
pub started_at: String,
/// Interval end.
pub ended_at: String,
/// Provider-accepted active whole seconds.
pub accepted_seconds: u64,
/// `accepted_seconds * multiplier`.
pub standard_equivalent_seconds: u64,
/// Allocation snapshot the provider accepted.
pub provider_allocation: ProviderAllocation,
/// Provider event / interval reference.
pub provider_event_ref: String,
/// Prior receipt this exact replay matched.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub replay_of: Option<String>,
/// Original receipt this append-only correction restates.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub correction_of: Option<String>,
/// Prior receipt ids in this lineage.
#[serde(default)]
pub lineage: Vec<String>,
/// Digest of the bound receipt fields.
pub binding_digest: String,
}
/// Inputs required to bind an admission before dispatch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ComputerAdmissionRequest {
/// Caller-supplied admission identity.
pub admission_id: String,
/// Account that funds the Computer.
pub account_id: String,
/// Durable Computer id.
pub computer_id: String,
/// Optional run.
#[serde(default)]
pub run_id: String,
/// Infrastructure provider.
pub provider: String,
/// Selected profile id (`standard-8` / `standard-16` / `standard-32`).
pub profile_id: String,
/// Funding authority.
pub funding_authority: String,
/// Quote identity.
pub quote_id: String,
/// Expiry timestamp.
pub expires_at: String,
/// Optional meter revision; must match v3 when supplied.
#[serde(default)]
pub meter_revision: String,
/// Optional catalog revision; must match v3 when supplied.
#[serde(default)]
pub catalog_revision: String,
}
/// Fail-closed Computer meter errors.
#[derive(Debug, Clone, PartialEq, Eq, Error)]
pub enum ComputerMeterError {
/// Profile id is not a v3 launch profile.
#[error("{message}")]
ProfileUnknown { message: String },
/// Historic Large/XL/Auto/standard aliases cannot admit new work.
#[error("{message}")]
HistoricProfileNotAdmissible { message: String },
/// Required identity field is missing or hostile.
#[error("{message}")]
ReferenceInvalid { message: String },
/// Timestamp is not RFC 3339.
#[error("{message}")]
TimestampInvalid { message: String },
/// Interval ends before it starts.
#[error("A Computer meter interval cannot end before it starts.")]
IntervalReversed,
/// Admission expired before the interval ended. An interval must lie
/// wholly inside the admission; the tail after expiry was never authorized.
#[error("The Computer admission expired before this interval ended.")]
AdmissionExpired,
/// Meter or catalog revision is not the pinned v3 revision.
#[error("{message}")]
RevisionMismatch { message: String },
/// Caller asked to meter wall-clock, including idle wall-clock.
#[error(
"Computer entitlement meters provider-accepted active seconds only, never wall-clock-if-idle."
)]
WallClockIdle,
/// Interval is not a provider-accepted active state.
#[error("{message}")]
NotProviderAcceptedActive { message: String },
/// Observed allocation is not exactly the admitted profile.
#[error("{message}")]
AllocationMismatch { message: String },
/// Replay disagrees with the original bound receipt.
#[error("This Computer meter receipt is already bound to different terms.")]
ReplayConflict,
}
impl ComputerMeterError {
/// Stable error code for tests and CWC.
#[must_use]
pub fn code(&self) -> &'static str {
match self {
Self::ProfileUnknown { .. } => "computer_profile_unknown",
Self::HistoricProfileNotAdmissible { .. } => "computer_profile_historic_not_admissible",
Self::ReferenceInvalid { .. } => "computer_meter_reference_invalid",
Self::TimestampInvalid { .. } => "computer_meter_timestamp_invalid",
Self::IntervalReversed => "computer_meter_interval_reversed",
Self::AdmissionExpired => "computer_admission_expired",
Self::RevisionMismatch { .. } => "computer_meter_revision_mismatch",
Self::WallClockIdle => "computer_meter_wall_clock_idle",
Self::NotProviderAcceptedActive { .. } => "computer_meter_not_provider_accepted_active",
Self::AllocationMismatch { .. } => "computer_meter_allocation_mismatch",
Self::ReplayConflict => "computer_meter_receipt_replay_conflict",
}
}
}
/// Decode a profile for reading historic or v3 ids. Does not authorize admission.
pub fn decode_computer_profile(input: &str) -> Result<ComputerProfile, ComputerMeterError> {
match normalize_token(input).as_str() {
"standard-8" | "standard" | "8" | "8gb" | "8gib" => Ok(COMPUTER_PROFILES[0]),
"standard-16" | "large" | "16" | "16gb" | "16gib" => Ok(COMPUTER_PROFILES[1]),
"standard-32" | "xl" | "32" | "32gb" | "32gib" => Ok(COMPUTER_PROFILES[2]),
other => Err(ComputerMeterError::ProfileUnknown {
message: format!("Unknown Computer profile: {other}."),
}),
}
}
/// Resolve a profile that may be used to create, quote, resume, or bill v3 work.
pub fn admit_computer_profile(input: &str) -> Result<ComputerProfile, ComputerMeterError> {
let normalized = normalize_token(input);
match normalized.as_str() {
"standard-8" | "standard-16" | "standard-32" => decode_computer_profile(&normalized),
"standard" | "large" | "xl" | "auto" => {
Err(ComputerMeterError::HistoricProfileNotAdmissible {
message: format!(
"Historic Computer profile `{normalized}` remains readable but cannot admit, resume, or meter new v3 work."
),
})
}
other => Err(ComputerMeterError::ProfileUnknown {
message: format!("Unknown Computer profile: {other}."),
}),
}
}
/// Bind provider, profile, resources, multiplier, revisions, account, funding,
/// quote, and expiry before dispatch.
pub fn bind_computer_admission(
request: ComputerAdmissionRequest,
) -> Result<ComputerAdmission, ComputerMeterError> {
let profile = admit_computer_profile(&request.profile_id)?;
let admission_id = require_ref(&request.admission_id, "admissionId")?;
let account_id = require_ref(&request.account_id, "accountId")?;
let computer_id = require_ref(&request.computer_id, "computerId")?;
let run_id = optional_ref(&request.run_id, "runId")?;
let provider = require_ref(&request.provider, "provider")?;
let funding_authority = require_ref(&request.funding_authority, "fundingAuthority")?;
let quote_id = require_ref(&request.quote_id, "quoteId")?;
let expires_at = normalize_timestamp(&request.expires_at, "expiresAt")?;
let meter_revision = require_revision(&request.meter_revision, COMPUTER_METER_REVISION)?;
let catalog_revision = require_revision(&request.catalog_revision, COMPUTER_CATALOG_REVISION)?;
let bound = ComputerAdmission {
schema_id: COMPUTER_ADMISSION_SCHEMA.to_string(),
admission_id,
account_id,
computer_id,
run_id,
provider,
profile_id: profile.id,
cpu: profile.cpu,
memory_gib: profile.memory_gib,
disk_gib: profile.disk_gib,
multiplier: profile.multiplier,
meter_revision,
catalog_revision,
funding_authority,
quote_id,
expires_at,
binding_digest: String::new(),
};
let binding_digest = admission_binding_digest(&bound);
Ok(ComputerAdmission {
binding_digest,
..bound
})
}
/// Mint an immutable receipt for one provider-accepted active interval.
pub fn issue_computer_meter_receipt(
admission: &ComputerAdmission,
observation: ProviderObservation,
) -> Result<ComputerMeterReceipt, ComputerMeterError> {
assert_active_observation(&observation)?;
assert_allocation_matches(admission, &observation)?;
let started_at = normalize_timestamp(&observation.started_at, "startedAt")?;
let ended_at = normalize_timestamp(&observation.ended_at, "endedAt")?;
let started = parse_timestamp(&started_at, "startedAt")?;
let ended = parse_timestamp(&ended_at, "endedAt")?;
if ended < started {
return Err(ComputerMeterError::IntervalReversed);
}
let expires = parse_timestamp(&admission.expires_at, "expiresAt")?;
// `ended_at` is exclusive, so an interval ending exactly at expiry is
// wholly authorized; one that runs past it is refused rather than clipped,
// because a clipped receipt would no longer restate the provider interval.
if started >= expires || ended > expires {
return Err(ComputerMeterError::AdmissionExpired);
}
let accepted_seconds = elapsed_whole_seconds(started, ended);
let standard_equivalent_seconds =
accepted_seconds.saturating_mul(u64::from(admission.multiplier));
let provider = require_ref(&observation.provider, "provider")?;
if provider != admission.provider {
return Err(ComputerMeterError::AllocationMismatch {
message: format!(
"Provider `{}` does not match admitted provider `{}`.",
provider, admission.provider
),
});
}
let provider_sandbox_id = require_ref(&observation.provider_sandbox_id, "providerSandboxId")?;
let provider_event_ref = require_ref(&observation.provider_event_ref, "providerEventRef")?;
let allocation = ProviderAllocation {
provider: provider.clone(),
provider_sandbox_id: provider_sandbox_id.clone(),
cpu: observation.cpu,
memory_gib: observation.memory_gib,
disk_gib: observation.disk_gib,
state: normalize_token(&observation.state),
accepted: true,
};
let mut receipt = ComputerMeterReceipt {
schema_id: COMPUTER_METER_RECEIPT_SCHEMA.to_string(),
receipt_id: String::new(),
kind: COMPUTER_METER_RECEIPT_KIND.to_string(),
account_id: admission.account_id.clone(),
run_id: admission.run_id.clone(),
computer_id: admission.computer_id.clone(),
admission_id: admission.admission_id.clone(),
provider,
profile_id: admission.profile_id,
multiplier: admission.multiplier,
cpu: admission.cpu,
memory_gib: admission.memory_gib,
disk_gib: admission.disk_gib,
meter_revision: admission.meter_revision.clone(),
catalog_revision: admission.catalog_revision.clone(),
funding_authority: admission.funding_authority.clone(),
quote_id: admission.quote_id.clone(),
started_at,
ended_at,
accepted_seconds,
standard_equivalent_seconds,
provider_allocation: allocation,
provider_event_ref,
replay_of: None,
correction_of: None,
lineage: Vec::new(),
binding_digest: String::new(),
};
receipt.binding_digest = receipt_binding_digest(&receipt);
receipt.receipt_id = receipt_id_for(&receipt);
Ok(receipt)
}
/// Exact replay of an existing receipt. Every field must match (only the
/// `replay_of` back-reference may differ), and the incoming digest and id must
/// be the ones its own fields produce: a caller-supplied digest is never
/// trusted to stand for fields it does not cover.
pub fn assert_computer_meter_receipt_replay(
existing: &ComputerMeterReceipt,
incoming: &ComputerMeterReceipt,
) -> Result<(), ComputerMeterError> {
let self_consistent = receipt_binding_digest(incoming) == incoming.binding_digest
&& receipt_id_for(incoming) == incoming.receipt_id;
let same_terms = ComputerMeterReceipt {
replay_of: existing.replay_of.clone(),
..incoming.clone()
} == *existing;
if self_consistent && same_terms {
return Ok(());
}
Err(ComputerMeterError::ReplayConflict)
}
/// Append-only correction. The original receipt is not mutated.
pub fn correct_computer_meter_receipt(
original: &ComputerMeterReceipt,
admission: &ComputerAdmission,
observation: ProviderObservation,
) -> Result<ComputerMeterReceipt, ComputerMeterError> {
if original.admission_id != admission.admission_id
|| original.account_id != admission.account_id
{
return Err(ComputerMeterError::ReplayConflict);
}
let mut correction = issue_computer_meter_receipt(admission, observation)?;
if correction.profile_id != original.profile_id || correction.multiplier != original.multiplier
{
return Err(ComputerMeterError::ReplayConflict);
}
correction.correction_of = Some(original.receipt_id.clone());
correction.lineage = {
let mut lineage = original.lineage.clone();
lineage.push(original.receipt_id.clone());
lineage
};
correction.binding_digest = receipt_binding_digest(&correction);
correction.receipt_id = receipt_id_for(&correction);
Ok(correction)
}
/// Sum Standard-equivalent seconds across independent Computer receipts.
#[must_use]
pub fn sum_standard_equivalent_seconds(receipts: &[ComputerMeterReceipt]) -> u64 {
receipts
.iter()
.map(|receipt| receipt.standard_equivalent_seconds)
.fold(0, u64::saturating_add)
}
fn assert_active_observation(observation: &ProviderObservation) -> Result<(), ComputerMeterError> {
if observation.meter_basis != MeterBasis::ProviderAcceptedActive || observation.idle {
return Err(ComputerMeterError::WallClockIdle);
}
if !observation.provider_accepted {
return Err(ComputerMeterError::NotProviderAcceptedActive {
message: "A Computer meter receipt requires a provider-accepted live allocation."
.to_string(),
});
}
let state = normalize_token(&observation.state);
if !matches!(state.as_str(), "running" | "started" | "active") {
return Err(ComputerMeterError::NotProviderAcceptedActive {
message: format!(
"Computer entitlement does not accrue in `{state}` (requested, queued, rejected, stopped, suspended, archived, failed-before-acceptance, and teardown-tail are excluded)."
),
});
}
Ok(())
}
fn assert_allocation_matches(
admission: &ComputerAdmission,
observation: &ProviderObservation,
) -> Result<(), ComputerMeterError> {
if observation.cpu == admission.cpu
&& observation.memory_gib == admission.memory_gib
&& observation.disk_gib == admission.disk_gib
{
return Ok(());
}
Err(ComputerMeterError::AllocationMismatch {
message: format!(
"Provider allocation {} vCPU / {} GiB RAM / {} GiB disk does not equal admitted profile {} ({} / {} / {}). Smaller is not a cost-saving substitution and larger is not an upgrade.",
observation.cpu,
observation.memory_gib,
observation.disk_gib,
admission.profile_id.as_str(),
admission.cpu,
admission.memory_gib,
admission.disk_gib
),
})
}
fn admission_binding_digest(admission: &ComputerAdmission) -> String {
sha256_hex(
[
ADMISSION_DIGEST_NS,
admission.admission_id.as_str(),
admission.account_id.as_str(),
admission.computer_id.as_str(),
admission.run_id.as_str(),
admission.provider.as_str(),
admission.profile_id.as_str(),
&admission.cpu.to_string(),
&admission.memory_gib.to_string(),
&admission.disk_gib.to_string(),
&admission.multiplier.to_string(),
admission.meter_revision.as_str(),
admission.catalog_revision.as_str(),
admission.funding_authority.as_str(),
admission.quote_id.as_str(),
admission.expires_at.as_str(),
]
.join("\0"),
)
}
fn receipt_binding_digest(receipt: &ComputerMeterReceipt) -> String {
sha256_hex(
[
RECEIPT_DIGEST_NS,
receipt.account_id.as_str(),
receipt.run_id.as_str(),
receipt.computer_id.as_str(),
receipt.admission_id.as_str(),
receipt.provider.as_str(),
receipt.profile_id.as_str(),
&receipt.multiplier.to_string(),
&receipt.cpu.to_string(),
&receipt.memory_gib.to_string(),
&receipt.disk_gib.to_string(),
receipt.meter_revision.as_str(),
receipt.catalog_revision.as_str(),
receipt.funding_authority.as_str(),
receipt.quote_id.as_str(),
receipt.started_at.as_str(),
receipt.ended_at.as_str(),
&receipt.accepted_seconds.to_string(),
&receipt.standard_equivalent_seconds.to_string(),
receipt.provider_event_ref.as_str(),
receipt.provider_allocation.provider_sandbox_id.as_str(),
&receipt.provider_allocation.cpu.to_string(),
&receipt.provider_allocation.memory_gib.to_string(),
&receipt.provider_allocation.disk_gib.to_string(),
receipt.provider_allocation.state.as_str(),
receipt.correction_of.as_deref().unwrap_or(""),
&receipt.lineage.join(","),
]
.join("\0"),
)
}
fn receipt_id_for(receipt: &ComputerMeterReceipt) -> String {
format!("cmr_{}", &receipt.binding_digest[..32])
}
fn elapsed_whole_seconds(started: DateTime<Utc>, ended: DateTime<Utc>) -> u64 {
ended
.signed_duration_since(started)
.num_milliseconds()
.max(0)
.unsigned_abs()
/ 1000
}
fn require_revision(value: &str, expected: &str) -> Result<String, ComputerMeterError> {
let normalized = value.trim();
if normalized.is_empty() {
return Ok(expected.to_string());
}
if normalized == expected {
return Ok(expected.to_string());
}
Err(ComputerMeterError::RevisionMismatch {
message: format!("Computer meter revision `{normalized}` is not {expected}."),
})
}
fn require_ref(value: &str, field: &str) -> Result<String, ComputerMeterError> {
let normalized = value.trim();
if normalized.is_empty()
|| normalized.len() > MAX_REF_CHARS
|| normalized.chars().any(|ch| ch.is_control())
{
return Err(ComputerMeterError::ReferenceInvalid {
message: format!(
"Computer meter field `{field}` is required and must be a bounded token."
),
});
}
Ok(normalized.to_string())
}
fn optional_ref(value: &str, field: &str) -> Result<String, ComputerMeterError> {
let normalized = value.trim();
if normalized.is_empty() {
return Ok(String::new());
}
require_ref(normalized, field)
}
fn normalize_token(value: &str) -> String {
value.trim().to_ascii_lowercase()
}
fn normalize_timestamp(value: &str, field: &str) -> Result<String, ComputerMeterError> {
Ok(parse_timestamp(value, field)?.to_rfc3339_opts(SecondsFormat::Millis, true))
}
fn parse_timestamp(value: &str, field: &str) -> Result<DateTime<Utc>, ComputerMeterError> {
DateTime::parse_from_rfc3339(value.trim())
.map(|parsed| parsed.with_timezone(&Utc))
.map_err(|_| ComputerMeterError::TimestampInvalid {
message: format!("Computer meter field `{field}` must be an RFC 3339 timestamp."),
})
}
#[cfg(test)]
mod tests;
@@ -1,429 +0,0 @@
{
"schema": "codewhale.computer-meter-fixtures/v1",
"note": "Hermetic Daytona-shaped observations. No live provider calls.",
"cases": [
{
"id": "accepted-standard-8",
"expect": "receipt",
"acceptedSeconds": 600,
"standardEquivalentSeconds": 600,
"admission": {
"admissionId": "adm_std8_001",
"accountId": "acct_demo",
"computerId": "cmp_8gb_a",
"runId": "run_demo_1",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_001",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_std8_a",
"providerEventRef": "daytona:sbox_std8_a:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:10:00.000Z"
}
},
{
"id": "accepted-standard-16",
"expect": "receipt",
"acceptedSeconds": 600,
"standardEquivalentSeconds": 1200,
"admission": {
"admissionId": "adm_std16_001",
"accountId": "acct_demo",
"computerId": "cmp_16gb_a",
"runId": "run_demo_2",
"provider": "daytona",
"profileId": "standard-16",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std16_001",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_std16_a",
"providerEventRef": "daytona:sbox_std16_a:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 4,
"memoryGiB": 16,
"diskGiB": 16,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:10:00.000Z"
}
},
{
"id": "accepted-standard-32",
"expect": "receipt",
"acceptedSeconds": 600,
"standardEquivalentSeconds": 2400,
"admission": {
"admissionId": "adm_std32_001",
"accountId": "acct_demo",
"computerId": "cmp_32gb_a",
"runId": "run_demo_3",
"provider": "daytona",
"profileId": "standard-32",
"fundingAuthority": "computer_time_pack",
"quoteId": "quote_std32_001",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_std32_a",
"providerEventRef": "daytona:sbox_std32_a:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 8,
"memoryGiB": 32,
"diskGiB": 32,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:10:00.000Z"
}
},
{
"id": "per-second-no-minute-floor",
"expect": "receipt",
"acceptedSeconds": 11,
"standardEquivalentSeconds": 11,
"admission": {
"admissionId": "adm_std8_subminute",
"accountId": "acct_demo",
"computerId": "cmp_8gb_b",
"runId": "run_demo_4",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_subminute",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_std8_b",
"providerEventRef": "daytona:sbox_std8_b:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:00:11.400Z"
}
},
{
"id": "idle-wall-clock",
"expect": "error",
"code": "computer_meter_wall_clock_idle",
"admission": {
"admissionId": "adm_std8_idle",
"accountId": "acct_demo",
"computerId": "cmp_8gb_idle",
"runId": "run_demo_idle",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_idle",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_idle",
"providerEventRef": "daytona:sbox_idle:wall-clock:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": true,
"providerAccepted": true,
"meterBasis": "wall_clock",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T13:00:00.000Z"
}
},
{
"id": "queued-not-accepted",
"expect": "error",
"code": "computer_meter_not_provider_accepted_active",
"admission": {
"admissionId": "adm_std8_queued",
"accountId": "acct_demo",
"computerId": "cmp_8gb_queued",
"runId": "run_demo_queued",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_queued",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_queued",
"providerEventRef": "daytona:sbox_queued:queued:2026-08-31T12:00:00.000Z",
"state": "queued",
"idle": false,
"providerAccepted": false,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:02:00.000Z"
}
},
{
"id": "stopped-storage-tail",
"expect": "error",
"code": "computer_meter_not_provider_accepted_active",
"admission": {
"admissionId": "adm_std8_stopped",
"accountId": "acct_demo",
"computerId": "cmp_8gb_stopped",
"runId": "run_demo_stopped",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_stopped",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_stopped",
"providerEventRef": "daytona:sbox_stopped:stopped:2026-08-31T12:10:00.000Z",
"state": "stopped",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:10:00.000Z",
"endedAt": "2026-08-31T13:10:00.000Z"
}
},
{
"id": "teardown-tail",
"expect": "error",
"code": "computer_meter_not_provider_accepted_active",
"admission": {
"admissionId": "adm_std8_teardown",
"accountId": "acct_demo",
"computerId": "cmp_8gb_teardown",
"runId": "run_demo_teardown",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_teardown",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_teardown",
"providerEventRef": "daytona:sbox_teardown:teardown:2026-08-31T12:10:00.000Z",
"state": "teardown",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:10:00.000Z",
"endedAt": "2026-08-31T12:10:45.000Z"
}
},
{
"id": "undersized-rollback-small",
"expect": "error",
"code": "computer_meter_allocation_mismatch",
"admission": {
"admissionId": "adm_std8_undersize",
"accountId": "acct_demo",
"computerId": "cmp_8gb_undersize",
"runId": "run_demo_undersize",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_undersize",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_undersize",
"providerEventRef": "daytona:sbox_undersize:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 4,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:10:00.000Z"
}
},
{
"id": "oversize-not-an-upgrade",
"expect": "error",
"code": "computer_meter_allocation_mismatch",
"admission": {
"admissionId": "adm_std8_oversize",
"accountId": "acct_demo",
"computerId": "cmp_8gb_oversize",
"runId": "run_demo_oversize",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_oversize",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_oversize",
"providerEventRef": "daytona:sbox_oversize:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 4,
"memoryGiB": 16,
"diskGiB": 16,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:10:00.000Z"
}
},
{
"id": "concurrent-two-standard-8",
"expect": "sum",
"standardEquivalentSeconds": 1200,
"intervals": [
{
"admission": {
"admissionId": "adm_std8_conc_a",
"accountId": "acct_demo",
"computerId": "cmp_8gb_conc_a",
"runId": "run_conc_a",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_conc_a",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_conc_a",
"providerEventRef": "daytona:sbox_conc_a:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:10:00.000Z"
}
},
{
"admission": {
"admissionId": "adm_std8_conc_b",
"accountId": "acct_demo",
"computerId": "cmp_8gb_conc_b",
"runId": "run_conc_b",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_conc_b",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_conc_b",
"providerEventRef": "daytona:sbox_conc_b:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:10:00.000Z"
}
}
]
},
{
"id": "historic-large-readable",
"expect": "decode",
"profileInput": "large",
"profileId": "standard-16",
"multiplier": 2
},
{
"id": "historic-large-not-admissible",
"expect": "admit_error",
"profileInput": "large",
"code": "computer_profile_historic_not_admissible"
},
{
"id": "append-only-correction",
"expect": "correction",
"acceptedSeconds": 590,
"standardEquivalentSeconds": 590,
"admission": {
"admissionId": "adm_std8_corr",
"accountId": "acct_demo",
"computerId": "cmp_8gb_corr",
"runId": "run_demo_corr",
"provider": "daytona",
"profileId": "standard-8",
"fundingAuthority": "coding_membership_included",
"quoteId": "quote_std8_corr",
"expiresAt": "2026-08-31T18:00:00.000Z"
},
"observation": {
"provider": "daytona",
"providerSandboxId": "sbox_corr",
"providerEventRef": "daytona:sbox_corr:running:2026-08-31T12:00:00.000Z",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:10:00.000Z"
},
"restatedObservation": {
"provider": "daytona",
"providerSandboxId": "sbox_corr",
"providerEventRef": "daytona:sbox_corr:running:2026-08-31T12:00:00.000Z:restated",
"state": "running",
"idle": false,
"providerAccepted": true,
"meterBasis": "provider_accepted_active",
"cpu": 2,
"memoryGiB": 8,
"diskGiB": 8,
"startedAt": "2026-08-31T12:00:00.000Z",
"endedAt": "2026-08-31T12:09:50.000Z"
}
}
]
}
-300
View File
@@ -1,300 +0,0 @@
use super::*;
const FIXTURES: &str = include_str!("fixtures/cases.json");
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FixtureFile {
cases: Vec<FixtureCase>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FixtureCase {
id: String,
expect: String,
#[serde(default)]
code: Option<String>,
#[serde(default)]
accepted_seconds: Option<u64>,
#[serde(default)]
standard_equivalent_seconds: Option<u64>,
#[serde(default)]
admission: Option<ComputerAdmissionRequest>,
#[serde(default)]
observation: Option<ProviderObservation>,
#[serde(default)]
restated_observation: Option<ProviderObservation>,
#[serde(default)]
intervals: Vec<FixtureInterval>,
#[serde(default)]
profile_input: Option<String>,
#[serde(default)]
profile_id: Option<String>,
#[serde(default)]
multiplier: Option<u32>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct FixtureInterval {
admission: ComputerAdmissionRequest,
observation: ProviderObservation,
}
fn fixtures() -> Vec<FixtureCase> {
serde_json::from_str::<FixtureFile>(FIXTURES)
.expect("computer meter fixtures must parse")
.cases
}
fn issue_case(case: &FixtureCase) -> Result<ComputerMeterReceipt, ComputerMeterError> {
let admission = bind_computer_admission(
case.admission
.clone()
.expect("receipt fixture requires admission"),
)?;
issue_computer_meter_receipt(
&admission,
case.observation
.clone()
.expect("receipt fixture requires observation"),
)
}
#[test]
fn fixtures_cover_provider_accepted_active_seconds_only() {
let cases = fixtures();
assert!(
cases.len() >= 12,
"fixture pack must cover accept, idle, queued, stopped, teardown, mismatch, concurrent, historic, correction"
);
for case in &cases {
match case.expect.as_str() {
"receipt" => {
let receipt = issue_case(case).unwrap_or_else(|error| {
panic!("{} should mint a receipt, got {}", case.id, error.code())
});
assert_eq!(receipt.schema_id, COMPUTER_METER_RECEIPT_SCHEMA);
assert_eq!(receipt.kind, COMPUTER_METER_RECEIPT_KIND);
assert_eq!(receipt.accepted_seconds, case.accepted_seconds.unwrap());
assert_eq!(
receipt.standard_equivalent_seconds,
case.standard_equivalent_seconds.unwrap()
);
assert_eq!(
receipt.standard_equivalent_seconds,
receipt.accepted_seconds * u64::from(receipt.multiplier)
);
let admission = bind_computer_admission(case.admission.clone().unwrap()).unwrap();
assert_eq!(receipt.admission_id, admission.admission_id);
assert_eq!(receipt.account_id, admission.account_id);
assert_eq!(receipt.profile_id, admission.profile_id);
assert_eq!(receipt.multiplier, admission.multiplier);
assert_eq!(receipt.provider_allocation.cpu, admission.cpu);
assert_eq!(receipt.provider_allocation.memory_gib, admission.memory_gib);
assert_eq!(receipt.provider_allocation.disk_gib, admission.disk_gib);
assert!(receipt.provider_allocation.accepted);
assert_eq!(receipt.meter_revision, COMPUTER_METER_REVISION);
assert_eq!(receipt.catalog_revision, COMPUTER_CATALOG_REVISION);
assert_ne!(receipt.binding_digest, admission.binding_digest);
}
"error" => {
let error = issue_case(case).expect_err(&case.id);
assert_eq!(error.code(), case.code.as_deref().unwrap(), "{}", case.id);
}
"sum" => {
let receipts: Vec<_> = case
.intervals
.iter()
.map(|interval| {
let admission = bind_computer_admission(interval.admission.clone())
.unwrap_or_else(|error| panic!("{}: {}", case.id, error));
issue_computer_meter_receipt(&admission, interval.observation.clone())
.unwrap_or_else(|error| panic!("{}: {}", case.id, error))
})
.collect();
assert_eq!(
sum_standard_equivalent_seconds(&receipts),
case.standard_equivalent_seconds.unwrap()
);
}
"decode" => {
let profile = decode_computer_profile(case.profile_input.as_deref().unwrap())
.unwrap_or_else(|error| panic!("{}: {}", case.id, error));
assert_eq!(profile.id.as_str(), case.profile_id.as_deref().unwrap());
assert_eq!(profile.multiplier, case.multiplier.unwrap());
}
"admit_error" => {
let error = admit_computer_profile(case.profile_input.as_deref().unwrap())
.expect_err(&case.id);
assert_eq!(error.code(), case.code.as_deref().unwrap());
}
"correction" => {
let admission = bind_computer_admission(case.admission.clone().unwrap()).unwrap();
let original =
issue_computer_meter_receipt(&admission, case.observation.clone().unwrap())
.unwrap();
let original_clone = original.clone();
let correction = correct_computer_meter_receipt(
&original,
&admission,
case.restated_observation.clone().unwrap(),
)
.unwrap();
assert_eq!(
original, original_clone,
"corrections must not mutate the original receipt"
);
assert_eq!(
correction.correction_of.as_deref(),
Some(original.receipt_id.as_str())
);
assert!(correction.lineage.contains(&original.receipt_id));
assert_ne!(correction.receipt_id, original.receipt_id);
assert_eq!(correction.accepted_seconds, case.accepted_seconds.unwrap());
assert_eq!(
correction.standard_equivalent_seconds,
case.standard_equivalent_seconds.unwrap()
);
assert_eq!(correction.account_id, original.account_id);
assert_eq!(correction.admission_id, original.admission_id);
assert_eq!(correction.profile_id, original.profile_id);
}
other => panic!("unknown fixture expect `{other}`"),
}
}
}
#[test]
fn exact_replay_is_idempotent_and_a_mutated_binding_conflicts() {
let case = fixtures()
.into_iter()
.find(|case| case.id == "accepted-standard-8")
.unwrap();
let first = issue_case(&case).unwrap();
let replay = issue_case(&case).unwrap();
assert_eq!(first.receipt_id, replay.receipt_id);
assert_eq!(first.binding_digest, replay.binding_digest);
assert_computer_meter_receipt_replay(&first, &replay).unwrap();
let mut mutated = replay.clone();
mutated.accepted_seconds = first.accepted_seconds + 1;
mutated.standard_equivalent_seconds = mutated.accepted_seconds;
assert_eq!(
assert_computer_meter_receipt_replay(&first, &mutated)
.unwrap_err()
.code(),
"computer_meter_receipt_replay_conflict"
);
}
#[test]
fn replay_rejects_fields_the_supplied_digest_does_not_vouch_for() {
let case = fixtures()
.into_iter()
.find(|case| case.id == "accepted-standard-8")
.unwrap();
let first = issue_case(&case).unwrap();
// Same id and digest, different interval: the digest no longer matches
// the fields it claims to bind.
let mut shifted = first.clone();
shifted.started_at = "2026-08-31T13:00:00.000Z".to_string();
shifted.ended_at = "2026-08-31T13:10:00.000Z".to_string();
assert_eq!(
assert_computer_meter_receipt_replay(&first, &shifted)
.unwrap_err()
.code(),
"computer_meter_receipt_replay_conflict"
);
// A field outside the digest must still match exactly.
let mut restated = first.clone();
restated.provider_allocation.accepted = false;
assert_eq!(
assert_computer_meter_receipt_replay(&first, &restated)
.unwrap_err()
.code(),
"computer_meter_receipt_replay_conflict"
);
}
#[test]
fn an_interval_that_runs_past_admission_expiry_is_refused() {
let case = fixtures()
.into_iter()
.find(|case| case.id == "accepted-standard-8")
.unwrap();
let mut admission_request = case.admission.clone().unwrap();
let observation = case.observation.clone().unwrap();
// Starts before expiry, ends after it.
admission_request.expires_at = "2026-08-31T12:05:00.000Z".to_string();
let admission = bind_computer_admission(admission_request.clone()).unwrap();
assert_eq!(
issue_computer_meter_receipt(&admission, observation.clone())
.unwrap_err()
.code(),
"computer_admission_expired"
);
// Ending exactly at expiry is inside the admission (end is exclusive).
admission_request.expires_at = observation.ended_at.clone();
let admission = bind_computer_admission(admission_request).unwrap();
assert!(issue_computer_meter_receipt(&admission, observation).is_ok());
}
#[test]
fn admission_binds_profile_multiplier_account_and_expiry_before_dispatch() {
let admission = bind_computer_admission(ComputerAdmissionRequest {
admission_id: "adm_bind".to_string(),
account_id: "acct_demo".to_string(),
computer_id: "cmp_bind".to_string(),
run_id: "run_bind".to_string(),
provider: "daytona".to_string(),
profile_id: "standard-16".to_string(),
funding_authority: "coding_membership_included".to_string(),
quote_id: "quote_bind".to_string(),
expires_at: "2026-08-31T18:00:00.000Z".to_string(),
meter_revision: String::new(),
catalog_revision: String::new(),
})
.unwrap();
assert_eq!(admission.schema_id, COMPUTER_ADMISSION_SCHEMA);
assert_eq!(admission.profile_id, ComputerProfileId::Standard16);
assert_eq!(admission.cpu, 4);
assert_eq!(admission.memory_gib, 16);
assert_eq!(admission.disk_gib, 16);
assert_eq!(admission.multiplier, 2);
assert_eq!(admission.account_id, "acct_demo");
assert_eq!(admission.meter_revision, COMPUTER_METER_REVISION);
assert!(!admission.binding_digest.is_empty());
}
#[test]
fn one_standard_16_matches_two_standard_8_over_the_same_ten_minutes() {
let cases = fixtures();
let two_eights = cases
.iter()
.find(|case| case.id == "concurrent-two-standard-8")
.unwrap();
let sixteen = cases
.iter()
.find(|case| case.id == "accepted-standard-16")
.unwrap();
let eight_receipts: Vec<_> = two_eights
.intervals
.iter()
.map(|interval| {
let admission = bind_computer_admission(interval.admission.clone()).unwrap();
issue_computer_meter_receipt(&admission, interval.observation.clone()).unwrap()
})
.collect();
let sixteen_receipt = issue_case(sixteen).unwrap();
assert_eq!(
sum_standard_equivalent_seconds(&eight_receipts),
sixteen_receipt.standard_equivalent_seconds
);
assert_eq!(sum_standard_equivalent_seconds(&eight_receipts), 1200);
}
-1
View File
@@ -13,7 +13,6 @@
pub use codewhale_command_contract::elapsed;
pub mod computer_meter;
pub mod context_budget;
pub mod continual_harness;
pub mod fast_hash;
+2 -56
View File
@@ -41,15 +41,8 @@ use thiserror::Error;
/// with credentials saved before the CodeWhale rename. macOS users can verify
/// entries with `security find-generic-password -s deepseek -a <provider>`.
pub const DEFAULT_SERVICE: &str = "deepseek";
/// Secret-store slot consumed by Daytona cloud dispatch (`codewhale dispatch`).
///
/// Login writes here; dispatch looks this slot up after `DAYTONA_API_KEY` and
/// `CWC_DAYTONA_TOKEN`. Do not invent a second Daytona credential name.
pub const DAYTONA_TOKEN_SLOT: &str = "daytona";
/// First-class Daytona process env that dispatch also accepts.
pub const DAYTONA_API_KEY_ENV: &str = "DAYTONA_API_KEY";
/// CWC alias that Daytona dispatch also accepts.
pub const CWC_DAYTONA_TOKEN_ENV: &str = "CWC_DAYTONA_TOKEN";
/// On-disk slot name of the retired sandbox token; logout deletes it.
pub const LEGACY_SANDBOX_TOKEN_SLOT: &str = "daytona";
/// Select the secret storage backend. Supported values are `file` (default)
/// and `system`/`keyring` for the OS credential store.
pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
@@ -1384,31 +1377,6 @@ pub fn env_first<'a>(vars: &[&'a str]) -> Option<(&'a str, String)> {
})
}
/// Report whether a Daytona token is present without revealing it.
///
/// Order matches dispatch: secret-store slot `daytona`, then
/// [`DAYTONA_API_KEY_ENV`], then [`CWC_DAYTONA_TOKEN_ENV`].
#[must_use]
pub fn daytona_credential_source(secrets: &Secrets) -> Option<&'static str> {
if secrets
.get(DAYTONA_TOKEN_SLOT)
.ok()
.flatten()
.is_some_and(|value| !value.trim().is_empty())
{
return Some("secret-store");
}
for var in [DAYTONA_API_KEY_ENV, CWC_DAYTONA_TOKEN_ENV] {
if std::env::var(var)
.ok()
.is_some_and(|value| !value.trim().is_empty())
{
return Some("env");
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1488,8 +1456,6 @@ mod tests {
"CONCENTRATE_API_KEY",
"MODELSTUDIO_API_KEY",
"DASHSCOPE_API_KEY",
DAYTONA_API_KEY_ENV,
CWC_DAYTONA_TOKEN_ENV,
SECRET_BACKEND_ENV,
LEGACY_SECRET_BACKEND_ENV,
] {
@@ -2257,26 +2223,6 @@ mod tests {
assert_eq!(secrets.get("deepseek").unwrap(), None);
}
#[test]
fn daytona_slot_resolves_secret_store_then_dispatch_envs() {
let _lock = env_lock();
clear_known_envs();
let secrets = Secrets::new(std::sync::Arc::new(InMemoryKeyringStore::new()));
assert_eq!(daytona_credential_source(&secrets), None);
assert_eq!(DAYTONA_TOKEN_SLOT, "daytona");
secrets.set(DAYTONA_TOKEN_SLOT, "dtn_store").unwrap();
assert_eq!(daytona_credential_source(&secrets), Some("secret-store"));
secrets.delete(DAYTONA_TOKEN_SLOT).unwrap();
let _key = EnvVarGuard::set(DAYTONA_API_KEY_ENV, "dtn_env");
assert_eq!(daytona_credential_source(&secrets), Some("env"));
assert_eq!(
env_first(&[DAYTONA_API_KEY_ENV, CWC_DAYTONA_TOKEN_ENV]),
Some((DAYTONA_API_KEY_ENV, "dtn_env".to_string()))
);
}
#[path = "diagnostic_tests.rs"]
mod diagnostic_tests;
}
+65
View File
@@ -7,6 +7,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- A first launch with no key or route now opens the composer. Codewhale asks
for a provider when you send your first message, keeps the message in the
box, and offers a local model only when a local Ollama server answers. The provider list
now shows what you type to filter it during first-run setup.
- Removed the own-sandbox cloud dispatch (`--own-sandbox`, `/dispatch`,
`agent runtime:"cloud"`, the `cloud-agent` alias, and the `--confirm`,
`--remote`, `--branch`, `--cwd`, `--status` and `--list` options).
`codewhale dispatch "<task>"` now runs only through the signed-in account,
with its quote and EU-compute consent.
- `codewhale dispatch` uses the account's neutral wording for cloud errors,
runs for the quoted seconds unless `--seconds` is given, and asks for consent
only when both stdin and stdout are terminals. It refuses a message that was
not read as new Work, and prints the Message ID.
- MCP servers you configure now connect when a session starts; set
`lazy = true` on a server to connect it on demand instead. Servers that come
from plugins stay on demand. Finishing an OAuth login reconnects just that
server, and `/mcp reload` redials every server that is not `lazy` and reports
how many are not connected out of the configured total.
- Running shell commands open an inspector with their output and status, and
`Alt+V` opens the newest running shell (#6931).
- `/resume`, `/load` and other session changes that cannot run yet now list
what is still busy and how to stop it.
- Sessions that ended without a clean exit show `interrupted` with their age
in the session list.
- `codewhale logout` also deletes a stored legacy sandbox token.
- `codewhale lane list` prints an empty list instead of failing when no lane
has been started, `codewhale completion` no longer errors when its output
pipe closes early, and `session-diagnostics`, `receipts` and `features` help
and errors say what to pass and where to find saved sessions.
- A missing `--config` file, a missing `-C` workspace directory and an unknown
`--profile` now fail at startup with a clear message.
- `codewhale web` works when `$HOME` sits under a root-owned symlink.
- Typing `/exi` and pressing Enter now completes the command instead of running
a different destructive command (`/exit`, `/clear`, `/new`, `/logout`,
`/restore`).
- The Shift+Tab permission hint stays in the footer while in Auto-Review and
Full Access.
- An idle pet view no longer builds animation frames while nobody is
looking at it (#6728).
- README: `codewhale exec --auto` is shown for headless runs that edit files
or run commands.
- `bash` now moves a foreground command to the background after 30 s and
reports the elapsed time and how long ago it last printed output.
`task_shell_start` is removed; use `bash` with `background=true`.
- Full Access now asks before turning on (Shift+Tab, Alt+Y, `/config
approval_mode full-access`, the High-trust setup preset) and lasts one
session. `R` in the popup keeps it on for this repo (`full_access_repos` in
`settings.toml`); `--yolo` and `CODEWHALE_YOLO` skip the question. A saved
`permission_posture = "full-access"` stays on for that session with a
one-time notice; later sessions start in Ask.
The footer chip reads `Full Access · session` or `Full Access · this repo`,
and every change of effective permission writes a `Permissions changed to …`
line (a `status` item and `thread.permissions_changed` event in runtime
threads). Runtime clients that `PATCH /v1/threads/{id}` into Full Access
must now send `full_access_confirmation` (`session` or `repo`) or get a 400.
- The bundled first-party catalog pins marketplace revision
`9b5f9d614ef69702be9944a61e89aeb3df14c047`; the 19 catalog entries are
unchanged, and installed bundles now come from a revision carrying the
@@ -3661,6 +3717,9 @@ item-level change record is retained below the categorized release highlights.
### Contributors
- **xyzs996 ([@xyzs996](https://github.com/xyzs996))** — DeepSeek V4 whole
Beijing weekends are billed as off-peak, and the scorecard window comment now
matches (#5545).
- **Sun Zhenyuan ([@bistack](https://github.com/bistack))** — tool-call stage
extraction with the existing execution and policy contracts preserved
(#5523).
@@ -4893,6 +4952,10 @@ locales grow to 18 and 8.
### Contributors
- Lstarsky0 (@Lstarsky0) — compact rows no longer reserve a hidden
session-metrics strip (#5486); docs shell and shared component pick-text
moved onto the typed dictionary spine (#5488, #5490).
- Sh1Zuku (@SparkofSpike) — topbar session title (#5481).
- hexin (@h3c-hexin) — a concrete route/offering output limit outranks the
8,192-token compatibility guess for an uncatalogued model (#5461, closes
#5460); web tool results use the noisy soft limit (#5474); owned direct
@@ -5259,6 +5322,8 @@ a published OpenAI-compatible host ship here (#5350).
### Contributors
- Arlo (@xiaoray-blip) — MCP list responses omit `nextCursor` when there
are no further pages (#5336, reported in #5335).
- EvanProgramming (@EvanProgramming) — webhook client panic fallback
(#5381); session-index JSONL mutex (#5382).
- Lstarsky0 (@Lstarsky0) — session peek hides internal runtime events
@@ -1,51 +0,0 @@
#!/usr/bin/env node
// codewhale-cu-turn-hold — hold a Sprite awake for exactly one turn.
//
// The Engine spawns this at turn start with a piped stdin and ends it at turn
// end by closing stdin (or SIGTERM). It registers a Sprite Task (5 min
// expiry), refreshes it every 60 s, and deletes it on stdin EOF, SIGTERM,
// SIGINT or SIGHUP. If the Engine dies, stdin closes and the hold is released;
// if this process is SIGKILLed, the Task lapses within its expiry.
// Receipts are JSON lines on stdout.
//
// codewhale-cu-turn-hold --name turn-<id> [--expire 5m] [--refresh 60] [--socket /.sprite/api.sock]
import { createTaskHold, DEFAULT_SOCKET } from "../src/sprite-task.mjs";
function arg(flag, fallback) {
const i = process.argv.indexOf(flag);
return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : fallback;
}
const emit = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
let hold;
try {
hold = createTaskHold({
name: arg("--name", null),
expire: arg("--expire", "5m"),
refreshMs: Number(arg("--refresh", "60")) * 1000,
socket: arg("--socket", process.env.CODEWHALE_SPRITE_API_SOCKET || DEFAULT_SOCKET),
onEvent: emit,
});
} catch (error) {
emit({ event: "refused", error: error.message, code: error.code ?? "bad_args" });
process.exit(2);
}
let ending = false;
async function end(code = 0) {
if (ending) return;
ending = true;
await hold.release();
process.exit(code);
}
try {
await hold.acquire();
} catch (error) {
emit({ event: "acquire_failed", error: error.message, code: error.code ?? "task_api_error" });
process.exit(1);
}
process.stdin.on("end", () => end(0));
process.stdin.on("error", () => end(0));
process.stdin.resume();
for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(signal, () => end(0));
+1 -2
View File
@@ -10,8 +10,7 @@
"license": "MIT",
"bin": {
"codewhale-cu": "mcp/server.mjs",
"codewhale-cu-app": "app/daemon.mjs",
"codewhale-cu-turn-hold": "mcp/turn-hold.mjs"
"codewhale-cu-app": "app/daemon.mjs"
},
"devDependencies": {
"sharp": "0.35.5"
@@ -1,6 +1,6 @@
// Human/agent control lease — the input gate for a shared computer.
//
// On a Codewhale Computer (a Sprite seat), a person and the agent share one
// On a Codewhale Computer, a person and the agent share one
// X display and one Chromium. The Engine owns the control lease and writes its
// current holder to a small JSON file (CODEWHALE_CU_LEASE_FILE, normally
// /run/cw/lease.json, owned by cw-engine). While a person holds it, every
@@ -1,121 +0,0 @@
// Sprite Task hold — keep a Codewhale Computer (a Fly Sprite) awake while a
// turn runs, and only then.
//
// Sprites pause when idle; a Task registered on the in-Sprite API socket
// (/.sprite/api.sock, virtual host "sprite") holds one awake until it expires.
// The contract (ARCHITECTURE §2.1, S0 Q7):
// - acquire at turn start with a 5-minute expiry, refresh every 60 s,
// release (DELETE) at turn end;
// - expiries are capped at 5 minutes: a Task survives a checkpoint restore
// and keeps the Sprite billing until it expires, so a crashed or halted
// holder must never leave more than a short tail;
// - the holder dies with its parent: the CLI (mcp/turn-hold.mjs) releases on
// stdin EOF, so an Engine crash cannot leave a refreshed hold behind.
import http from "node:http";
export const DEFAULT_SOCKET = "/.sprite/api.sock";
export const MAX_EXPIRE_SEC = 300;
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
/** "5m" | "90s" | 300 → seconds; refuses anything above the 5-minute cap. */
export function expireSeconds(expire) {
let sec;
if (typeof expire === "number") sec = expire;
else {
const m = /^(\d+)(s|m)$/.exec(String(expire ?? "").trim());
if (!m) throw Object.assign(new Error(`expire must look like "5m" or "90s" (got ${JSON.stringify(expire)})`), { code: "bad_args" });
sec = Number(m[1]) * (m[2] === "m" ? 60 : 1);
}
if (!Number.isInteger(sec) || sec < 30 || sec > MAX_EXPIRE_SEC) {
throw Object.assign(new Error(`task expiry must be 30..${MAX_EXPIRE_SEC} s — a Task outlives restores, so long holds are refused`), { code: "bad_args" });
}
return sec;
}
/** One JSON request to the Sprite API socket. Resolves {status, body}. */
export function spriteApi(method, path, body, { socket = DEFAULT_SOCKET, timeoutMs = 5_000 } = {}) {
return new Promise((resolve, reject) => {
const payload = body == null ? null : JSON.stringify(body);
const req = http.request({
socketPath: socket, method, path, host: "sprite",
headers: { Host: "sprite", ...(payload ? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) } : {}) },
timeout: timeoutMs,
}, (res) => {
let text = "";
res.setEncoding("utf8");
res.on("data", (chunk) => { text += chunk; });
res.on("end", () => {
let parsed = null;
try { parsed = text ? JSON.parse(text) : null; } catch { parsed = text; }
resolve({ status: res.statusCode, body: parsed });
});
});
req.on("timeout", () => req.destroy(Object.assign(new Error(`Sprite API ${method} ${path} timed out`), { code: "timeout" })));
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
/**
* A refreshed Task hold. acquire() registers it and starts the refresh timer;
* release() stops the timer and deletes the Task. onEvent receives receipts
* ({event:"acquired"|"refreshed"|"refresh_failed"|"released"|"release_failed", ...}).
*/
export function createTaskHold({
name, expire = "5m", refreshMs = 60_000, socket = DEFAULT_SOCKET,
api = (method, path, body) => spriteApi(method, path, body, { socket }),
onEvent = () => {}, now = () => new Date().toISOString(),
} = {}) {
if (typeof name !== "string" || !NAME_RE.test(name)) throw Object.assign(new Error("task name must be lowercase letters, digits and dashes (≤ 63)"), { code: "bad_args" });
const sec = expireSeconds(expire);
if (!(refreshMs > 0) || refreshMs >= sec * 1000) throw Object.assign(new Error("refresh interval must be shorter than the expiry"), { code: "bad_args" });
const expireText = `${sec}s`;
let timer = null;
let held = false;
const path = `/v1/tasks/${encodeURIComponent(name)}`;
async function register(method, url) {
const r = await api(method, url, { name, expire: expireText });
if (r.status < 200 || r.status >= 300) throw Object.assign(new Error(`Sprite API ${method} ${url} returned ${r.status}`), { code: "task_api_error", status: r.status });
return r.body;
}
async function refresh() {
try {
// PUT refreshes per the docs; a server without it gets a re-POST, which
// S0 observed to re-register the same name with a fresh expiry.
let body;
try { body = await register("PUT", path); } catch (error) {
if (error.status !== 404 && error.status !== 405) throw error;
body = await register("POST", "/v1/tasks");
}
onEvent({ event: "refreshed", name, expires_at: body?.expires_at ?? null, ts: now() });
} catch (error) {
onEvent({ event: "refresh_failed", name, error: error.message, ts: now() });
}
}
return {
get held() { return held; },
async acquire() {
if (held) return;
const body = await register("POST", "/v1/tasks");
held = true;
onEvent({ event: "acquired", name, expire: expireText, expires_at: body?.expires_at ?? null, ts: now() });
timer = setInterval(refresh, refreshMs);
},
async release() {
if (timer) { clearInterval(timer); timer = null; }
if (!held) return;
held = false;
try {
const r = await api("DELETE", path);
if (r.status >= 300 && r.status !== 404) throw new Error(`Sprite API DELETE ${path} returned ${r.status}`);
onEvent({ event: "released", name, ts: now() });
} catch (error) {
onEvent({ event: "release_failed", name, error: error.message, note: `the Task lapses on its own within ${sec} s`, ts: now() });
}
},
};
}
@@ -8,8 +8,8 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { createBrowser, connectPipeSocket, attachSocket } from "../src/browser-cdp.mjs";
// These transports are Unix sockets inside the Linux Sprite; Windows cannot bind the path.
const UNIX_SOCKETS = { skip: process.platform === "win32" && "Unix-socket transport (Sprite/Linux only)" };
// These transports are Unix sockets inside the Linux computer; Windows cannot bind the path.
const UNIX_SOCKETS = { skip: process.platform === "win32" && "Unix-socket transport (Linux only)" };
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-attach-"));
after(() => fs.rmSync(dir, { recursive: true, force: true }));
@@ -1,116 +0,0 @@
// Sprite Task hold for one turn: 5 min expiry refreshed every 60 s, released
// at turn end, capped so a crashed holder leaves only a short tail (S0 Q7).
import { test, after } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { createTaskHold, expireSeconds, spriteApi } from "../src/sprite-task.mjs";
// These transports are Unix sockets inside the Linux Sprite; Windows cannot bind the path.
const UNIX_SOCKETS = { skip: process.platform === "win32" && "Unix-socket transport (Sprite/Linux only)" };
const ROOT = path.resolve(import.meta.dirname, "..");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-task-"));
after(() => fs.rmSync(dir, { recursive: true, force: true }));
/** A fake /.sprite/api.sock: POST/PUT register, DELETE removes, GET lists. */
function fakeApi(sock, { putStatus = null } = {}) {
const tasks = new Map();
const log = [];
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => { body += c; });
req.on("end", () => {
log.push(`${req.method} ${req.url} host=${req.headers.host}`);
const send = (status, obj) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(obj ? JSON.stringify(obj) : ""); };
const name = decodeURIComponent(req.url.split("/")[3] ?? "");
if (req.method === "GET") return send(200, { tasks: [...tasks.values()] });
if (req.method === "POST" || req.method === "PUT") {
if (req.method === "PUT" && putStatus) return send(putStatus, null);
const p = JSON.parse(body);
const task = { name: p.name, expire: p.expire, expires_at: "2026-09-22T20:05:00Z" };
tasks.set(p.name, task);
return send(200, task);
}
if (req.method === "DELETE") { tasks.delete(name); return send(204, null); }
send(405, null);
});
});
return new Promise((resolve) => server.listen(sock, () => resolve({ server, tasks, log })));
}
test("expiry is capped at 5 minutes", () => {
assert.equal(expireSeconds("5m"), 300);
assert.equal(expireSeconds("90s"), 90);
assert.throws(() => expireSeconds("1h"));
assert.throws(() => expireSeconds("6m"));
assert.throws(() => expireSeconds("10s"));
assert.throws(() => createTaskHold({ name: "Bad Name" }));
assert.throws(() => createTaskHold({ name: "turn-1", expire: "60s", refreshMs: 60_000 }), /shorter/);
});
test("acquire registers, refresh re-registers, release deletes", UNIX_SOCKETS, async (t) => {
const sock = path.join(dir, "api1.sock");
const api = await fakeApi(sock);
t.after(() => api.server.close());
const events = [];
const hold = createTaskHold({ name: "turn-abc", refreshMs: 30, socket: sock, onEvent: (e) => events.push(e.event) });
await hold.acquire();
assert.equal(api.tasks.get("turn-abc").expire, "300s");
await new Promise((r) => setTimeout(r, 80));
await hold.release();
assert.equal(api.tasks.size, 0);
assert.ok(events.includes("refreshed"));
assert.equal(events[0], "acquired");
assert.equal(events.at(-1), "released");
assert.ok(api.log.every((l) => l.endsWith("host=sprite")));
assert.ok(api.log.some((l) => l.startsWith("PUT /v1/tasks/turn-abc")));
});
test("refresh falls back to POST when PUT is not offered", UNIX_SOCKETS, async (t) => {
const sock = path.join(dir, "api2.sock");
const api = await fakeApi(sock, { putStatus: 405 });
t.after(() => api.server.close());
const events = [];
const hold = createTaskHold({ name: "turn-x", refreshMs: 30, socket: sock, onEvent: (e) => events.push(e.event) });
await hold.acquire();
await new Promise((r) => setTimeout(r, 70));
await hold.release();
assert.ok(events.includes("refreshed"));
assert.ok(!events.includes("refresh_failed"));
});
test("turn-hold CLI holds for the turn and releases on stdin EOF (parent gone)", UNIX_SOCKETS, async (t) => {
const sock = path.join(dir, "api3.sock");
const api = await fakeApi(sock);
t.after(() => api.server.close());
const child = spawn(process.execPath, [path.join(ROOT, "mcp/turn-hold.mjs"), "--name", "turn-cli", "--socket", sock], { stdio: ["pipe", "pipe", "inherit"] });
let out = "";
child.stdout.on("data", (c) => { out += c; });
for (let i = 0; i < 50 && !out.includes("acquired"); i++) await new Promise((r) => setTimeout(r, 20));
assert.ok(api.tasks.has("turn-cli"), "held while the turn runs");
child.stdin.end();
const [code] = await once(child, "exit");
assert.equal(code, 0);
assert.equal(api.tasks.size, 0, "released at turn end");
assert.match(out, /"event":"released"/);
const listed = await spriteApi("GET", "/v1/tasks", null, { socket: sock });
assert.deepEqual(listed.body.tasks, []);
});
test("turn-hold CLI refuses a long expiry and reports an unreachable socket", async () => {
const run = (args) => new Promise((resolve) => {
const c = spawn(process.execPath, [path.join(ROOT, "mcp/turn-hold.mjs"), ...args], { stdio: ["ignore", "pipe", "ignore"] });
let out = ""; c.stdout.on("data", (d) => { out += d; });
c.on("exit", (code) => resolve({ code, out }));
});
let r = await run(["--name", "turn-1", "--expire", "1h", "--socket", path.join(dir, "none.sock")]);
assert.equal(r.code, 2);
assert.match(r.out, /"event":"refused"/);
r = await run(["--name", "turn-1", "--socket", path.join(dir, "none.sock")]);
assert.equal(r.code, 1);
assert.match(r.out, /"event":"acquire_failed"/);
});
+1
View File
@@ -493,6 +493,7 @@ impl AcpServer {
&resumed.thread_id,
UpdateThreadRequest {
permission_posture: Some(self.permission_value().to_string()),
full_access_confirmation: Some("session".to_string()),
allow_shell: Some(self.shell_allowed()),
..Default::default()
},
File diff suppressed because it is too large. Load diff
-1
View File
@@ -203,7 +203,6 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option<MessageId> {
"cmd_hotbar_description" => MessageId::CmdHotbarDescription,
"cmd_init_description" => MessageId::CmdInitDescription,
"cmd_jobs_description" => MessageId::CmdJobsDescription,
"cmd_dispatch_description" => MessageId::CmdDispatchDescription,
"cmd_lane_description" => MessageId::CmdLaneDescription,
"cmd_links_description" => MessageId::CmdLinksDescription,
"cmd_load_description" => MessageId::CmdLoadDescription,
+69 -35
View File
@@ -2277,6 +2277,7 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
.and_then(|settings| settings.permission_posture)
.as_deref()
.and_then(ApprovalMode::from_config_value)
.filter(|mode| *mode != ApprovalMode::Bypass)
.unwrap_or(ApprovalMode::Suggest);
app.set_agent_approval_posture(saved_mode);
app.clear_saved_approval_policy_lock();
@@ -2322,18 +2323,17 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) ->
if persist
&& matches!(control, crate::config::ApprovalPolicyControl::RootConfig) =>
{
match app.adopt_root_approval_posture(ApprovalMode::Bypass) {
Ok(()) => CommandResult::with_message_and_action(
"approval_mode = Full Access (saved as the TUI permission posture; removed the root approval_policy override)",
AppAction::ApprovalPolicyPersisted { policy: None },
),
Err(reason) => {
CommandResult::error(format!("Failed to save Full Access: {reason}"))
}
}
request_full_access_message(
app,
crate::tui::full_access_confirm::FullAccessOrigin::RootConfig,
)
}
Some(ApprovalMode::Bypass) if persist => CommandResult::error(
"Full Access is saved as the TUI permission posture, not as a top-level approval_policy. Remove the controlling policy first.",
"Full Access is per-session and is not saved as a top-level approval_policy. Remove the controlling policy first.",
),
Some(ApprovalMode::Bypass) => request_full_access_message(
app,
crate::tui::full_access_confirm::FullAccessOrigin::Config,
),
Some(m) => {
if persist {
@@ -3081,6 +3081,21 @@ fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) {
app.setting_locked_message(MessageId::SettingSubjectMode),
false,
),
SettingSelection::NeedsConfirmation => (
app.tr(MessageId::FullAccessConfirmTitle).into_owned(),
false,
),
}
}
fn request_full_access_message(
app: &mut App,
origin: crate::tui::full_access_confirm::FullAccessOrigin,
) -> CommandResult {
if app.request_full_access(origin) {
CommandResult::message(app.tr(MessageId::FullAccessConfirmTitle).into_owned())
} else {
CommandResult::message("approval_mode = Full Access (already on)")
}
}
@@ -3099,6 +3114,10 @@ fn switch_yolo_compat_with_status(app: &mut App) -> (String, bool) {
app.setting_locked_message(MessageId::SettingSubjectMode),
false,
),
SettingSelection::NeedsConfirmation => (
app.tr(MessageId::FullAccessConfirmTitle).into_owned(),
false,
),
}
}
@@ -3399,25 +3418,26 @@ fn clear_local_account_session() -> Result<bool, String> {
Ok(had)
}
fn clear_daytona_slot() -> Result<bool, String> {
fn clear_legacy_sandbox_slot() -> Result<bool, String> {
let secrets = codewhale_secrets::Secrets::auto_detect();
let had = secrets
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
.get(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT)
.map_err(|error| error.to_string())?
.is_some_and(|value| !value.trim().is_empty());
if had {
secrets
.delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)
.delete(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT)
.map_err(|error| error.to_string())?;
}
Ok(had)
}
/// Logout — clear the active provider key, the Codewhale account session,
/// and the Daytona slot. Named custom providers still clear only their own
/// and any legacy sandbox token. Named custom providers still clear only their own
/// table. For a full every-provider wipe, use `codewhale logout`.
pub fn logout(app: &mut App) -> CommandResult {
let provider_name = app.provider_identity_for_persistence().to_string();
let legacy_sandbox = clear_legacy_sandbox_slot();
match clear_active_provider_api_key(&provider_name) {
Ok(()) => {
app.onboarding = OnboardingState::Provider;
@@ -3431,12 +3451,10 @@ pub fn logout(app: &mut App) -> CommandResult {
Ok(false) => {}
Err(error) => cleared.push(format!("account session not cleared ({error})")),
}
match clear_daytona_slot() {
Ok(true) => cleared.push("internal cloud-agent token".to_string()),
match legacy_sandbox {
Ok(true) => cleared.push("legacy sandbox token".to_string()),
Ok(false) => {}
Err(error) => {
cleared.push(format!("internal cloud-agent token not cleared ({error})"))
}
Err(error) => cleared.push(format!("legacy sandbox token not cleared ({error})")),
}
CommandResult::with_message_and_action(
format!(
@@ -4065,9 +4083,18 @@ mod tests {
// user settings on the host machine.
let _ = mode(&mut app, Some("agent"));
let result = mode(&mut app, Some("yolo"));
// YOLO is invisible Act+Bypass shorthand — user-facing copy says Act.
assert!(result.message.unwrap().contains("Switched to Act mode"));
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent)));
assert_eq!(
app.view_stack.top_kind(),
Some(crate::tui::views::ModalKind::FullAccessConfirm)
);
assert_eq!(
result.message.as_deref(),
Some(app.tr(MessageId::FullAccessConfirmTitle).as_ref())
);
assert!(app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Yolo,
));
assert!(app.allow_shell);
assert!(app.trust_mode);
assert!(app.yolo);
@@ -4101,10 +4128,15 @@ mod tests {
let result = mode(&mut app, Some("9"));
assert!(result.is_error);
assert_eq!(app.mode, AppMode::Operate);
let result = mode(&mut app, Some("4"));
// "4" still routes to the deprecated YOLO alias, which lands in Agent
// mode with bypass approvals (M6 compat shim).
assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent)));
let _ = mode(&mut app, Some("4"));
assert_eq!(
app.view_stack.top_kind(),
Some(crate::tui::views::ModalKind::FullAccessConfirm)
);
assert!(app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Yolo,
));
assert_eq!(app.mode, AppMode::Agent);
assert!(app.yolo);
}
@@ -6290,7 +6322,7 @@ context_window = 262144
let result = set_config_value(&mut app, "approval_policy", "use-tui-default", true);
assert!(!result.is_error, "{:?}", result.message);
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert_eq!(app.approval_mode, ApprovalMode::Suggest);
assert!(!app.approval_policy_locked());
assert_eq!(
result.action,
@@ -6328,20 +6360,22 @@ context_window = 262144
let result = set_config_value(&mut app, "approval_policy", "full-access", true);
assert!(!result.is_error, "{:?}", result.message);
assert_eq!(
app.view_stack.top_kind(),
Some(crate::tui::views::ModalKind::FullAccessConfirm)
);
assert!(app.approval_policy_locked());
assert!(app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::RootConfig,
));
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert!(!app.approval_policy_locked());
assert_eq!(
result.action,
Some(AppAction::ApprovalPolicyPersisted { policy: None })
);
let saved_config = fs::read_to_string(config_path).unwrap();
assert!(saved_config.contains("# keep"));
assert!(!saved_config.contains("approval_policy"));
let saved_settings = Settings::load_persisted().expect("saved TUI settings");
assert_eq!(
saved_settings.permission_posture.as_deref(),
Some("full-access")
);
assert_eq!(saved_settings.permission_posture.as_deref(), Some("ask"));
}
#[test]
@@ -1,255 +0,0 @@
//! `/dispatch` — first-class Codewhale cloud-agent offload.
use codewhale_command_contract::facets::CommandWorkspaceContext;
use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
use crate::cloud_dispatch::{
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
discover_credentials, discover_machine_token, discover_remotes, execute_dispatch, format_job,
format_job_list, format_status, plan_dispatch,
};
use crate::commands::CommandResult;
use crate::dispatch_runner::spawn_confirmed_runner;
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
name: "dispatch",
aliases: &["cloud-agent", "cloud-dispatch"],
usage: "/dispatch [list|show <id>|confirm <id>|cancel <id>|<prompt> [--remote github|cnb|gitee]]",
description_key: "cmd_dispatch_description",
};
pub(in crate::commands) struct DispatchCmd;
impl RegisterCommand<CommandResult> for DispatchCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: codewhale_command_contract::handler::CommandCapabilities::WORKSPACE,
handler: dispatch_contextual,
}
}
}
fn dispatch_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
let mut parts = contexts.into_parts();
let Some(workspace) = parts.workspace.as_deref_mut() else {
return CommandResult::error("Command capability unavailable: workspace");
};
dispatch(workspace, arg)
}
fn dispatch(workspace: &mut dyn CommandWorkspaceContext, args: Option<&str>) -> CommandResult {
let raw = args.unwrap_or("").trim();
let store = match CloudJobStore::from_env() {
Ok(store) => store,
Err(error) => return CommandResult::error(error.to_string()),
};
if raw.is_empty() {
let recent = store.list().unwrap_or_default();
let recent: Vec<_> = recent.into_iter().take(5).collect();
return CommandResult::message(format_status(
&discover_remotes(&workspace.workspace()),
&discover_credentials(),
&recent,
));
}
let mut parts = raw.splitn(2, char::is_whitespace);
let verb = parts.next().unwrap_or("").to_ascii_lowercase();
let rest = parts.next().map(str::trim).unwrap_or("");
match verb.as_str() {
"list" => match store.list() {
Ok(jobs) => CommandResult::message(format_job_list(&jobs)),
Err(error) => CommandResult::error(error.to_string()),
},
"show" | "inspect" => {
if rest.is_empty() {
return CommandResult::error("Usage: /dispatch show <id>");
}
match store.load(rest) {
Ok(job) => CommandResult::message(format_job(&job)),
Err(error) => CommandResult::error(error.to_string()),
}
}
"confirm" => {
if rest.is_empty() {
return CommandResult::error("Usage: /dispatch confirm <id>");
}
match confirm_job(
&store,
rest,
&discover_credentials(),
&discover_machine_token(),
) {
Ok(outcome) => {
if let DispatchOutcome::Accepted(job) = &outcome {
// Detached: the TUI stays responsive and the job
// record streams progress; `/dispatch cancel` tears
// the sandbox down at any time.
spawn_confirmed_runner(store.clone(), job.id.clone());
}
CommandResult::message(outcome_message(&outcome))
}
Err(error) => CommandResult::error(error.to_string()),
}
}
"cancel" | "kill" | "stop" => {
if rest.is_empty() {
return CommandResult::error("Usage: /dispatch cancel <id>");
}
match cancel_job(&store, rest, &LiveDaytonaLauncher) {
Ok(job) => CommandResult::message(format_job(&job)),
Err(error) => CommandResult::error(error.to_string()),
}
}
_ => propose_or_run(workspace, &store, raw, false),
}
}
fn propose_or_run(
workspace: &mut dyn CommandWorkspaceContext,
store: &CloudJobStore,
raw: &str,
confirm: bool,
) -> CommandResult {
let (prompt, requested) = match split_prompt_and_remote(raw) {
Ok(parsed) => parsed,
Err(error) => return CommandResult::error(error),
};
let remotes = discover_remotes(&workspace.workspace());
let plan = match plan_dispatch(&remotes, &prompt, requested, None) {
Ok(plan) => plan,
Err(error) => return CommandResult::error(error.to_string()),
};
match execute_dispatch(
store,
plan,
confirm,
&discover_credentials(),
&discover_machine_token(),
) {
Ok(outcome) => {
if let DispatchOutcome::Accepted(job) = &outcome {
spawn_confirmed_runner(store.clone(), job.id.clone());
}
CommandResult::message(outcome_message(&outcome))
}
Err(error) => CommandResult::error(error.to_string()),
}
}
fn split_prompt_and_remote(raw: &str) -> Result<(String, Option<Forge>), String> {
let mut remote = None;
let mut prompt_parts = Vec::new();
let mut tokens = raw.split_whitespace().peekable();
while let Some(token) = tokens.next() {
if token == "--remote" {
let Some(value) = tokens.next() else {
return Err("Usage: /dispatch <prompt> --remote github|cnb|gitee".to_string());
};
remote = Some(
Forge::parse(value)
.ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?,
);
continue;
}
if let Some(value) = token.strip_prefix("--remote=") {
remote = Some(
Forge::parse(value)
.ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?,
);
continue;
}
prompt_parts.push(token);
}
let prompt = prompt_parts.join(" ");
if prompt.is_empty() {
return Err("Usage: /dispatch <prompt> [--remote github|cnb|gitee]".to_string());
}
Ok((prompt, remote))
}
fn outcome_message(outcome: &DispatchOutcome) -> String {
match outcome {
DispatchOutcome::Proposal(job)
| DispatchOutcome::Refused(job)
| DispatchOutcome::Accepted(job) => format_job(job),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
struct FakeWorkspace(PathBuf);
impl CommandWorkspaceContext for FakeWorkspace {
fn workspace(&self) -> PathBuf {
self.0.clone()
}
fn work_state_snapshot(&self) -> Result<Option<String>, String> {
Ok(None)
}
fn operation_digest(&mut self) -> Result<String, String> {
Ok("digest".to_string())
}
}
#[test]
fn parses_prompt_and_explicit_remote() {
let (prompt, remote) = split_prompt_and_remote("fix the flake --remote cnb").unwrap();
assert_eq!(prompt, "fix the flake");
assert_eq!(remote, Some(Forge::Cnb));
assert!(split_prompt_and_remote("--remote gitee").is_err());
assert_eq!(
split_prompt_and_remote("open pr --remote=github")
.unwrap()
.1,
Some(Forge::Github)
);
}
#[test]
fn handler_is_contextual_and_argument_aware() {
assert!(matches!(
DispatchCmd::handler(),
CommandHandler::Contextual { .. }
));
assert_eq!(
DispatchCmd::info().description_key,
"cmd_dispatch_description"
);
assert_eq!(
DispatchCmd::info().aliases,
&["cloud-agent", "cloud-dispatch"]
);
assert!(DispatchCmd::info().usage.starts_with("/dispatch"));
}
#[test]
fn missing_workspace_facet_fails_safely() {
let result = dispatch_contextual(CommandContexts::empty(), None);
assert!(result.is_error, "{result:?}");
assert_eq!(
result.message.as_deref(),
Some("Error: Command capability unavailable: workspace")
);
assert!(result.action.is_none());
}
#[test]
fn bare_dispatch_is_a_status_card_not_a_silent_launch() {
let mut workspace = FakeWorkspace(PathBuf::from("."));
let result = dispatch(&mut workspace, None);
assert!(!result.is_error, "{result:?}");
let message = result.message.expect("status message");
assert!(message.contains("Cloud agents"));
assert!(message.contains("fails closed") || message.contains("ready"));
assert!(result.action.is_none());
}
}
@@ -43,7 +43,6 @@ fn jobs(args: Option<&str>) -> CommandResult {
match action.as_str() {
"list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)),
"show" | "inspect" => match id {
Some(id) if id.starts_with("cloud_") => show_cloud_job(id),
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show {
id: id.to_string(),
})),
@@ -75,7 +74,6 @@ fn jobs(args: Option<&str>) -> CommandResult {
None => CommandResult::error("Usage: /jobs close-stdin <id>"),
},
"cancel" | "kill" | "stop" => match id {
Some(id) if id.starts_with("cloud_") => cancel_cloud_job(id),
Some(id) if is_cancel_all_token(id) => {
CommandResult::action(AppAction::ShellJob(ShellJobAction::CancelAll))
}
@@ -95,22 +93,6 @@ fn jobs(args: Option<&str>) -> CommandResult {
}
}
fn show_cloud_job(id: &str) -> CommandResult {
match crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.load(id)) {
Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
Err(error) => CommandResult::error(error.to_string()),
}
}
fn cancel_cloud_job(id: &str) -> CommandResult {
match crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| {
crate::cloud_dispatch::cancel_job(&store, id, &crate::cloud_dispatch::LiveDaytonaLauncher)
}) {
Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
Err(error) => CommandResult::error(error.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -136,10 +118,6 @@ mod tests {
Some(AppAction::ShellJob(ShellJobAction::CancelAll))
));
let cloud = jobs(Some("show cloud_deadbeef"));
assert!(cloud.action.is_none());
assert!(cloud.message.is_some() || cloud.is_error);
let cancel_all_spaced = jobs(Some("cancel all"));
assert!(matches!(
cancel_all_spaced.action,
@@ -3,7 +3,6 @@
mod attachment;
mod automation;
mod dispatch;
mod jobs;
mod mcp;
mod network;
@@ -31,10 +30,6 @@ impl CommandGroup for UtilityCommands {
Box::new(
ContextualCommand::from_contract::<jobs::JobsCmd>().expect("jobs registration"),
),
Box::new(
ContextualCommand::from_contract::<dispatch::DispatchCmd>()
.expect("dispatch registration"),
),
Box::new(ContextualCommand::from_contract::<mcp::McpCmd>().expect("mcp registration"),),
Box::new(
ContextualCommand::from_contract::<network::NetworkCmd>()
-2
View File
@@ -1224,7 +1224,6 @@ mod tests {
fn flagship_orchestration_and_workspace_commands_are_visible_at_the_palette_root() {
for name in [
"auto",
"dispatch",
"goal",
"hooks",
"tokens",
@@ -2093,7 +2092,6 @@ mod tests {
// FEAT-018 utility group.
"attach",
"automation",
"dispatch",
"jobs",
"mcp",
"network",
+2 -2
View File
@@ -65,8 +65,8 @@ pub(crate) const COMPATIBILITY_DISCOVERY_COMMANDS: &[&str] = &["subagents"];
/// control-plane actions through that registry, so it is a substrate rather
/// than a place to browse. The entry here only stops the product from
/// *teaching* a route it is not standing behind yet. Founder, 2026-09-03:
/// "remove /lane", "hide dispatch for now".
pub(crate) const UNLISTED_COMMANDS: &[&str] = &["lane", "dispatch"];
/// "remove /lane".
pub(crate) const UNLISTED_COMMANDS: &[&str] = &["lane"];
/// Small, task-oriented starting set for a bare `/` in the composer.
///
+2
View File
@@ -4400,9 +4400,11 @@ impl Config {
}
parsed
} else {
apply_profile(ConfigFile::default(), profile)?;
Config::default()
}
} else {
apply_profile(ConfigFile::default(), profile)?;
Config::default()
};
+31
View File
@@ -845,6 +845,37 @@ pub fn get_tool_category_for_call(name: &str, params: &Value) -> ToolCategory {
get_tool_category(canonical_action_alias(name, params))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FullAccessScope {
#[default]
Session,
Repo,
}
pub fn permission_label_for(
locale: codewhale_localization::Locale,
mode: AppMode,
approval: ApprovalMode,
scope: FullAccessScope,
) -> std::borrow::Cow<'static, str> {
use codewhale_localization::{MessageId, tr};
if mode == AppMode::Plan {
return tr(locale, MessageId::ChipPermissionReadOnly);
}
match approval {
ApprovalMode::Suggest => tr(locale, MessageId::ChipPermissionAsk),
ApprovalMode::Auto => tr(locale, MessageId::ChipPermissionAuto),
ApprovalMode::Bypass => tr(
locale,
match scope {
FullAccessScope::Session => MessageId::ChipPermissionFullAccessSession,
FullAccessScope::Repo => MessageId::ChipPermissionFullAccessRepo,
},
),
ApprovalMode::Never => tr(locale, MessageId::ChipPermissionNever),
}
}
#[cfg(test)]
mod tests {
use super::*;
+67 -12
View File
@@ -243,6 +243,9 @@ impl Grant {
if self.limits.expires_at.is_some_and(|at| at <= now) {
return Some(GrantState::Expired);
}
if self.effect == GrantEffect::Deny {
return None;
}
if let Some(ttl) = self.limits.idle_ttl_secs {
let base = self.limits.last_used_at.unwrap_or(self.created.at);
let ttl = Duration::seconds(i64::try_from(ttl).unwrap_or(i64::MAX / 2));
@@ -315,6 +318,8 @@ pub enum GrantError {
TooBroad,
#[error("grant store is degraded: {0}")]
Degraded(String),
#[error("a grant needs a non-empty thread or workspace id")]
InvalidScope,
#[error("grant is exhausted, expired or no longer active")]
NotUsable,
#[error("grant evidence could not be written: {0}")]
@@ -366,9 +371,34 @@ struct Inner {
dir: Option<PathBuf>,
grants: Vec<Grant>,
degraded: Option<String>,
stamp: Option<(std::time::SystemTime, u64)>,
}
impl Inner {
fn file_stamp(&self) -> Option<(std::time::SystemTime, u64)> {
let meta = fs::metadata(self.dir.as_ref()?.join(GRANTS_FILE)).ok()?;
Some((meta.modified().ok()?, meta.len()))
}
fn refresh(&mut self) {
let Some(dir) = &self.dir else {
return;
};
let stamp = self.file_stamp();
if stamp.is_none() || stamp == self.stamp {
return;
}
let Ok(bytes) = fs::read(dir.join(GRANTS_FILE)) else {
return;
};
if let Ok(file) = serde_json::from_slice::<GrantFile>(&bytes)
&& file.schema_v <= GRANT_SCHEMA_VERSION
{
self.grants = file.grants;
self.stamp = stamp;
}
}
fn persist(&self, grants: &[Grant], events: &[GrantEvent]) -> std::io::Result<()> {
let Some(dir) = &self.dir else {
return Ok(());
@@ -397,6 +427,7 @@ impl Inner {
fn commit(&mut self, grants: Vec<Grant>, events: Vec<GrantEvent>) -> std::io::Result<()> {
self.persist(&grants, &events)?;
self.grants = grants;
self.stamp = self.file_stamp();
Ok(())
}
@@ -405,9 +436,11 @@ impl Inner {
tracing::warn!(target: "approval", %error, "grant revocation evidence could not be written");
}
self.grants = grants;
self.stamp = self.file_stamp();
}
fn sweep(&mut self, now: DateTime<Utc>) {
self.refresh();
let mut grants = self.grants.clone();
let mut events = Vec::new();
for grant in &mut grants {
@@ -439,6 +472,7 @@ impl Inner {
reason: &str,
tighten: bool,
) -> Vec<Grant> {
self.refresh();
let mut grants = self.grants.clone();
let mut events = Vec::new();
let mut changed = Vec::new();
@@ -487,30 +521,31 @@ pub struct GrantStore {
impl GrantStore {
/// Open the file-backed store under `dir`. An unreadable or corrupt store
/// is moved aside and treated as holding no grants: the store never
/// allows from a file it cannot read.
/// is left untouched on disk and the store runs degraded (no grants, no
/// writes, see [`Self::degraded_reason`]): it never allows from a file it
/// cannot read, and it never overwrites standing denies it could not parse.
#[must_use]
pub fn open(dir: &Path) -> Self {
let mut inner = Inner {
dir: Some(dir.to_path_buf()),
grants: Vec::new(),
degraded: None,
stamp: None,
};
let path = dir.join(GRANTS_FILE);
match fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<GrantFile>(&bytes) {
Ok(file) if file.schema_v <= GRANT_SCHEMA_VERSION => inner.grants = file.grants,
Ok(file) if file.schema_v <= GRANT_SCHEMA_VERSION => {
inner.grants = file.grants;
inner.stamp = inner.file_stamp();
}
Ok(file) => {
inner.degraded = Some(format!("grant store schema {} is newer", file.schema_v));
inner.dir = None;
}
Err(error) => {
let aside = dir.join(format!(
"{GRANTS_FILE}.corrupt-{}",
Utc::now().timestamp_millis()
));
let _ = fs::rename(&path, aside);
inner.degraded = Some(format!("grant store was unreadable: {error}"));
inner.dir = None;
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
@@ -523,7 +558,10 @@ impl GrantStore {
tracing::warn!(target: "approval", %reason, "standing grants are unavailable; every call asks");
}
inner.transition(
|grant| matches!(grant.scope, GrantScope::Thread { .. }),
|grant| {
grant.effect == GrantEffect::Allow
&& matches!(grant.scope, GrantScope::Thread { .. })
},
GrantState::Active,
GrantState::Suspended,
"suspended",
@@ -536,12 +574,24 @@ impl GrantStore {
}
}
#[must_use]
pub fn degraded_reason(&self) -> Option<String> {
self.inner.lock().degraded.clone()
}
/// Create a grant. Allow grants cannot cover a whole tool class, and an
/// identical live grant is returned instead of duplicated.
pub fn create(&self, new: NewGrant) -> Result<Grant, GrantError> {
if new.effect == GrantEffect::Allow && !new.matcher.narrows_beyond_class() {
return Err(GrantError::TooBroad);
}
let empty_id = match &new.scope {
GrantScope::Thread { thread_id } => thread_id.is_empty(),
GrantScope::Workspace { workspace_id } => workspace_id.is_empty(),
};
if empty_id {
return Err(GrantError::InvalidScope);
}
let mut inner = self.inner.lock();
if let Some(reason) = &inner.degraded
&& inner.dir.is_none()
@@ -552,7 +602,7 @@ impl GrantStore {
inner.sweep(now);
let matcher_digest = new.matcher.digest();
if let Some(existing) = inner.grants.iter().find(|grant| {
grant.state == GrantState::Active
matches!(grant.state, GrantState::Active | GrantState::Suspended)
&& grant.effect == new.effect
&& grant.scope == new.scope
&& grant.matcher_digest == matcher_digest
@@ -560,7 +610,10 @@ impl GrantStore {
return Ok(existing.clone());
}
let mut limits = new.limits;
if limits.idle_ttl_secs.is_none() && matches!(new.scope, GrantScope::Workspace { .. }) {
if limits.idle_ttl_secs.is_none()
&& new.effect == GrantEffect::Allow
&& matches!(new.scope, GrantScope::Workspace { .. })
{
limits.idle_ttl_secs = Some(DEFAULT_WORKSPACE_IDLE_TTL_SECS);
}
let grant = Grant {
@@ -611,7 +664,9 @@ impl GrantStore {
grant.state == GrantState::Active
&& match &grant.scope {
GrantScope::Thread { thread_id } => thread_id == query.thread_id,
GrantScope::Workspace { workspace_id } => workspace_id == query.workspace_id,
GrantScope::Workspace { workspace_id } => {
!workspace_id.is_empty() && workspace_id == query.workspace_id
}
}
&& grant.matcher.matches(query)
};
+3 -5
View File
@@ -8111,11 +8111,9 @@ impl Engine {
}
};
// Boot is lazy (#6033): a configured server nobody asked for is not
// spawned at session start. The eager set is `required` servers plus
// whatever the session's explicit tool selections cover; everything
// else connects on demand — a selected turn, a `/mcp` connect, or a
// lazy tool-name resolution.
// Boot connects every configured server in the background except
// those marked `lazy = true`, which connect on demand — a selected
// turn, a `/mcp` connect, or a lazy tool-name resolution.
let requested = self.explicit_mcp_tool_names(self.config.allowed_tools.as_deref());
let (pending, auth_errors, timeouts, network_policy, catalog_generation, backend) = {
let mut pool = pool.lock().await;
@@ -1470,7 +1470,7 @@ readline.createInterface({ input: process.stdin }).on('line', line => {
serde_json::to_vec(&serde_json::json!({
"servers": {
"eager": { "command": node, "args": [server, "eager", tmp.path()], "required": true },
"lazy": { "command": node, "args": [server, "lazy", tmp.path()] }
"lazy": { "command": node, "args": [server, "lazy", tmp.path()], "lazy": true }
}
}))
.unwrap(),
@@ -1642,11 +1642,11 @@ readline.createInterface({input:process.stdin}).on('line', line => {
fs::write(
&config_path,
serde_json::to_vec(&json!({"servers": {
"engram": {"command":node,"args":[server,"engram",tmp.path()],"required":false},
"unrelated": {"command":node,"args":[server,"unrelated",tmp.path()],"required":false},
"engram": {"command":node,"args":[server,"engram",tmp.path()],"required":false,"lazy":true},
"unrelated": {"command":node,"args":[server,"unrelated",tmp.path()],"required":false,"lazy":true},
"disabled": {"command":node,"args":[server,"disabled",tmp.path()],"enabled":false},
"stalled": {"command":node,"args":[server,"stalled",tmp.path()],"required":false},
"failed": {"command":"codewhale-missing-mcp-discovery-6828"}
"stalled": {"command":node,"args":[server,"stalled",tmp.path()],"required":false,"lazy":true},
"failed": {"command":"codewhale-missing-mcp-discovery-6828","lazy":true}
}}))
.unwrap(),
)
File diff suppressed because it is too large. Load diff
+7 -23
View File
@@ -27,7 +27,6 @@ mod auto_reasoning;
mod automation_manager;
mod child_env;
mod client;
pub mod cloud_dispatch;
mod codex_model_cache;
mod commands;
mod compaction;
@@ -45,7 +44,6 @@ mod credentials;
/// Guarded installation I/O reusing Engine's confined file primitives.
pub mod delivery_files;
mod dependencies;
pub mod dispatch_runner;
mod doctor;
mod doctor_fix;
mod dsh_credentials;
@@ -143,10 +141,10 @@ use codewhale_release::tls;
// re-exports; the split deletes it by rewriting these paths to
// `codewhale_runtime::` (docs/design/TUI_DECONSTRUCTION.md).
use codewhale_runtime::{
computer_meter, context_budget, continual_harness, fast_hash, features, goal_loop, hashing,
host_terminal, lane_control, llm_response_cache, logging, media_originals, model_context,
native_memory, prompt_zones, regex_cache, resource_telemetry, retry_status, runtime_policy,
safe_label, session_tree, skill_state, sleep_guard, startup_trace, tool_history_repair,
context_budget, continual_harness, fast_hash, features, goal_loop, hashing, host_terminal,
lane_control, llm_response_cache, logging, media_originals, model_context, native_memory,
prompt_zones, regex_cache, resource_telemetry, retry_status, runtime_policy, safe_label,
session_tree, skill_state, sleep_guard, startup_trace, tool_history_repair,
workspace_discovery,
};
mod todo_snapshot;
@@ -12024,6 +12022,7 @@ async fn run_mcp_command(
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: std::collections::HashMap::new(),
@@ -12175,6 +12174,7 @@ async fn run_mcp_command(
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: std::collections::HashMap::new(),
@@ -13514,23 +13514,6 @@ async fn run_interactive_prepared(
{
let _ = manager.cleanup_old_sessions_keeping(janitor_resume_id.as_deref());
}
// Cloud-dispatch orphan reconciliation: a previous TUI could quit
// (or crash) with a detached cloud-agent runner in flight, leaving
// an active job record — and possibly a billing sandbox — behind.
// Stale active jobs are failed and torn down, then any
// dispatch-labeled sandbox whose job no longer needs it is deleted
// by label. Best effort: one bounded listing call when credentials
// exist, fail-closed quiet otherwise, never fatal.
if let Ok(store) = crate::cloud_dispatch::CloudJobStore::from_env() {
let receipt = crate::dispatch_runner::startup_reconcile(
&store,
&crate::cloud_dispatch::LiveDaytonaLauncher,
);
if !receipt.is_empty() {
logging::info(receipt);
}
}
});
// A launcher can forward `--yolo` to this binary via the CODEWHALE_YOLO
@@ -18906,6 +18889,7 @@ mod doctor_mcp_tests {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: std::collections::HashMap::new(),
+1
View File
@@ -152,6 +152,7 @@ fn make_server(command: Option<&str>, args: &[&str], url: Option<&str>) -> McpSe
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: std::collections::HashMap::new(),
+11 -4
View File
@@ -618,6 +618,8 @@ pub struct McpServerConfig {
pub enabled: bool,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub lazy: bool,
#[serde(default)]
pub enabled_tools: Vec<String>,
#[serde(default)]
@@ -4391,16 +4393,19 @@ impl McpPool {
self.connecting.iter().cloned().collect()
}
/// Enabled, allowed configured servers the boot pass must still start
/// eagerly under lazy boot (#6033): servers marked `required`, plus any
/// server the session's explicit tool selections cover.
/// Enabled, allowed configured servers the boot pass starts at session
/// start: every user-configured one except those marked `lazy = true`.
/// Plugin-contributed servers and lazy ones connect on demand unless
/// `required` or covered by the session's explicit tool selections.
pub(crate) fn eager_boot_server_names(&self, requested: &[String]) -> HashSet<String> {
self.config
.servers
.iter()
.filter(|(name, server)| server.is_enabled() && self.server_allowed(name))
.filter(|(name, server)| {
server.required || tool_selection_covers_server(requested, name)
server.required
|| (!server.lazy && server.reviewed_plugin.is_none())
|| tool_selection_covers_server(requested, name)
})
.map(|(name, _)| name.clone())
.collect()
@@ -7312,6 +7317,7 @@ fn mcp_template_json() -> Result<String> {
disabled: true,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
@@ -7378,6 +7384,7 @@ pub fn add_server_config(
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
+1 -1
View File
@@ -1723,7 +1723,7 @@ impl OauthLoginFlow {
.await
.with_context(|| {
let retry_hint = match announce {
OAuthLoginAnnounce::Terminal => "Retry from a terminal, or use task_shell_start/background shell if an agent is running the login flow.".to_string(),
OAuthLoginAnnounce::Terminal => "Retry from a terminal, or use a background shell (background=true) if an agent is running the login flow.".to_string(),
OAuthLoginAnnounce::Tool { .. } => format!(
"The user can complete the sign-in directly via `/mcp login {}` or `codewhale mcp login {}`, then this tool can be called again.",
self.server_name, self.server_name
+14 -2
View File
@@ -324,6 +324,7 @@ fn mcp_server_config_omits_headers_when_empty() {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
@@ -2892,6 +2893,7 @@ fn test_server_effective_timeouts() {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
@@ -3053,6 +3055,7 @@ fn test_server_config() -> McpServerConfig {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
@@ -3863,6 +3866,7 @@ fn hash_mcp_config_is_stable_and_change_sensitive() {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
@@ -4900,12 +4904,17 @@ async fn a_failed_server_waits_out_a_cooldown_instead_of_redialing_every_turn()
fn lazy_boot_scopes_pending_connects_and_tracks_in_flight() {
let mut required_cfg = test_server_config();
required_cfg.required = true;
required_cfg.lazy = true;
let mut selected_cfg = test_server_config();
selected_cfg.lazy = true;
let mut lazy_cfg = test_server_config();
lazy_cfg.lazy = true;
let mut pool = McpPool::new(McpConfig {
timeouts: McpTimeouts::default(),
servers: HashMap::from([
("needed".to_string(), required_cfg),
("selected".to_string(), test_server_config()),
("lazy".to_string(), test_server_config()),
("selected".to_string(), selected_cfg),
("lazy".to_string(), lazy_cfg),
]),
});
let requested = vec!["mcp_selected_read".to_string()];
@@ -5183,6 +5192,7 @@ async fn mcp_connection_supports_streamable_http_event_stream_responses() {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
@@ -6155,6 +6165,7 @@ async fn streamable_http_stale_session_reconnects_and_retries_tool_call() {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
@@ -6444,6 +6455,7 @@ async fn legacy_sse_closed_stream_reports_unknown_outcome_and_reconnects_without
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
+1
View File
@@ -851,6 +851,7 @@ fn standard_server_to_config(
disabled: extension.disabled.unwrap_or(false),
enabled: extension.enabled.unwrap_or(true),
required: extension.required.unwrap_or(false),
lazy: false,
enabled_tools: extension.enabled_tools,
disabled_tools: extension.disabled_tools,
headers: server.headers.into_iter().collect(),
-2
View File
@@ -90,7 +90,6 @@ const COMPUTER_USE_FILES: &[(&str, &[u8])] = &[
bundle_file!("app/install-macos.mjs"),
bundle_file!("app/updates.mjs"),
bundle_file!("mcp/server.mjs"),
bundle_file!("mcp/turn-hold.mjs"),
bundle_file!("src/app-handler.mjs"),
bundle_file!("src/app-script-policy.mjs"),
bundle_file!("src/app-socket.mjs"),
@@ -103,7 +102,6 @@ const COMPUTER_USE_FILES: &[(&str, &[u8])] = &[
bundle_file!("src/recordings.mjs"),
bundle_file!("src/registry.mjs"),
bundle_file!("src/remote-runtime.mjs"),
bundle_file!("src/sprite-task.mjs"),
bundle_file!("src/ssh-args.mjs"),
bundle_file!("src/tools.mjs"),
bundle_file!("src/trajectory.mjs"),
+1
View File
@@ -6635,6 +6635,7 @@ fn mcp_server_config_from_write_request(
disabled: !enabled,
enabled,
required: req.required.unwrap_or(false),
lazy: false,
enabled_tools: req.enabled_tools.unwrap_or_default(),
disabled_tools: req.disabled_tools.unwrap_or_default(),
headers: std::collections::HashMap::new(),
+1
View File
@@ -9154,6 +9154,7 @@ async fn patch_thread_accepts_extended_field_set() -> Result<()> {
"auto_approve": true,
"model": "deepseek-v4-pro",
"mode": "yolo",
"full_access_confirmation": "session",
"title": "Whalescale UI test thread",
"system_prompt": "You are a useful assistant."
}))
+11 -2
View File
@@ -1912,8 +1912,16 @@ fn decode_history_overrides(
let mut prompt_parts = Vec::new();
for (key, value) in fields {
match key.as_str() {
"model" | "model_provider" | "model_provider_id" | "mode" | "permission_posture"
| "allow_shell" | "trust_mode" | "auto_approve" | "system_prompt" => {
"model"
| "model_provider"
| "model_provider_id"
| "mode"
| "permission_posture"
| "full_access_confirmation"
| "allow_shell"
| "trust_mode"
| "auto_approve"
| "system_prompt" => {
ensure!(
wire.insert(key.clone(), value.clone()).is_none(),
"conflicting history configuration fields"
@@ -1963,6 +1971,7 @@ fn decode_history_overrides(
| "model_provider_id"
| "mode"
| "permission_posture"
| "full_access_confirmation"
| "allow_shell"
| "trust_mode"
| "auto_approve"
+165 -2
View File
@@ -4946,6 +4946,8 @@ pub struct UpdateThreadRequest {
pub model: Option<String>,
pub mode: Option<String>,
pub permission_posture: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub full_access_confirmation: Option<String>,
pub title: Option<String>,
pub system_prompt: Option<String>,
pub workspace: Option<PathBuf>,
@@ -5340,7 +5342,7 @@ pub struct PendingApprovalRequest {
/// prompts again until then (fail closed).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeApprovalGrant {
/// Runtime-minted `grant_<32 hex>`; the revoke endpoint accepts only this.
/// Runtime-minted `grt_<32 hex>`; the revoke endpoint accepts only this.
pub grant_id: String,
pub tool_name: String,
/// The approval grouping key the grant matches (tool + argument class).
@@ -7697,6 +7699,9 @@ impl RuntimeThreadManager {
crate::config::initialize_cloud_facts(&config);
let (event_tx, _event_rx) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
let grants = crate::core::authority::grants::GrantStore::open(&manager_cfg.data_dir);
if let Some(reason) = grants.degraded_reason() {
tracing::warn!(%reason, "approval grants are unavailable; remembered approvals will not apply");
}
let manager = Self {
host_profile,
config: Arc::new(parking_lot::RwLock::new(config)),
@@ -10989,6 +10994,11 @@ impl RuntimeThreadManager {
{
bail!("permission_posture must not be empty");
}
if let Some(confirmation) = req.full_access_confirmation.as_deref()
&& !matches!(confirmation, "session" | "repo")
{
bail!("full_access_confirmation must be \"session\" or \"repo\"");
}
if let Some(workspace) = req.workspace.as_ref()
&& workspace.as_os_str().is_empty()
{
@@ -11046,7 +11056,59 @@ impl RuntimeThreadManager {
None
};
let configured_sandbox_mode = self.read_config().sandbox_mode.clone();
let (thread, changes, evicted_engine, posture_engine, ended_grants) = {
if (req.mode.is_some() || req.permission_posture.is_some() || req.auto_approve.is_some())
&& let Ok(current) = self.store.load_thread(id)
&& let Ok(next) = runtime_policy_with_overrides(
&current,
req.mode.as_deref(),
req.permission_posture.as_deref(),
req.auto_approve,
)
{
let was_full = RuntimePolicyProjection::from_persisted(
&current.mode,
current.permission_posture.as_deref(),
current.auto_approve,
)
.permission
== ApprovalMode::Bypass;
let is_full = next.permission == ApprovalMode::Bypass;
let keep =
is_full && !was_full && req.full_access_confirmation.as_deref() == Some("repo");
let forget = was_full && !is_full;
if keep || forget {
let workspace = if keep {
req.workspace
.clone()
.unwrap_or_else(|| current.workspace.clone())
} else {
current.workspace.clone()
};
#[cfg(test)]
let env_ticket = crate::test_support::env_scope_ticket();
let saved = tokio::task::spawn_blocking(move || {
#[cfg(test)]
let _membership = crate::test_support::join_env_scope(env_ticket);
crate::settings::Settings::transact(|settings| {
settings.set_full_access_repo(&workspace, keep)
})
})
.await
.map_err(|error| anyhow!("Full Access repo save task failed: {error}"))?;
match saved {
Ok(_) => {}
Err(error) if keep => {
return Err(error.context(
"Full Access could not be kept for this repo; the thread was not changed",
));
}
Err(error) => {
tracing::warn!(%error, thread_id = id, "Full Access repo opt-in was not forgotten");
}
}
}
}
let (thread, changes, evicted_engine, posture_engine, ended_grants, permission_receipt) = {
// Take the active guard first so a workspace mutation can check
// and evict the cached engine atomically with the durable update.
// Using the same order as start/compact avoids lock inversion.
@@ -11093,6 +11155,20 @@ impl RuntimeThreadManager {
} else {
None
};
let previous_policy = RuntimePolicyProjection::from_persisted(
&thread.mode,
thread.permission_posture.as_deref(),
thread.auto_approve,
);
if let Some(policy) = policy_patch.as_ref()
&& policy.permission == ApprovalMode::Bypass
&& previous_policy.permission != ApprovalMode::Bypass
&& req.full_access_confirmation.is_none()
{
bail!(
"full_access_confirmation_required: turning on Full Access needs full_access_confirmation set to \"session\" or \"repo\""
);
}
if let Some(archived) = req.archived
&& thread.archived != archived
@@ -11217,6 +11293,32 @@ impl RuntimeThreadManager {
} else {
None
};
let permission_receipt = policy_patch.map(|policy| {
let scope = if policy.permission == ApprovalMode::Bypass
&& req.full_access_confirmation.as_deref() == Some("repo")
{
crate::core::authority::FullAccessScope::Repo
} else {
crate::core::authority::FullAccessScope::Session
};
let label = |policy: RuntimePolicyProjection, scope| {
crate::core::authority::permission_label_for(
codewhale_localization::Locale::En,
policy.mode,
policy.permission,
scope,
)
.into_owned()
};
(
label(
previous_policy,
crate::core::authority::FullAccessScope::Session,
),
label(policy, scope),
scope,
)
});
// Archiving ends the conversation's session grants: unarchiving
// later starts from a clean slate and the next call prompts.
let ended_grants = if changes.get("archived") == Some(&json!(true)) {
@@ -11230,6 +11332,7 @@ impl RuntimeThreadManager {
evicted_engine,
posture_engine,
ended_grants,
permission_receipt,
)
};
@@ -11281,9 +11384,69 @@ impl RuntimeThreadManager {
.await?;
}
if let Some((from, to, scope)) = permission_receipt
&& from != to
{
self.publish_permissions_changed(&thread, &from, &to, scope)
.await?;
}
Ok(thread)
}
async fn publish_permissions_changed(
&self,
thread: &ThreadRecord,
from: &str,
to: &str,
scope: crate::core::authority::FullAccessScope,
) -> Result<()> {
let message = codewhale_localization::tr(
codewhale_localization::Locale::En,
codewhale_localization::MessageId::PermissionsChangedReceipt,
)
.replace("{label}", to);
let scope_name = match scope {
crate::core::authority::FullAccessScope::Session => "session",
crate::core::authority::FullAccessScope::Repo => "repo",
};
if let Some(turn_id) = thread.latest_turn_id.as_deref() {
let item = TurnItemRecord {
schema_version: CURRENT_RUNTIME_SCHEMA_VERSION,
id: runtime_record_id("item"),
turn_id: turn_id.to_string(),
kind: TurnItemKind::Status,
status: TurnItemLifecycleStatus::Completed,
summary: summarize_text(&message, SUMMARY_LIMIT),
detail: Some(message),
metadata: Some(json!({ "permissions_changed": { "from": from, "to": to } })),
artifact_refs: Vec::new(),
artifacts: Vec::new(),
started_at: Some(Utc::now()),
ended_at: Some(Utc::now()),
};
self.store.save_item(&item)?;
self.attach_item_to_turn(turn_id, &item.id)?;
self.emit_event(
&thread.id,
Some(turn_id),
Some(&item.id),
"item.completed",
json!({ "item": item }),
)
.await?;
}
self.emit_event(
&thread.id,
None,
None,
"thread.permissions_changed",
json!({ "from": from, "to": to, "scope": scope_name, "source": "runtime_api" }),
)
.await?;
Ok(())
}
/// Save/resume holds this existing admission lease from snapshot through
/// binding, so a newer turn cannot be mistaken for part of the snapshot.
pub(crate) async fn session_checkpoint_guard(&self) -> tokio::sync::OwnedRwLockWriteGuard<()> {
+7 -1
View File
@@ -10693,6 +10693,7 @@ async fn active_turn_permission_posture_switches_use_the_engine_live_authority()
&thread.id,
UpdateThreadRequest {
permission_posture: Some(requested.to_string()),
full_access_confirmation: Some("session".to_string()),
..Default::default()
},
)
@@ -16184,7 +16185,12 @@ async fn runtime_approval_disposition_auto_paths_obey_force_and_posture() -> Res
if approved && posture == "ask" {
assert_eq!(decided.payload["grant_id"], grant.unwrap().grant_id);
} else {
assert!(decided.payload.get("grant_id").is_none_or(serde_json::Value::is_null));
assert!(
decided
.payload
.get("grant_id")
.is_none_or(serde_json::Value::is_null)
);
}
let approval_id = decided.payload["approval_id"]
.as_str()
+39 -1
View File
@@ -417,13 +417,17 @@ pub struct Settings {
/// Default reasoning effort selected from the TUI model picker.
/// `None` falls back to `config.toml` and then the runtime default.
pub reasoning_effort: Option<String>,
/// TUI-only Shift+Tab posture: ask, auto-review, or full-access.
/// TUI-only Shift+Tab posture: ask or auto-review.
/// An explicit/managed `config.toml` approval policy always takes
/// precedence, so this preference cannot loosen project requirements.
/// This is **tool-approval posture**, not filesystem scope — see
/// [`Self::sandbox_mode`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_posture: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub full_access_repos: Vec<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub full_access_migration_shown: bool,
/// Filesystem sandbox scope, independent of approval posture:
/// `read-only | workspace-write | danger-full-access | external-sandbox`.
/// Surfaced in Settings and the shell so "Full Access" (approval) is
@@ -598,6 +602,8 @@ impl Default for Settings {
default_model: None,
reasoning_effort: None,
permission_posture: None,
full_access_repos: Vec::new(),
full_access_migration_shown: false,
sandbox_mode: None,
provider_models: None,
enabled_models: None,
@@ -1939,6 +1945,38 @@ impl Settings {
lines.join("\n")
}
#[must_use]
pub fn full_access_repo_kept(&self, workspace: &std::path::Path) -> bool {
Self::full_access_repo_key(workspace)
.is_some_and(|key| self.full_access_repos.iter().any(|kept| kept == &key))
}
#[must_use]
pub fn full_access_repo_key(workspace: &std::path::Path) -> Option<String> {
codewhale_execpolicy::normalize_workspace_scope(&workspace.to_string_lossy())
}
pub fn set_full_access_repo(
&mut self,
workspace: &std::path::Path,
kept: bool,
) -> Result<bool> {
let key = Self::full_access_repo_key(workspace).ok_or_else(|| {
anyhow::anyhow!(
"cannot keep Full Access for '{}': not an absolute, non-root workspace path",
workspace.display()
)
})?;
let before = self.full_access_repos.clone();
self.full_access_repos.retain(|existing| existing != &key);
if kept {
self.full_access_repos.push(key);
}
self.full_access_repos.sort();
self.full_access_repos.dedup();
Ok(self.full_access_repos != before)
}
/// Toggle one exact provider/model pin without touching credentials or
/// the provider's default route.
pub fn toggle_pinned_model(&mut self, provider: &str, model: &str) -> bool {
+9 -10
View File
@@ -1193,9 +1193,9 @@ impl ToolRegistryBuilder {
/// Each family is one tool with an `action` parameter (`tasks`, `github`,
/// `automation`). Per-action execution aliases were removed in v0.9.3.
///
/// Shell-related task tools (`task_shell_start`, `task_shell_wait`) are
/// *not* included here — use `with_runtime_task_shell_tools` to register
/// them when `allow_shell` is true.
/// The shell-related task tool (`task_shell_wait`) is *not* included here —
/// use `with_runtime_task_shell_tools` to register it when `allow_shell`
/// is true.
#[must_use]
pub fn with_runtime_task_tools(self) -> Self {
use super::automation::AutomationTool;
@@ -1209,16 +1209,15 @@ impl ToolRegistryBuilder {
.with_tool(Arc::new(SendLaterTool::new("send_later")))
}
/// Include shell-related task tools (`task_shell_start`, `task_shell_wait`).
/// Include the shell-related task tool (`task_shell_wait`).
///
/// These are gated behind `allow_shell` because `task_shell_start`
/// delegates directly to `BashTool`, providing the same shell
/// execution capability as `Bash`.
/// Gated behind `allow_shell` because it polls and cancels the background
/// jobs that `Bash` starts; background work has one entry point, the shell
/// tool's `background=true`.
#[must_use]
pub fn with_runtime_task_shell_tools(self) -> Self {
use super::tasks::{TaskShellStartTool, TaskShellWaitTool};
self.with_tool(Arc::new(TaskShellStartTool))
.with_tool(Arc::new(TaskShellWaitTool))
use super::tasks::TaskShellWaitTool;
self.with_tool(Arc::new(TaskShellWaitTool))
}
/// Include only read-only durable task, PR-attempt, GitHub, and automation
+3 -4
View File
@@ -20,7 +20,7 @@ use super::{
#[tokio::test]
async fn shell_denial_reaches_registry_and_direct_delegation_sinks() {
use crate::tools::run_tool::RunTool;
use crate::tools::tasks::{TaskShellStartTool, TasksTool};
use crate::tools::tasks::TasksTool;
use crate::tools::terminal_session::{TerminalResetTool, TerminalRunTool, TerminalSendTool};
use crate::tools::test_runner::RunTestsTool;
use crate::tools::verifier::RunVerifiersTool;
@@ -35,7 +35,6 @@ async fn shell_denial_reaches_registry_and_direct_delegation_sinks() {
Arc::new(BashTool::alias("exec_interact", "interact")),
json!({"task_id":"missing", "stdin":command, "action":"wait"}),
),
(Arc::new(TaskShellStartTool), json!({"command":command})),
(
Arc::new(TasksTool::new("tasks")),
json!({"action":"gate_run", "gate":"custom", "command":command}),
@@ -1986,8 +1985,8 @@ fn agent_tools_with_shell_policy_full_includes_shell_tools() {
assert!(registry.contains("Bash"));
assert!(!registry.contains("exec_shell"));
assert!(
registry.contains("task_shell_start"),
"task_shell_start should be included when the shell policy is Full"
!registry.contains("task_shell_start"),
"task_shell_start is removed; background shell work goes through bash background=true"
);
assert!(
registry.contains("task_shell_wait"),
+2
View File
@@ -51,6 +51,7 @@ pub fn parse_mcp_command(input: &str) -> Result<ParsedMcpServer> {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
@@ -90,6 +91,7 @@ pub fn parse_mcp_command(input: &str) -> Result<ParsedMcpServer> {
disabled: false,
enabled: true,
required: false,
lazy: false,
enabled_tools: Vec::new(),
disabled_tools: Vec::new(),
headers: HashMap::new(),
+62 -13
View File
@@ -48,7 +48,9 @@ use portable_pty::{CommandBuilder, PtySize, native_pty_system};
mod guidance;
mod output;
use super::shell_output::{summarize_output, truncate_with_meta};
use super::shell_output::{
kill_outcome_note, output_phase_hint, summarize_output, truncate_with_meta,
};
use crate::child_env;
use crate::sandbox::{
CommandSpec,
@@ -3189,6 +3191,11 @@ impl ShellManager {
Ok(())
}
pub fn last_output_age_ms(&self, task_id: &str) -> Option<u64> {
let shell = self.processes.get(task_id)?;
Some(u64::try_from(shell.last_output_at.elapsed().as_millis()).unwrap_or(u64::MAX))
}
/// Kill a running background process
pub fn kill(&mut self, task_id: &str) -> Result<ShellResult> {
let shell = self
@@ -5254,7 +5261,7 @@ const FOREGROUND_POLL_MAX_MS: u64 = 100;
/// no `timeout_ms`. Matches the value the tool's own input schema advertises;
/// before this existed the omitted case fell through to
/// `BASH_MAX_TIMEOUT_MS`.
const CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS: u64 = 120_000;
const CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS: u64 = 30_000;
/// Resolve the lifetime for one `bash` run.
///
@@ -5262,7 +5269,7 @@ const CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS: u64 = 120_000;
/// `BASH_MAX_TIMEOUT_MS` (~24.8 days), so a command that blocked on an
/// interactive prompt or a hung network call pinned the turn indefinitely —
/// the tool row just counted seconds while the model waited. The tool's own
/// schema already promises `action=run 120000`, so an omitted timeout takes
/// schema already promises `action=run 30000`, so an omitted timeout takes
/// that default as the foreground wait. Past it the command moves to the
/// background (it is never killed for running long) and the model gets its
/// output so far and task id.
@@ -5283,6 +5290,27 @@ fn contract_bash_timeout_ms(
requested_ms
}
fn promotion_notice(
context: &ToolContext,
task_id: &str,
elapsed_seconds: u64,
output: &str,
) -> String {
let last_output = context
.shell_manager
.lock()
.unwrap_or_else(|error| error.into_inner())
.last_output_age_ms(task_id)
.map_or_else(
|| "last output age unknown".to_string(),
|ms| format!("last output {}s ago", ms / 1_000),
);
format!(
"Still running after {elapsed_seconds}s ({last_output}; {}); moved to the background as {task_id} (not killed).",
output_phase_hint(output)
)
}
fn contract_bash_error_status(result: &ShellResult, timeout_ms: Option<u64>) -> String {
match result.status {
ShellStatus::TimedOut => {
@@ -5294,7 +5322,10 @@ fn contract_bash_error_status(result: &ShellResult, timeout_ms: Option<u64>) ->
};
format!("Command timed out after {seconds} seconds")
}
ShellStatus::Killed => "Command aborted".to_string(),
ShellStatus::Killed => format!(
"Command aborted. {}",
kill_outcome_note(&format!("{}{}", result.stdout, result.stderr))
),
ShellStatus::Failed | ShellStatus::Completed | ShellStatus::Running => format!(
"Command exited with code {}",
result.exit_code.unwrap_or(-1)
@@ -5334,9 +5365,9 @@ fn finish_contract_bash_result(
} else {
tail_lines(output.trim(), 20)
};
let notice = promotion_notice(context, task_id, result.duration_ms / 1_000, &output);
return Ok(ToolResult::success(format!(
"Still running after {}s; moved to the background as {task_id} (not killed).\n\nOutput so far:\n{so_far}\n\nCompletion will appear as a runtime event. To see more output or block until it finishes, call `tool_search` for `task_shell_wait`, then `task_shell_wait` with task_id=\"{task_id}\". To stop it, send the same call with cancel=true. It also stops when the session ends.",
result.duration_ms / 1_000
"{notice}\n\nOutput so far:\n{so_far}\n\nCompletion will appear as a runtime event. To see more output or block until it finishes, call `tool_search` for `task_shell_wait`, then `task_shell_wait` with task_id=\"{task_id}\". To stop it, send the same call with cancel=true. It also stops when the session ends."
)).with_metadata(metadata));
}
if result.status != ShellStatus::Completed {
@@ -5381,7 +5412,7 @@ impl ToolSpec for LowercaseBashTool {
"type": "object",
"properties": {
"command": { "type": "string", "description": guidance::runtime_command_guidance() },
"timeout": { "type": "number", "description": "Optional seconds to wait in the foreground (default 120 seconds). A command still running then is not killed: it moves to the background and you get its output so far and a task_id. Find task_shell_wait with tool_search to read more or wait for it." },
"timeout": { "type": "number", "description": "Optional seconds to wait in the foreground (default 30 seconds). A command still running then is not killed: it moves to the background by itself and you get its output so far, how long ago it last printed, its build phase, and a task_id. Find task_shell_wait with tool_search to read more or wait for it." },
"read_only": { "type": "boolean", "description": "Set true to run analysis code (including Python/SQLite) with mandatory native filesystem read-only isolation and no network. Available during peer writes. Refused when native enforcement is unavailable; no background, stdin, external backend, or sandbox escalation." },
"sandbox_permissions": {
"type": "string",
@@ -5585,7 +5616,7 @@ impl ToolSpec for BashTool {
"read_only": { "type": "boolean", "description": "Set true to require native filesystem read-only and no-network execution. Only foreground run with command, cwd and timeout_ms; unavailable enforcement fails closed." },
"timeout_ms": {
"type": "integer",
"description": "How long to wait, in milliseconds. action=run: how long the turn waits in the foreground (default 120000, max 600000); a command still running then is NOT killed — it moves to the background and you get its output so far and task_id. action=wait 30000, action=interact 1000. For action=wait, `timeout_secs` (seconds) and `timeout` (milliseconds) are accepted aliases."
"description": "How long to wait, in milliseconds. action=run: how long the turn waits in the foreground (default 30000, max 600000); a command still running then is NOT killed — it moves to the background by itself and you get its output so far, how long ago it last printed, its build phase, and task_id. action=wait 30000, action=interact 1000. For action=wait, `timeout_secs` (seconds) and `timeout` (milliseconds) are accepted aliases."
},
"background": {
"type": "boolean",
@@ -5842,7 +5873,14 @@ impl ToolSpec for BashTool {
})
.transpose()?
} else {
Some(optional_u64(&input, "timeout_ms", 120_000)?.min(600_000))
let detached = optional_bool(&input, "background", false)?
|| optional_bool(&input, "interactive", false)?;
let default_ms = if detached {
120_000
} else {
CONTRACT_BASH_FOREGROUND_DEFAULT_TIMEOUT_MS
};
Some(optional_u64(&input, "timeout_ms", default_ms)?.min(600_000))
};
let background = optional_bool(&input, "background", false)?;
let interactive = optional_bool(&input, "interactive", false)?;
@@ -6442,8 +6480,14 @@ impl ToolSpec for BashTool {
tail_lines(err, 20)
),
};
let notice = promotion_notice(
context,
&task_id_str,
seconds,
&format!("{}{}", result.stdout, result.stderr),
);
format!(
"Still running after {seconds}s; moved to the background as {task_id_str} (not killed).\n\nOutput so far:\n{so_far}\n\n{completion_contract} Keep working if you can. To decide: call `tool_search` for `task_shell_wait`, then `task_shell_wait` with task_id=\"{task_id_str}\" for more output or completion, or with cancel=true to stop it. It also stops when the session ends."
"{notice}\n\nOutput so far:\n{so_far}\n\n{completion_contract} Keep working if you can. To decide: call `tool_search` for `task_shell_wait`, then `task_shell_wait` with task_id=\"{task_id_str}\" for more output or completion, or with cancel=true to stop it. It also stops when the session ends."
)
} else {
format!(
@@ -6452,8 +6496,10 @@ impl ToolSpec for BashTool {
}
} else if result.status == ShellStatus::Killed && was_cancelled {
format!(
"Command canceled; process killed.\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
result.stdout, result.stderr
"Command canceled; process killed. {}\n\nSTDOUT:\n{}\n\nSTDERR:\n{}",
kill_outcome_note(&format!("{}{}", result.stdout, result.stderr)),
result.stdout,
result.stderr
)
} else if result.status == ShellStatus::TimedOut {
format!(
@@ -6988,7 +7034,10 @@ impl BashTool {
.clone()
.unwrap_or_else(|| task_id.to_string());
Ok(ToolResult {
content: format!("Canceled background command: {task_id}"),
content: format!(
"Canceled background command: {task_id}. {}",
kill_outcome_note(&format!("{}{}", result.stdout, result.stderr))
),
success: true,
metadata: Some(json!({
"status": format!("{:?}", result.status),
+1 -1
View File
@@ -108,7 +108,7 @@ pub(super) fn description() -> &'static str {
// Interpreter syntax lives on the command parameter. Repeating it in the
// tool description adds the same bytes to every active request.
pub(super) fn foreground_description() -> &'static str {
"Execute a shell command in the workspace and return stdout and stderr. Output keeps the last 2000 lines or 50KB. An optional timeout is the seconds to wait in the foreground (default 120 seconds); a command still running then is never killed for time: it moves to the background and you get its output so far and a task_id, so long builds and tests need no special timeout. Find task_shell_wait with tool_search to read more or wait for it. In Ask, after a sandbox denial, retry the exact command once with sandbox_permissions (the narrowest wider mode that suffices) and a one-sentence justification; the approval prompt asks the user."
"Execute a shell command in the workspace and return stdout and stderr. Output keeps the last 2000 lines or 50KB. An optional timeout is the seconds to wait in the foreground (default 30 seconds); a command still running then is never killed for time: it moves to the background by itself and you get its output so far, how long ago it last printed, its build phase, and a task_id, so long builds and tests need no special timeout. Find task_shell_wait with tool_search to read more or wait for it. In Ask, after a sandbox denial, retry the exact command once with sandbox_permissions (the narrowest wider mode that suffices) and a one-sentence justification; the approval prompt asks the user."
}
#[cfg(test)]
+3 -3
View File
@@ -126,7 +126,7 @@ fn lowercase_bash_description_matches_the_timeout_it_actually_applies() {
};
// `bash {command}` with no `timeout` translates to a legacy input carrying
// no `timeout_ms`. The contract delegate waits the 120 s default in the
// no `timeout_ms`. The contract delegate waits the 30 s default in the
// foreground, then moves a still-running process to the background.
let translated =
contract_bash_legacy_input(&json!({"command": "sleep 600"})).expect("translated input");
@@ -1807,7 +1807,7 @@ async fn exec_shell_multiline_block_explains_allow_shell_boundary() {
result.content
);
assert!(
result.content.contains("task_shell_start"),
result.content.contains("background=true"),
"{}",
result.content
);
@@ -5113,7 +5113,7 @@ fn timeout_ms_description_covers_every_action_default() {
.as_str()
.expect("timeout_ms description");
for expected in ["120000", "600000", "30000", "1000"] {
for expected in ["600000", "30000", "1000"] {
assert!(
description.contains(expected),
"missing {expected}: {description}"
+71
View File
@@ -24,6 +24,77 @@ const MAX_PRESERVED_SUMMARY_LINES: usize = 80;
const MAX_PRESERVED_SUMMARY_BYTES: usize = 4 * 1024;
const MAX_PRESERVED_SUMMARY_LINE_CHARS: usize = 400;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputPhase {
Compiling,
BuildFinished,
Running,
Unknown,
}
fn cargo_status_word(line: &str) -> Option<&str> {
let rest = line.strip_prefix(' ')?;
let indent = line.len() - line.trim_start().len();
if indent < 2 {
return None;
}
let trimmed = rest.trim_start();
let word_end = trimmed.find(' ')?;
let word = &trimmed[..word_end];
let tail = trimmed[word_end..].trim_start();
if tail.is_empty() {
return None;
}
Some(word)
}
fn output_phase(text: &str) -> OutputPhase {
for line in text.lines().rev().take(200) {
let trimmed = line.trim_start();
if trimmed.starts_with("test result:")
|| trimmed.starts_with("Doc-tests ")
|| (trimmed.starts_with("running ")
&& (trimmed.ends_with(" tests") || trimmed.ends_with(" test")))
{
return OutputPhase::Running;
}
match cargo_status_word(line) {
Some("Running") if trimmed.contains("target/") || trimmed.contains("(target") => {
return OutputPhase::Running;
}
Some("Finished") => return OutputPhase::BuildFinished,
Some("Compiling" | "Checking" | "Downloading" | "Downloaded" | "Updating") => {
return OutputPhase::Compiling;
}
_ => {}
}
}
OutputPhase::Unknown
}
pub(crate) fn output_phase_hint(text: &str) -> &'static str {
match output_phase(text) {
OutputPhase::Compiling => "output phase: still compiling, no test or program output yet",
OutputPhase::BuildFinished => "output phase: build finished, program or tests starting",
OutputPhase::Running => "output phase: past compile, tests or program running",
OutputPhase::Unknown => "output phase: not recognized from the output",
}
}
pub(crate) fn kill_outcome_note(text: &str) -> &'static str {
match output_phase(text) {
OutputPhase::Compiling => {
"Killed during the compile phase: artifacts already written to disk are not removed, only the unfinished compile step is lost."
}
OutputPhase::BuildFinished | OutputPhase::Running => {
"Killed after the compile phase: compiled artifacts are not removed, but the run time already spent on this command is discarded."
}
OutputPhase::Unknown => {
"Build phase not recognized from the output: artifacts already written to disk are not removed, but progress inside the command is lost."
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct TruncationMeta {
pub(crate) original_len: usize,
@@ -1,345 +0,0 @@
//! `agent action=start runtime:"cloud"`: the model may propose a long job for
//! a cloud sandbox; only a person can start it.
//!
//! This is a thin front door onto `/dispatch`. It plans through
//! [`crate::cloud_dispatch::plan_dispatch`] and queues the job through
//! [`crate::cloud_dispatch::execute_dispatch`] with `confirm = false`, which
//! saves a `proposed` record and nothing else: no sandbox, no push, no spend.
//! The person confirms with `/dispatch confirm <id>`, and `confirm_job` runs
//! the same fail-closed credential and machine-token gates it always has.
//! Nothing here can confirm, so there is no auto-confirm to guard.
use std::path::Path;
use serde_json::{Value, json};
use crate::cloud_dispatch::{
CloudJobStore, CredentialState, DispatchOutcome, Forge, GitRemote, MachineTokenState,
discover_credentials, discover_machine_token, discover_remotes, execute_dispatch, format_job,
missing_credentials_message, missing_machine_token_message, plan_dispatch,
};
use crate::tools::spec::{ToolError, ToolResult};
/// Where an `agent action=start` runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum StartRuntime {
Local,
Cloud,
}
/// Fields a cloud proposal reads. Everything else on `start` shapes a local
/// child (scopes, roles, worktrees, limits); those are not refused, because a
/// retry costs more than they are worth, but the result names them as unused
/// so neither the model nor the person assumes they applied.
const CLOUD_START_FIELDS: &[&str] = &["action", "op", "runtime", "prompt", "remote"];
pub(super) fn parse_start_runtime(input: &Value) -> Result<StartRuntime, ToolError> {
match input.get("runtime") {
None | Some(Value::Null) => Ok(StartRuntime::Local),
Some(Value::String(value)) => match value.trim().to_ascii_lowercase().as_str() {
"" | "local" => Ok(StartRuntime::Local),
"cloud" => Ok(StartRuntime::Cloud),
other => Err(ToolError::invalid_input(format!(
"runtime must be local or cloud, not `{other}`."
))),
},
Some(_) => Err(ToolError::invalid_input(
"runtime must be a string: local or cloud.",
)),
}
}
pub(super) fn is_cloud_start(input: &Value) -> bool {
matches!(parse_start_runtime(input), Ok(StartRuntime::Cloud))
}
/// Queue a cloud proposal for the person to confirm. Never spawns.
pub(super) fn propose_cloud_run(
input: &Value,
workspace: &Path,
spawn_depth: u32,
) -> Result<ToolResult, ToolError> {
if spawn_depth > 0 {
return Err(ToolError::permission_denied(
"Only the main session can propose a cloud run. Say in your report that this work needs one.",
));
}
let store = CloudJobStore::from_env()
.map_err(|error| ToolError::execution_failed(error.to_string()))?;
let payload = propose_with(
input,
&discover_remotes(workspace),
&store,
&discover_credentials(),
&discover_machine_token(),
)?;
let mut result =
ToolResult::json(&payload).map_err(|e| ToolError::execution_failed(e.to_string()))?;
result.metadata = Some(json!({
"action": "start",
"runtime": "cloud",
"status": "proposed",
"job_id": payload["job_id"],
}));
Ok(result)
}
fn propose_with(
input: &Value,
remotes: &[GitRemote],
store: &CloudJobStore,
credentials: &CredentialState,
machine_token: &MachineTokenState,
) -> Result<Value, ToolError> {
let mut unused: Vec<&str> = input
.as_object()
.map(|object| {
object
.keys()
.map(String::as_str)
.filter(|key| !CLOUD_START_FIELDS.contains(key))
.collect()
})
.unwrap_or_default();
unused.sort_unstable();
let prompt = match input.get("prompt") {
Some(Value::String(prompt)) => prompt.as_str(),
_ => {
return Err(ToolError::invalid_input(
"runtime=\"cloud\" needs prompt: the task the cloud agent should do.",
));
}
};
let remote = match input.get("remote") {
None | Some(Value::Null) => None,
Some(Value::String(value)) => Some(
Forge::parse(value)
.ok_or_else(|| ToolError::invalid_input("remote must be github, cnb, or gitee."))?,
),
Some(_) => {
return Err(ToolError::invalid_input(
"remote must be a string: github, cnb, or gitee.",
));
}
};
let plan = plan_dispatch(remotes, prompt, remote, None)
.map_err(|error| ToolError::invalid_input(error.to_string()))?;
// `confirm = false` saves a proposal and returns before any credential,
// sandbox or forge call. Confirmation belongs to the person.
let job = match execute_dispatch(store, plan, false, credentials, machine_token)
.map_err(|error| ToolError::execution_failed(error.to_string()))?
{
DispatchOutcome::Proposal(job) => job,
DispatchOutcome::Refused(job) | DispatchOutcome::Accepted(job) => {
return Err(ToolError::execution_failed(format!(
"cloud job {} did not come back as a proposal; nothing was started from here.",
job.id
)));
}
};
// Say now what confirm will refuse, so the person is not asked to
// approve a job that cannot run.
let blocked_by = match (credentials, machine_token) {
(CredentialState::Missing, _) => Some(missing_credentials_message()),
(_, MachineTokenState::Missing) => Some(missing_machine_token_message()),
_ => None,
};
let mut summary = match &blocked_by {
None => format!(
"Proposed cloud job {id}. Nothing runs or spends until the person types \
`/dispatch confirm {id}`. Do not confirm it yourself; tell the person it is waiting.",
id = job.id
),
Some(reason) => format!(
"Proposed cloud job {id}, but confirming it will be refused until this is fixed: {reason}",
id = job.id
),
};
if !unused.is_empty() {
summary.push_str(&format!(
" Not used by a cloud job, which gets only the prompt: {}.",
unused.join(", ")
));
}
Ok(json!({
"action": "start",
"runtime": "cloud",
"status": "proposed",
"started": false,
"job_id": job.id,
"confirm_with": format!("/dispatch confirm {}", job.id),
"cancel_with": format!("/dispatch cancel {}", job.id),
"ready_to_confirm": blocked_by.is_none(),
"blocked_by": blocked_by,
"unused_fields": unused,
"summary": summary,
"card": format_job(&job),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cloud_dispatch::{CloudJobStatus, CredentialSource};
fn github_remote() -> Vec<GitRemote> {
vec![GitRemote {
name: "github".to_string(),
url: "https://github.com/example/repo.git".to_string(),
}]
}
const READY: CredentialState = CredentialState::Present {
source: CredentialSource::Env,
};
#[test]
fn runtime_defaults_to_local_and_rejects_unknown_values() {
assert_eq!(
parse_start_runtime(&json!({"prompt": "x"})).unwrap(),
StartRuntime::Local
);
assert_eq!(
parse_start_runtime(&json!({"runtime": "Cloud"})).unwrap(),
StartRuntime::Cloud
);
assert!(parse_start_runtime(&json!({"runtime": "moon"})).is_err());
assert!(parse_start_runtime(&json!({"runtime": true})).is_err());
assert!(is_cloud_start(
&json!({"action": "start", "runtime": "cloud"})
));
assert!(!is_cloud_start(&json!({"action": "start"})));
}
#[test]
fn cloud_start_only_queues_a_proposal_for_the_person() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = CloudJobStore::from_path(tmp.path().join("cloud-jobs"));
let payload = propose_with(
&json!({"action": "start", "runtime": "cloud", "prompt": "run the full soak suite and fix flakes"}),
&github_remote(),
&store,
&READY,
&MachineTokenState::Present,
)
.expect("proposal");
assert_eq!(payload["status"], "proposed");
assert_eq!(payload["started"], false);
assert_eq!(payload["ready_to_confirm"], true);
let id = payload["job_id"].as_str().expect("job id");
assert_eq!(payload["confirm_with"], format!("/dispatch confirm {id}"));
assert!(
payload["summary"]
.as_str()
.unwrap()
.contains("Do not confirm it yourself")
);
// The queued record is the same card `/dispatch show` renders, still
// unconfirmed, with no sandbox.
let jobs = store.list().expect("jobs");
assert_eq!(jobs.len(), 1);
let job = &jobs[0];
assert_eq!(job.id, id);
assert_eq!(job.status, CloudJobStatus::Proposed);
assert!(!job.confirmed);
assert!(job.sandbox_id.is_none());
assert_eq!(payload["card"], format_job(job));
}
#[test]
fn a_missing_machine_token_is_named_before_the_person_confirms() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = CloudJobStore::from_path(tmp.path().join("cloud-jobs"));
let payload = propose_with(
&json!({"runtime": "cloud", "prompt": "long job"}),
&github_remote(),
&store,
&READY,
&MachineTokenState::Missing,
)
.expect("proposal is still queued");
assert_eq!(payload["ready_to_confirm"], false);
assert_eq!(payload["blocked_by"], missing_machine_token_message());
assert_eq!(
store.list().expect("jobs")[0].status,
CloudJobStatus::Proposed,
"the gate runs at confirm, not here"
);
}
#[test]
fn local_only_fields_are_named_as_unused_not_refused() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = CloudJobStore::from_path(tmp.path().join("cloud-jobs"));
let payload = propose_with(
&json!({"runtime": "cloud", "prompt": "x", "worktree": true, "type": "implement"}),
&github_remote(),
&store,
&READY,
&MachineTokenState::Present,
)
.expect("a proposal, not a retry");
assert_eq!(payload["unused_fields"], json!(["type", "worktree"]));
assert!(
payload["summary"]
.as_str()
.unwrap()
.contains("gets only the prompt: type, worktree."),
"{payload}"
);
assert_eq!(store.list().expect("jobs").len(), 1);
}
#[test]
fn bad_remotes_and_missing_prompts_are_refused_without_a_record() {
let tmp = tempfile::tempdir().expect("tempdir");
let store = CloudJobStore::from_path(tmp.path().join("cloud-jobs"));
let error = propose_with(
&json!({"runtime": "cloud", "prompt": "x", "remote": "bitbucket"}),
&github_remote(),
&store,
&READY,
&MachineTokenState::Present,
)
.expect_err("unknown forge refused");
assert!(
error.to_string().contains("github, cnb, or gitee"),
"{error}"
);
assert!(
propose_with(
&json!({"runtime": "cloud"}),
&github_remote(),
&store,
&READY,
&MachineTokenState::Present,
)
.is_err(),
"prompt is required"
);
assert!(
propose_with(
&json!({"runtime": "cloud", "prompt": "x"}),
&[],
&store,
&READY,
&MachineTokenState::Present,
)
.is_err(),
"no forge remote, no proposal"
);
assert!(store.list().expect("jobs").is_empty());
}
#[test]
fn a_child_agent_cannot_propose() {
let error = propose_cloud_run(
&json!({"runtime": "cloud", "prompt": "x"}),
Path::new("."),
1,
)
.expect_err("children refused");
assert!(error.to_string().contains("main session"), "{error}");
}
}
+13 -26
View File
@@ -96,7 +96,6 @@ use coord::{
pub mod advisor;
pub(crate) mod budget_handback;
mod cloud_proposal;
pub mod coord;
mod delivery;
pub(crate) mod engine;
@@ -11260,16 +11259,6 @@ impl ToolSpec for AgentTool {
"type": "string",
"description": "The focused task to give the worker. A read-only role needs no write scope; a write-capable role defaults to the parent workspace unless narrowed with write_roots."
},
"runtime": {
"type": "string",
"enum": ["local", "cloud"],
"description": "cloud only proposes a cloud PR job; nothing runs until the person types /dispatch confirm <id>. Never confirm it yourself."
},
"remote": {
"type": "string",
"enum": ["github", "cnb", "gitee"],
"description": "cloud: PR forge."
},
"detached": {
"type": "boolean",
"description": "Default children continue after ordinary parent turn completion and remain explicitly cancellable. true additionally opts this subtree out of parent-turn cancellation; its own budgets still apply."
@@ -11454,11 +11443,6 @@ impl ToolSpec for AgentTool {
| AgentToolAction::Peek
| AgentToolAction::Wait,
) => ApprovalRequirement::Auto,
// A cloud proposal spawns and spends nothing; the person's
// `/dispatch confirm` is its gate.
Ok(AgentToolAction::Start) if cloud_proposal::is_cloud_start(input) => {
ApprovalRequirement::Auto
}
Ok(AgentToolAction::Start) if start_requests_read_only_role(input) => {
ApprovalRequirement::Auto
}
@@ -11512,14 +11496,18 @@ impl ToolSpec for AgentTool {
let action = parse_agent_tool_action(&input)?;
match action {
AgentToolAction::Start => {
if cloud_proposal::parse_start_runtime(&input)?
== cloud_proposal::StartRuntime::Cloud
{
return cloud_proposal::propose_cloud_run(
&input,
&context.workspace,
self.runtime.spawn_depth,
);
let remote_set = !matches!(input.get("remote"), None | Some(Value::Null));
let runtime_is_local = match input.get("runtime") {
None | Some(Value::Null) => true,
Some(Value::String(value)) => {
matches!(value.trim().to_ascii_lowercase().as_str(), "" | "local")
}
Some(_) => false,
};
if remote_set || !runtime_is_local {
return Err(ToolError::invalid_input(
"Cloud agent runs are no longer started from the agent tool; omit runtime and remote to run a local child.",
));
}
}
AgentToolAction::Roster => {
@@ -18413,8 +18401,7 @@ fn reject_subagent_terminal_takeover(name: &str, input: &Value) -> Result<()> {
return Err(anyhow!(
"Sub-agents run in the background and cannot use Bash with interactive=true \
because that would take over the parent TUI terminal. Use Bash without \
interactive, or with background=true / tty=true, or task_shell_start \
instead."
interactive, or with background=true / tty=true instead."
));
}
Ok(())
+1 -4
View File
@@ -6017,9 +6017,7 @@ fn subagent_tool_schemas_advertise_real_type_and_role_vocabulary() {
"offset",
"profile",
"prompt",
"remote",
"resume_from",
"runtime",
"thinking",
"type",
"until",
@@ -7534,7 +7532,7 @@ async fn issue_5633_catalog_and_dispatch_are_one_grant() {
// can never call what it cannot see. `Bash`/`exec_shell` are hidden
// execution-compatibility aliases, so they never appear in any
// catalog — under a non-Full grant they are still refused by name.
for name in ["terminal/run", "terminal/send", "task_shell_start"] {
for name in ["terminal/run", "terminal/send"] {
assert_eq!(
catalog.contains(name),
full_shell,
@@ -8042,7 +8040,6 @@ fn every_named_role_has_one_complete_capability_based_surface() {
"session_get",
"session_search",
"speech",
"task_shell_start",
"task_shell_wait",
"tasks",
"terminal/cancel",
+7 -105
View File
@@ -75,8 +75,8 @@ pub(crate) fn task_shell_wait_requests_cancel(input: &Value) -> bool {
/// the per-action logic below. The per-action `task_*` / `pr_attempt_*`
/// execution aliases were removed in v0.9.3.
///
/// `TaskShellStartTool` / `TaskShellWaitTool` stay separate: the registry
/// gates them behind `allow_shell` (see `with_runtime_task_shell_tools`),
/// `TaskShellWaitTool` stays separate: the registry gates it behind
/// `allow_shell` (see `with_runtime_task_shell_tools`),
/// which differs from every other action in this family.
pub struct TasksTool {
name: &'static str,
@@ -84,7 +84,6 @@ pub struct TasksTool {
read_only: bool,
}
pub struct TaskShellStartTool;
pub struct TaskShellWaitTool;
/// Actions the Plan-mode read-only surface exposes.
@@ -212,7 +211,7 @@ impl ToolSpec for TasksTool {
"Inspect durable tasks and their PR attempts. Actions: \"list\", \"read\", \"pr_attempt_list\", \"pr_attempt_read\"."
}
_ => {
"Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval). Use task_shell_start for long-running shell work."
"Manage durable background tasks through TaskManager. Durable tasks are restart-aware executable work, distinct from sub-agents. Actions: \"create\" (enqueue; approval), \"list\", \"read\", \"cancel\" (approval), \"gate_run\" (run an approved verification gate command and return structured evidence; approval), \"pr_attempt_record\" (approval), \"pr_attempt_list\", \"pr_attempt_read\", \"pr_attempt_preflight\" (approval). Run long shell work with the shell tool (background=true to detach immediately; a foreground command still running after 30s moves to the background by itself)."
}
}
}
@@ -930,73 +929,6 @@ impl TasksTool {
}
}
#[async_trait]
impl ToolSpec for TaskShellStartTool {
fn name(&self) -> &'static str {
"task_shell_start"
}
fn description(&self) -> &'static str {
"Start a long-running shell command in the background and return a shell task_id immediately. Completion is delivered automatically as an internal runtime event and remains visible in the task/status surface; use task_shell_wait only for early output, explicit barriers, or gate evidence on the active durable task."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"command": { "type": "string" },
"cwd": { "type": "string", "description": "Optional working directory within the workspace." },
"timeout_ms": { "type": "integer", "minimum": 1000, "maximum": 600000, "description": "Accepted for compatibility but not enforced: the command runs in the background until it finishes, the session ends, or task_shell_wait stops it with cancel=true." },
"stdin": { "type": "string" },
"tty": { "type": "boolean" }
},
"required": ["command"],
"additionalProperties": false
})
}
fn capabilities(&self) -> Vec<ToolCapability> {
vec![
ToolCapability::ExecutesCode,
ToolCapability::RequiresApproval,
]
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Required
}
fn starts_detached_for(&self, input: &Value) -> bool {
input.get("command").and_then(Value::as_str).is_some()
}
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
crate::core::engine::tool_catalog::enforce_tool_denial(context, self.name(), &input)?;
let mut shell_input = json!({
"command": required_str(&input, "command")?,
"background": true,
"timeout_ms": optional_u64(&input, "timeout_ms", DEFAULT_GATE_TIMEOUT_MS)?
.clamp(1_000, MAX_GATE_TIMEOUT_MS),
});
if let Some(cwd) = optional_str(&input, "cwd")? {
let cwd = resolve_cwd(context, Some(cwd))?;
shell_input["cwd"] = json!(cwd);
}
if let Some(stdin) = optional_str(&input, "stdin")? {
shell_input["stdin"] = json!(stdin);
}
if optional_bool(&input, "tty", false)? {
shell_input["tty"] = json!(true);
}
let mut result = BashTool::new("Bash").execute(shell_input, context).await?;
if let Some(metadata) = result.metadata.as_mut() {
metadata["background"] = json!(true);
metadata["task_shell"] = json!(true);
}
Ok(result)
}
}
#[async_trait]
impl ToolSpec for TaskShellWaitTool {
fn name(&self) -> &'static str {
@@ -1632,38 +1564,11 @@ mod tests {
#[test]
fn background_shell_schema_is_explicit() {
let schema = TaskShellStartTool.input_schema();
assert_eq!(schema["required"][0], "command");
assert_eq!(schema["properties"]["timeout_ms"]["maximum"], 600000);
let wait_schema = TaskShellWaitTool.input_schema();
assert_eq!(wait_schema["required"][0], "task_id");
assert!(wait_schema["properties"]["gate"].is_object());
}
// `timeout_ms` rides along for interface compatibility only: the shell
// always starts in the background, where no deadline enforces it. The
// schema must say so instead of implying a bounded run.
#[test]
fn background_shell_timeout_disclosure_admits_it_is_not_enforced() {
let schema = TaskShellStartTool.input_schema();
let timeout_ms = schema["properties"]["timeout_ms"]["description"]
.as_str()
.expect("timeout_ms must carry a description");
assert!(
timeout_ms.contains("not enforced"),
"timeout_ms must disclose that the background run is unbounded: {timeout_ms}"
);
assert!(
timeout_ms.contains("task_shell_wait") && timeout_ms.contains("cancel=true"),
"timeout_ms must point at the callable cancel path that can stop the run: {timeout_ms}"
);
assert!(
!timeout_ms.contains("exec_shell"),
"timeout_ms must not name the hidden exec_shell tool, which no model can call: {timeout_ms}"
);
}
// The gate blocks dangerous commands unless auto-approve is on, and the
// description is where the model learns that before composing a command.
#[test]
@@ -1733,8 +1638,8 @@ mod tests {
async fn task_shell_wait_null_is_a_nonblocking_snapshot() {
let workspace = tempfile::tempdir().expect("workspace");
let context = ToolContext::new(workspace.path());
let started = TaskShellStartTool
.execute(json!({"command": "sleep 2", "timeout_ms": 5_000}), &context)
let started = BashTool::new("Bash")
.execute(json!({"command": "sleep 2", "background": true}), &context)
.await
.expect("start background shell");
let task_id = started
@@ -1780,11 +1685,8 @@ mod tests {
async fn task_shell_wait_cancel_stops_the_background_task() {
let workspace = tempfile::tempdir().expect("workspace");
let context = ToolContext::new(workspace.path());
let started = TaskShellStartTool
.execute(
json!({"command": "sleep 30", "timeout_ms": 5_000}),
&context,
)
let started = BashTool::new("Bash")
.execute(json!({"command": "sleep 30", "background": true}), &context)
.await
.expect("start background shell");
let task_id = started
+218 -41
View File
@@ -294,22 +294,13 @@ fn initial_onboarding_state(
needs_api_key: bool,
needs_workspace_trust: bool,
) -> OnboardingState {
if skip_onboarding || (was_onboarded && !needs_api_key && !needs_workspace_trust) {
if skip_onboarding || needs_api_key {
return OnboardingState::None;
}
if needs_api_key {
// Nothing can answer until a model is connected, so the first screen
// is the one that connects it (#6566). A returning user keeps the
// configured route focused; a new user sees the provider list. It is
// one screen, not the old five-gate wizard, and Esc leaves it for the
// composer.
OnboardingState::Provider
} else if was_onboarded && needs_workspace_trust {
if was_onboarded && needs_workspace_trust {
OnboardingState::TrustDirectory
} else {
// A new user who already has a key starts at the composer. Language
// and trust stay in /setup.
OnboardingState::None
}
}
@@ -347,9 +338,10 @@ fn launch_onboarding_decision(
needs_workspace_trust,
)
};
// Both a new user and a returning one reach the picker directly, and Esc
// returns to the composer. An explicitly configured route is focused even
// on first run (see `onboarding_recovers_configured_route`).
// A launch with no usable key opens the composer. The provider picker
// opens when the first message is sent (`keep_unsent_message_for_connect`),
// and Esc there returns to the composer. An explicitly configured route is
// focused even on first run (see `onboarding_recovers_configured_route`).
let missing_key_recovery = !skip_onboarding && needs_api_key && !xai_oauth_needs_reauth;
(onboarding, missing_key_recovery)
}
@@ -636,6 +628,7 @@ pub struct LaunchRecentSession {
pub title: String,
pub updated_at: DateTime<Utc>,
pub message_count: usize,
pub interrupted: bool,
}
/// Identity of one interactive row on the startup card. The card's rows are
@@ -731,8 +724,10 @@ pub(crate) const LAUNCH_CARD_DISSOLVE_MS: u128 = 240;
/// exist at all so the card never claims "no recent sessions" while
/// `/resume` has some.
fn load_launch_recent(workspace: &std::path::Path) -> (Vec<LaunchRecentSession>, usize, bool) {
let sessions = crate::session_manager::SessionManager::default_location()
.and_then(|manager| manager.list_sessions())
let manager = crate::session_manager::SessionManager::default_location().ok();
let sessions = manager
.as_ref()
.and_then(|manager| manager.list_sessions().ok())
.unwrap_or_default();
let any_scoped = sessions.iter().any(|session| {
!session.archived
@@ -746,6 +741,7 @@ fn load_launch_recent(workspace: &std::path::Path) -> (Vec<LaunchRecentSession>,
&& crate::session_manager::workspace_scope_matches(&session.workspace, workspace)
})
.map(|session| LaunchRecentSession {
interrupted: false,
id: session.id,
title: session.title,
updated_at: session.updated_at,
@@ -754,6 +750,13 @@ fn load_launch_recent(workspace: &std::path::Path) -> (Vec<LaunchRecentSession>,
.collect();
let total = scoped.len();
scoped.truncate(LAUNCH_RECENT_INLINE_LIMIT);
if let Some(manager) = manager.as_ref() {
for session in &mut scoped {
session.interrupted = manager.session_has_checkpoint(&session.id)
&& manager.session_from_prior_instance(&session.id)
&& !manager.is_session_live_anywhere(&session.id);
}
}
(scoped, total, any_scoped)
}
@@ -2133,6 +2136,7 @@ pub struct App {
pub onboarding_had_language_step: bool,
pub onboarding_had_provider_step: bool,
pub onboarding_had_trust_step: bool,
pub local_runtime_detected: bool,
/// True when the active credential was discovered only through an
/// environment variable. Missing-key recovery and route rollback use this
/// provenance to decide whether a durable provider slot still exists;
@@ -2146,6 +2150,7 @@ pub struct App {
pub yolo: bool,
/// One-shot YOLO→Act+Bypass migration notice for this session (#0.8.68 M6).
yolo_compat_notified: bool,
pub(crate) full_access_scope: crate::tui::full_access_confirm::FullAccessScope,
/// The single serialized owner of `settings.toml` startup-default writes
/// (mode, thinking, model). Keeping one owner per `App` is what stops two
/// rapid selections from interleaving their load/modify/save transactions
@@ -2482,6 +2487,9 @@ pub struct App {
pub(crate) mcp_login: Option<PendingMcpLogin>,
/// `/mcp retry` requests still waiting on the engine, one per server.
pub(crate) mcp_retries: Vec<PendingMcpRetry>,
/// Server whose OAuth login just stored credentials; the event loop
/// retries that one server instead of asking for a full reload.
pub(crate) mcp_login_reconnect: Option<String>,
/// Shared cell for async prompt suggestion delivery from background task.
pub prompt_suggestion_cell: std::sync::Arc<std::sync::Mutex<Option<(u64, String)>>>,
/// Tracks whether the initial balance fetch has been attempted for this session.
@@ -3330,6 +3338,8 @@ impl App {
if previous_mode == mode && !self.yolo {
return false;
}
let permission_before = self.effective_permission_label();
let permission_pair_before = self.permission_pair();
self.mode = mode;
// Mode chip lives in the header — skip redundant status/toast copy.
@@ -3356,7 +3366,7 @@ impl App {
self.approval_mode = policy.approval_mode;
self.yolo = matches!(policy.approval_mode, ApprovalMode::Bypass);
self.finish_mode_change(previous_mode);
self.finish_mode_change(previous_mode, &permission_before, permission_pair_before);
true
}
@@ -3372,6 +3382,8 @@ impl App {
return false;
}
let previous_mode = self.mode;
let permission_before = self.effective_permission_label();
let permission_pair_before = self.permission_pair();
// Same baseline-refresh rule as a mode hop: the elevation must not
// bleed into the restored Agent surface.
if previous_mode.uses_agent_baseline() && !self.yolo {
@@ -3392,7 +3404,7 @@ impl App {
self.approval_mode = ApprovalMode::Bypass;
self.yolo = true;
self.notify_yolo_compat_once();
self.finish_mode_change(previous_mode);
self.finish_mode_change(previous_mode, &permission_before, permission_pair_before);
true
}
@@ -3413,6 +3425,11 @@ impl App {
self.needs_redraw = true;
return SettingSelection::Refused;
}
if !self.yolo
&& self.request_full_access(crate::tui::full_access_confirm::FullAccessOrigin::Yolo)
{
return SettingSelection::NeedsConfirmation;
}
let changed = self.set_mode_yolo_compat();
self.startup_defaults
.spawn(crate::tui::startup_defaults::StartupDefaults::mode(
@@ -3430,7 +3447,15 @@ impl App {
/// id, workspace, model, and token total as every other event — it used
/// to omit `DEEPSEEK_SESSION_ID` entirely, which made mode transitions
/// uncorrelatable with the session they belonged to.
fn finish_mode_change(&mut self, previous_mode: AppMode) {
fn finish_mode_change(
&mut self,
previous_mode: AppMode,
permission_before: &str,
permission_pair_before: (
ApprovalMode,
crate::tui::full_access_confirm::FullAccessScope,
),
) {
let context = self
.base_hook_context()
.with_mode(self.mode.label())
@@ -3438,9 +3463,26 @@ impl App {
if let Err(error) = self.submit_hooks(HookEvent::ModeChange, context) {
self.surface_observer_hook_submission_failure(error);
}
if self.permission_pair() != permission_pair_before {
self.record_permission_change(permission_before, false);
}
self.needs_redraw = true;
}
fn permission_pair(
&self,
) -> (
ApprovalMode,
crate::tui::full_access_confirm::FullAccessScope,
) {
let approval = if self.mode == AppMode::Plan {
self.mode_prefs.agent_approval_mode
} else {
self.approval_mode
};
(approval, self.full_access_scope)
}
/// Apply a *user-facing* mode selection: change the live session mode and
/// persist it as the startup default.
///
@@ -3775,6 +3817,10 @@ impl App {
self.needs_redraw = true;
return false;
}
if next == ApprovalMode::Bypass {
self.request_full_access(crate::tui::full_access_confirm::FullAccessOrigin::Cycle);
return false;
}
if let Err(err) = Self::persist_permission_posture(next) {
self.push_status_toast(
format!("Permissions were not changed: could not save TUI posture ({err})"),
@@ -3806,6 +3852,11 @@ impl App {
return false;
}
if next == ApprovalMode::Bypass {
self.request_full_access(crate::tui::full_access_confirm::FullAccessOrigin::RootCycle);
return false;
}
if let Err(reason) = self.adopt_root_approval_posture(next) {
self.push_status_toast(
format!("Permissions were not changed: {reason}"),
@@ -3853,7 +3904,9 @@ impl App {
}
};
let previous = settings.permission_posture.clone();
settings.permission_posture = Some(posture);
if next != ApprovalMode::Bypass {
settings.permission_posture = Some(posture);
}
if let Err(err) = transaction.save(&settings) {
return Ok(RootPostureOutcome::Failed(format!(
"could not save TUI posture ({err})"
@@ -3926,6 +3979,9 @@ impl App {
/// queued mode/thinking write — the two used to load the same bytes and the
/// later save reverted the other's field.
fn persist_permission_posture(next: ApprovalMode) -> anyhow::Result<()> {
if next == ApprovalMode::Bypass {
return Ok(());
}
Settings::transact(|settings| {
settings.permission_posture = Some(Self::approval_posture_setting(next).to_string());
Ok(())
@@ -3935,21 +3991,6 @@ impl App {
fn finish_approval_posture_change(&mut self, next: ApprovalMode) {
self.set_agent_approval_posture(next);
self.needs_redraw = true;
// In Plan the new posture is real but dormant, and the footer chip
// alone would imply it is live. Say when it starts applying instead of
// refusing the change.
if self.mode == AppMode::Plan {
self.push_status_toast(
format!(
"Permissions set to {}. Plan stays Read Only; this applies in Act and Operate.",
next.permission_chip_label()
),
StatusToastLevel::Info,
Some(5_000),
);
}
// Footer permission chip is canonical — no status toast for the new
// value, only the one-shot rebinding notice.
self.notify_keybinding_migration_once();
}
@@ -4026,6 +4067,8 @@ impl App {
/// an independently enabled trust baseline in other posture transitions.
/// Plan remains read-only.
pub fn set_agent_approval_posture(&mut self, next: ApprovalMode) {
let permission_before = self.effective_permission_label();
let forgot_repo = self.leave_full_access_scope(next);
let trust_mode = if next == ApprovalMode::Bypass {
true
} else if self.mode_prefs.agent_approval_mode == ApprovalMode::Bypass {
@@ -4034,6 +4077,146 @@ impl App {
self.mode_prefs.agent_trust_mode
};
self.set_agent_runtime_baseline(self.mode_prefs.agent_allow_shell, trust_mode, next);
self.record_permission_change(&permission_before, forgot_repo);
}
#[must_use]
pub(crate) fn effective_permission_label(&self) -> String {
crate::tui::underwater::permission_label(self).into_owned()
}
fn record_permission_change(&mut self, before: &str, forgot_repo: bool) {
let after = self.effective_permission_label();
if after == before {
return;
}
let mut content = self
.tr(MessageId::PermissionsChangedReceipt)
.replace("{label}", &after);
if forgot_repo {
content.push_str(" · ");
content.push_str(&self.tr(MessageId::ReceiptFullAccessForgotten));
}
self.add_message(HistoryCell::System { content });
self.needs_redraw = true;
}
fn leave_full_access_scope(&mut self, next: ApprovalMode) -> bool {
use crate::tui::full_access_confirm::FullAccessScope;
let in_force = self.approval_mode == ApprovalMode::Bypass
|| self.mode_prefs.agent_approval_mode == ApprovalMode::Bypass;
if next == ApprovalMode::Bypass || !in_force {
return false;
}
let was_repo = self.full_access_scope == FullAccessScope::Repo;
self.full_access_scope = FullAccessScope::Session;
was_repo && self.forget_full_access_repo()
}
fn forget_full_access_repo(&mut self) -> bool {
let workspace = self.workspace.clone();
match Settings::transact(|settings| settings.set_full_access_repo(&workspace, false)) {
Ok(changed) => changed,
Err(err) => {
self.push_status_toast(
format!("Full Access is still kept for this repo: could not save ({err})"),
StatusToastLevel::Warning,
Some(8_000),
);
false
}
}
}
pub(crate) fn request_full_access(
&mut self,
origin: crate::tui::full_access_confirm::FullAccessOrigin,
) -> bool {
if self.approval_mode == ApprovalMode::Bypass
|| self.mode_prefs.agent_approval_mode == ApprovalMode::Bypass
{
return false;
}
if self.view_stack.top_kind() != Some(crate::tui::views::ModalKind::FullAccessConfirm) {
self.view_stack
.push(crate::tui::full_access_confirm::FullAccessConfirmView::new(
origin,
self.ui_locale,
));
}
self.needs_redraw = true;
true
}
pub(crate) fn apply_confirmed_full_access(
&mut self,
scope: crate::tui::full_access_confirm::FullAccessScope,
origin: crate::tui::full_access_confirm::FullAccessOrigin,
) -> bool {
use crate::tui::full_access_confirm::{FullAccessOrigin, FullAccessScope};
let workspace = self.workspace.clone();
let migration = origin == FullAccessOrigin::Migration;
if (scope == FullAccessScope::Repo || migration)
&& let Err(err) = Settings::transact(|settings| {
if migration {
settings.permission_posture = Some("ask".to_string());
settings.full_access_migration_shown = true;
}
if scope == FullAccessScope::Repo {
settings.set_full_access_repo(&workspace, true)?;
}
Ok(())
})
{
let message = if migration {
format!("Full Access stays saved for every project: could not save ({err})")
} else {
format!("Full Access was not kept for this repo: could not save ({err})")
};
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
return false;
}
let permission_before = self.effective_permission_label();
self.full_access_scope = scope;
let moved = match origin {
FullAccessOrigin::Migration => {
self.record_permission_change(&permission_before, false);
false
}
FullAccessOrigin::Cycle | FullAccessOrigin::Config | FullAccessOrigin::Setup => {
self.finish_approval_posture_change(ApprovalMode::Bypass);
true
}
FullAccessOrigin::RootCycle | FullAccessOrigin::RootConfig => {
match self.adopt_root_approval_posture(ApprovalMode::Bypass) {
Ok(()) => true,
Err(reason) => {
self.full_access_scope = FullAccessScope::Session;
if scope == FullAccessScope::Repo {
self.forget_full_access_repo();
}
self.push_status_toast(
format!("Permissions were not changed: {reason}"),
StatusToastLevel::Warning,
Some(8_000),
);
false
}
}
}
FullAccessOrigin::Yolo => {
let changed = self.set_mode_yolo_compat();
self.startup_defaults
.spawn(crate::tui::startup_defaults::StartupDefaults::mode(
self.mode,
));
changed
}
};
self.needs_redraw = true;
moved
}
#[must_use]
@@ -6222,12 +6405,6 @@ impl App {
StatusToastLevel::Info,
Some(Self::QUIT_CONFIRMATION_WINDOW.as_millis() as u64),
);
if let Some(warning) = crate::cloud_dispatch::CloudJobStore::from_env()
.ok()
.and_then(|store| crate::cloud_dispatch::live_job_quit_warning(&store))
{
self.push_status_toast(warning, StatusToastLevel::Warning, Some(8_000));
}
}
/// Whether the quit timer is currently armed (i.e. a prior Ctrl+C set it
+29 -4
View File
@@ -584,13 +584,28 @@ impl App {
needs_api_key,
needs_workspace_trust,
);
let saved_posture = settings
.permission_posture
.as_deref()
.and_then(ApprovalMode::from_config_value);
let repo_full_access =
!approval_policy_locked && settings.full_access_repo_kept(&workspace);
let full_access_migration = !approval_policy_locked
&& explicit_approval_mode.is_none()
&& saved_posture == Some(ApprovalMode::Bypass)
&& !repo_full_access
&& !settings.full_access_migration_shown;
let saved_permission_posture = if approval_policy_locked {
None
} else if repo_full_access || full_access_migration {
Some(ApprovalMode::Bypass)
} else {
settings
.permission_posture
.as_deref()
.and_then(ApprovalMode::from_config_value)
saved_posture.filter(|mode| *mode != ApprovalMode::Bypass)
};
let full_access_scope = if repo_full_access {
crate::tui::full_access_confirm::FullAccessScope::Repo
} else {
crate::tui::full_access_confirm::FullAccessScope::Session
};
let configured_approval_mode = explicit_approval_mode
.or(saved_permission_posture)
@@ -1006,11 +1021,13 @@ impl App {
&& onboarding == OnboardingState::Language,
onboarding_had_provider_step: !was_onboarded && needs_api_key,
onboarding_had_trust_step: !was_onboarded && needs_workspace_trust,
local_runtime_detected: false,
api_key_env_only,
hooks,
lifecycle_outbox,
yolo: yolo_compat,
yolo_compat_notified: false,
full_access_scope,
startup_defaults: Default::default(),
keybinding_migration_notified: false,
mode_prefs,
@@ -1133,6 +1150,7 @@ impl App {
constitution_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
mcp_login: None,
mcp_retries: Vec::new(),
mcp_login_reconnect: None,
prompt_suggestion_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
balance_initiated: false,
last_balance_fetch: None,
@@ -1218,6 +1236,13 @@ impl App {
if yolo_compat {
app.notify_yolo_compat_once();
}
if full_access_migration {
app.view_stack
.push(crate::tui::full_access_confirm::FullAccessConfirmView::new(
crate::tui::full_access_confirm::FullAccessOrigin::Migration,
app.ui_locale,
));
}
app
}
}
+98 -46
View File
@@ -2685,29 +2685,26 @@ fn fully_configured_returning_user_skips_onboarding() {
}
#[test]
fn returning_user_missing_api_key_goes_to_canonical_provider_setup() {
fn returning_user_missing_api_key_starts_at_composer() {
assert_eq!(
initial_onboarding_state(false, true, false, true, false),
OnboardingState::Provider
OnboardingState::None
);
// workspace trust doesn't affect the api-key gate
assert_eq!(
initial_onboarding_state(false, true, false, true, true),
OnboardingState::Provider
OnboardingState::None
);
}
#[test]
fn first_run_user_without_a_key_starts_on_connect_a_model() {
// #6566: a new user with no key used to land on a composer that could
// not answer. The one launch screen is the provider picker.
fn first_run_user_without_a_key_starts_at_composer() {
assert_eq!(
initial_onboarding_state(false, false, true, true, true),
OnboardingState::Provider
OnboardingState::None
);
assert_eq!(
initial_onboarding_state(false, false, false, true, true),
OnboardingState::Provider
OnboardingState::None
);
}
@@ -2816,8 +2813,9 @@ fn first_run_app_without_a_key_opens_provider_setup() {
.collect();
let app = App::new(test_options(false), &Config::default());
// #6566: the first screen connects a model; Esc returns to the composer.
assert_eq!(app.onboarding, OnboardingState::Provider);
// CW-7167: a keyless launch opens the composer; the provider is asked for
// on the first send.
assert_eq!(app.onboarding, OnboardingState::None);
assert!(app.onboarding_needs_api_key);
assert!(app.onboarding_missing_key_recovery);
// A new user has no saved route, so the picker opens on the provider
@@ -3942,7 +3940,19 @@ fn cycle_approval_scenario() {
assert!(app.cycle_approval_posture());
assert_eq!(app.approval_mode, ApprovalMode::Auto);
assert!(app.cycle_approval_posture());
assert!(!app.cycle_approval_posture());
assert_eq!(
app.view_stack.top_kind(),
Some(crate::tui::views::ModalKind::FullAccessConfirm)
);
assert_eq!(app.approval_mode, ApprovalMode::Auto);
let persisted =
std::fs::read_to_string(tmp.path().join("settings.toml")).expect("settings");
assert!(!persisted.contains("full-access"));
assert!(app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Cycle,
));
assert_eq!(app.approval_mode, ApprovalMode::Bypass);
assert!(app.cycle_approval_posture());
@@ -3970,7 +3980,7 @@ fn cycle_approval_scenario() {
.count();
assert_eq!(notices, 1, "first cycle posts the rebinding notice");
assert!(app.cycle_approval_posture());
let _ = app.cycle_approval_posture();
let notices = app
.status_toasts
.iter()
@@ -4002,9 +4012,13 @@ fn plan_permission_cycle_moves_the_baseline_and_leaves_plan_read_only() {
app.set_mode(AppMode::Plan);
assert!(
app.cycle_approval_posture(),
"Shift+Tab must still change permissions while in Plan"
!app.cycle_approval_posture(),
"Shift+Tab into Full Access asks first, even while in Plan"
);
assert!(app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Cycle,
));
assert_eq!(
app.mode_prefs.agent_approval_mode,
ApprovalMode::Bypass,
@@ -4021,16 +4035,9 @@ fn plan_permission_cycle_moves_the_baseline_and_leaves_plan_read_only() {
"changing permissions must not move the mode"
);
assert!(
app.status_toasts
.iter()
.any(|toast| toast.text.contains("applies in Act and Operate")),
"the receipt must say when the new posture starts applying"
);
let persisted = std::fs::read_to_string(tmp.path().join("settings.toml")).expect("settings");
assert!(
persisted.contains("permission_posture = \"full-access\""),
"the posture is durable, not dropped because Plan was active: {persisted}"
std::fs::read_to_string(tmp.path().join("settings.toml"))
.map_or(true, |persisted| !persisted.contains("full-access")),
"Full Access is per-session and is never written to settings"
);
app.set_mode(AppMode::Operate);
@@ -4067,10 +4074,10 @@ fn busy_permission_cycle_changes_neither_runtime_nor_persistence() {
#[test]
fn permission_postures_persist_across_restart() {
let _env_lock = lock_test_env();
for (cycles, expected) in [
(1, ApprovalMode::Auto),
(2, ApprovalMode::Bypass),
(3, ApprovalMode::Suggest),
for (cycles, expected, restarted_expected) in [
(1, ApprovalMode::Auto, ApprovalMode::Auto),
(2, ApprovalMode::Bypass, ApprovalMode::Auto),
(3, ApprovalMode::Suggest, ApprovalMode::Suggest),
] {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("config.toml");
@@ -4079,16 +4086,27 @@ fn permission_postures_persist_across_restart() {
options.start_in_agent_mode = true;
options.config_path = Some(path.clone());
let mut app = App::new(options.clone(), &Config::default());
for _ in 0..cycles {
assert!(app.cycle_approval_posture());
for cycle in 1..=cycles {
if cycle == 2 {
assert!(!app.cycle_approval_posture());
assert!(app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Cycle,
));
} else {
assert!(app.cycle_approval_posture());
}
}
assert_eq!(app.approval_mode, expected);
assert_eq!(app.trust_mode, expected == ApprovalMode::Bypass);
let restarted = App::new(options, &Config::default());
assert_eq!(restarted.approval_mode, expected);
assert_eq!(restarted.mode_prefs.agent_approval_mode, expected);
assert_eq!(restarted.trust_mode, expected == ApprovalMode::Bypass);
assert_eq!(restarted.approval_mode, restarted_expected);
assert_eq!(restarted.mode_prefs.agent_approval_mode, restarted_expected);
assert_eq!(
restarted.trust_mode,
restarted_expected == ApprovalMode::Bypass
);
drop(config_env);
}
}
@@ -4158,11 +4176,24 @@ fn legacy_yolo_migrates_root_policy_to_agent_full_access() {
let saved_settings = std::fs::read_to_string(&settings_path).expect("saved settings");
assert!(saved_settings.contains("default_mode = \"agent\""));
assert!(saved_settings.contains("permission_posture = \"full-access\""));
assert!(!saved_settings.contains("full_access_migration_shown"));
assert_eq!(
app.view_stack.top_kind(),
Some(crate::tui::views::ModalKind::FullAccessConfirm)
);
let mut app = app;
assert!(!app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Migration,
));
let saved_settings = std::fs::read_to_string(&settings_path).expect("saved settings");
assert!(saved_settings.contains("permission_posture = \"ask\""));
assert!(saved_settings.contains("full_access_migration_shown = true"));
let restarted_config = Config::load(Some(config_path), None).expect("reload config");
let restarted = App::new(options, &restarted_config);
assert_eq!(restarted.mode, AppMode::Agent);
assert_eq!(restarted.approval_mode, ApprovalMode::Bypass);
assert_eq!(restarted.approval_mode, ApprovalMode::Suggest);
assert!(!restarted.approval_policy_locked());
}
@@ -6731,7 +6762,14 @@ fn explicit_mode_selection_and_hotbar_share_the_persistence_owner() {
// The legacy YOLO entry point installs Act, so that is what must persist —
// "yolo" is a permission alias, never a startup mode.
assert_eq!(app.select_yolo_compat(), SettingSelection::Changed);
assert_eq!(
app.select_yolo_compat(),
SettingSelection::NeedsConfirmation
);
assert!(app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Yolo,
));
assert_eq!(Settings::load().expect("reload").default_mode, "agent");
}
@@ -7086,13 +7124,20 @@ async fn mode_and_permission_posture_writes_do_not_clobber_each_other() {
"mode selection must change mode"
);
assert!(
app.cycle_approval_posture(),
app.cycle_approval_posture()
|| app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Cycle,
),
"the posture write must succeed, or the assertion below is vacuous"
);
}
app.startup_defaults.flush();
let expected_posture = App::approval_posture_setting(app.mode_prefs.agent_approval_mode);
let expected_posture = match app.mode_prefs.agent_approval_mode {
ApprovalMode::Bypass => "auto-review",
mode => App::approval_posture_setting(mode),
};
let saved = Settings::load_persisted().expect("reload settings");
assert_eq!(
saved.default_mode, "operate",
@@ -7190,7 +7235,13 @@ async fn rapid_mixed_writes_settle_on_the_last_value_for_every_field() {
);
app.apply_reasoning_effort_cycle();
// Synchronous direct writers.
assert!(app.cycle_approval_posture());
assert!(
app.cycle_approval_posture()
|| app.apply_confirmed_full_access(
crate::tui::full_access_confirm::FullAccessScope::Session,
crate::tui::full_access_confirm::FullAccessOrigin::Cycle,
)
);
Settings::transact(|settings| settings.set("max_history", &(200 + index).to_string()))
.expect("the direct write must land");
}
@@ -7207,7 +7258,10 @@ async fn rapid_mixed_writes_settle_on_the_last_value_for_every_field() {
&app.active_route_base_url,
&app.model,
);
let expected_posture = App::approval_posture_setting(app.mode_prefs.agent_approval_mode);
let expected_posture = match app.mode_prefs.agent_approval_mode {
ApprovalMode::Bypass => "auto-review",
mode => App::approval_posture_setting(mode),
};
let saved = Settings::load_persisted().expect("reload settings");
assert_eq!(saved.default_mode, app.mode.as_setting());
assert_eq!(saved.reasoning_effort.as_deref(), Some(expected_effort));
@@ -7683,11 +7737,11 @@ fn launch_onboarding_scenario() {
}
// from launch_onboarding_opens_picker_for_generic_missing_key
{
// A generic missing key (not the xAI-OAuth re-auth case) still reopens the
// provider picker for recovery.
// A generic missing key (not the xAI-OAuth re-auth case) starts at the
// composer with missing-key recovery armed for the first send.
let (onboarding, recovery) =
launch_onboarding_decision(false, true, false, true, false, false);
assert_eq!(onboarding, OnboardingState::Provider);
assert_eq!(onboarding, OnboardingState::None);
assert!(recovery);
}
// from launch_onboarding_clean_when_onboarded_with_key
@@ -7705,10 +7759,8 @@ fn launch_onboarding_scenario() {
assert_eq!(onboarding, OnboardingState::None);
assert!(!recovery);
// #6566: a first run with no key opens the picker directly, with the
// same Esc-to-composer exit as missing-key recovery.
let (keyless, recovery) = launch_onboarding_decision(false, false, true, true, true, false);
assert_eq!(keyless, OnboardingState::Provider);
assert_eq!(keyless, OnboardingState::None);
assert!(recovery);
// A first run with a key starts at the composer.
+2 -1
View File
@@ -30,6 +30,7 @@ pub enum SettingSelection {
PersistedSame,
/// Refused by the turn lock (#2982). Nothing was written anywhere.
Refused,
NeedsConfirmation,
}
impl SettingSelection {
@@ -43,7 +44,7 @@ impl SettingSelection {
#[must_use]
#[cfg(test)]
pub fn accepted(self) -> bool {
!matches!(self, Self::Refused)
matches!(self, Self::Changed | Self::PersistedSame)
}
}
+137
View File
@@ -0,0 +1,137 @@
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
use crate::tui::views::{
ModalKind, ModalView, ViewAction, ViewEvent, centered_modal_area, render_modal_surface,
};
use codewhale_localization::{Locale, MessageId, tr};
use codewhale_palette as palette;
pub(crate) use crate::core::authority::FullAccessScope;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FullAccessOrigin {
Cycle,
RootCycle,
Yolo,
Config,
RootConfig,
Setup,
Migration,
}
pub struct FullAccessConfirmView {
origin: FullAccessOrigin,
locale: Locale,
}
impl FullAccessConfirmView {
#[must_use]
pub fn new(origin: FullAccessOrigin, locale: Locale) -> Self {
Self { origin, locale }
}
fn migration(&self) -> bool {
self.origin == FullAccessOrigin::Migration
}
fn confirm(&self, scope: FullAccessScope) -> ViewAction {
ViewAction::EmitAndClose(ViewEvent::FullAccessConfirmed {
scope,
origin: self.origin,
})
}
}
impl ModalView for FullAccessConfirmView {
fn kind(&self) -> ModalKind {
ModalKind::FullAccessConfirm
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
if key.kind != KeyEventKind::Press {
return ViewAction::None;
}
if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
return ViewAction::None;
}
match key.code {
KeyCode::Char('r') | KeyCode::Char('R') => self.confirm(FullAccessScope::Repo),
KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
self.confirm(FullAccessScope::Session)
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') if self.migration() => {
self.confirm(FullAccessScope::Session)
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => ViewAction::Close,
_ => ViewAction::None,
}
}
fn occupied_region(&self, area: Rect) -> Rect {
centered_modal_area(area, 72, 13, 36, 10)
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup = self.occupied_region(area);
render_modal_surface(area, popup, buf);
let (title_id, body_id, keys_id) = if self.migration() {
(
MessageId::FullAccessMigrationTitle,
MessageId::FullAccessMigrationBody,
MessageId::FullAccessMigrationKeys,
)
} else {
(
MessageId::FullAccessConfirmTitle,
MessageId::FullAccessConfirmRisk,
MessageId::FullAccessConfirmKeys,
)
};
let block = Block::default()
.title(Line::from(Span::styled(
tr(self.locale, title_id).to_string(),
Style::default().fg(palette::STATUS_WARNING).bold(),
)))
.borders(Borders::ALL)
.border_style(Style::default().fg(palette::STATUS_WARNING))
.style(Style::default().bg(palette::WHALE_BG))
.padding(Padding::uniform(1));
let inner = block.inner(popup);
block.render(popup, buf);
let controls_height = if self.migration() { 1 } else { 4 };
let body_height = inner.height.saturating_sub(controls_height + 1);
Paragraph::new(tr(self.locale, body_id).to_string())
.style(Style::default().fg(palette::STATUS_WARNING))
.wrap(Wrap { trim: true })
.render(Rect::new(inner.x, inner.y, inner.width, body_height), buf);
let mut y = inner.bottom().saturating_sub(controls_height);
if !self.migration() {
for (id, key) in [
(MessageId::FullAccessConfirmSession, "Enter"),
(MessageId::FullAccessConfirmRepo, "R"),
(MessageId::FullAccessConfirmCancel, "Esc"),
] {
Paragraph::new(format!("{key} {}", tr(self.locale, id)))
.style(Style::default().fg(palette::TEXT_PRIMARY))
.render(Rect::new(inner.x, y, inner.width, 1), buf);
y += 1;
}
}
Paragraph::new(tr(self.locale, keys_id).to_string())
.style(Style::default().fg(palette::TEXT_MUTED))
.render(Rect::new(inner.x, y, inner.width, 1), buf);
}
}
+1
View File
@@ -50,6 +50,7 @@ pub mod footer_hints;
pub mod footer_ui;
pub mod format_helpers;
pub mod frame_rate_limiter;
pub mod full_access_confirm;
pub mod gate_receipts;
pub mod git_mention;
pub mod glyphs;
+1
View File
@@ -2556,6 +2556,7 @@ mod tests {
title: "refactor the parser".to_string(),
updated_at: chrono::Utc::now(),
message_count: 12,
interrupted: false,
}];
app.launch.row_hitboxes = vec![(
crate::tui::app::LaunchRowId::Recent("sess-1".to_string()),
+4 -1
View File
@@ -476,11 +476,14 @@ pub fn advance_onboarding_after_language(app: &mut App) {
}
/// Provider setup → trust when a decision is required, otherwise the ready
/// screen.
/// screen. A message already typed skips the ready screen and returns to the
/// composer with the draft intact.
pub fn advance_onboarding_after_provider(app: &mut App) {
app.status_message = None;
if trust_decision_required(app) {
app.onboarding = OnboardingState::TrustDirectory;
} else if !app.composer.input.trim().is_empty() && !app.onboarding_explore_offline {
app.finish_onboarding_without_feature_intro();
} else {
app.onboarding = OnboardingState::Ready;
}
+97 -90
View File
@@ -489,8 +489,8 @@ fn run_world(
{
audio = None;
}
let active =
now.duration_since(last_view) <= LEASE || producer.is_some() || audio.is_some();
let viewed = now.duration_since(last_view) <= LEASE;
let active = viewed || producer.is_some() || audio.is_some();
if !active || !was_active {
last = now;
}
@@ -560,99 +560,106 @@ fn run_world(
} else {
last + Duration::from_secs_f64(count as f64 / 30.0)
};
let text: String = context.with(|ctx| ctx.eval("pet.presentation()"))?;
let mut frame: Value = serde_json::from_str(&text)?;
frame["version"] = json!(1);
frame["identity"] = json!(saved.identity);
frame["epoch"] = json!(epoch);
frame["tick"] =
json!((frame["timeMs"].as_f64().unwrap_or(0.0) * 30.0 / 1000.0).round() as u64);
frame["cursor"] = json!(saved.cursor);
frame["source"] = json!(saved.source);
frame["sourceRevision"] = json!(saved.source_revision);
bind_activity(&mut frame, &saved.source, saved.cursor);
// Presentation material keeps missing coverage legible. The core
// pigment, score, particle digest and recording are unchanged.
for key in ["", "still"] {
let pose = if key.is_empty() {
&mut frame
} else {
&mut frame[key]
};
let hollow =
producer.is_none() || pose["style"]["hollow"].as_bool().unwrap_or(true);
if hollow {
pose["style"]["hollow"] = json!(true);
pose["style"]["r"] = json!(153);
pose["style"]["g"] = json!(176);
pose["style"]["b"] = json!(184);
pose["style"]["alpha"] =
json!(0.68 * pose["state"]["lit"].as_f64().unwrap_or(1.0).max(0.25));
}
}
for key in ["", "still"] {
let pose = if key.is_empty() {
&mut frame
} else {
&mut frame[key]
};
if !saved.appearance.event_colors {
for (key, value) in ["r", "g", "b"].into_iter().zip(saved.appearance.particle) {
pose["style"][key] = json!(value);
if viewed {
let text: String = context.with(|ctx| ctx.eval("pet.presentation()"))?;
let mut frame: Value = serde_json::from_str(&text)?;
frame["version"] = json!(1);
frame["identity"] = json!(saved.identity);
frame["epoch"] = json!(epoch);
frame["tick"] =
json!((frame["timeMs"].as_f64().unwrap_or(0.0) * 30.0 / 1000.0).round() as u64);
frame["cursor"] = json!(saved.cursor);
frame["source"] = json!(saved.source);
frame["sourceRevision"] = json!(saved.source_revision);
bind_activity(&mut frame, &saved.source, saved.cursor);
// Presentation material keeps missing coverage legible. The core
// pigment, score, particle digest and recording are unchanged.
for key in ["", "still"] {
let pose = if key.is_empty() {
&mut frame
} else {
&mut frame[key]
};
let hollow =
producer.is_none() || pose["style"]["hollow"].as_bool().unwrap_or(true);
if hollow {
pose["style"]["hollow"] = json!(true);
pose["style"]["r"] = json!(153);
pose["style"]["g"] = json!(176);
pose["style"]["b"] = json!(184);
pose["style"]["alpha"] =
json!(0.68 * pose["state"]["lit"].as_f64().unwrap_or(1.0).max(0.25));
}
}
let hollow = pose["style"]["hollow"].as_bool().unwrap_or(true);
let lit = pose["state"]["lit"]
.as_f64()
.unwrap_or(1.0)
.clamp(0.18, 1.0);
let light = saved
.appearance
.background
.iter()
.map(|v| f64::from(*v))
.sum::<f64>()
> 510.0;
if let Some(materials) = pose["materials"].as_array_mut() {
for material in materials {
if let Some(channels) = material.as_array_mut().filter(|c| c.len() == 4) {
for k in 0..3 {
let value = if hollow {
[153., 176., 184.][k]
} else if !saved.appearance.event_colors {
f64::from(saved.appearance.particle[k])
} else {
channels[k].as_f64().unwrap_or(160.0)
};
channels[k] = json!(if light { value * 0.48 } else { value });
}
let alpha = channels[3].as_f64().unwrap_or(0.5);
channels[3] =
json!((alpha * saved.appearance.brightness * lit).clamp(0.0, 1.0));
for key in ["", "still"] {
let pose = if key.is_empty() {
&mut frame
} else {
&mut frame[key]
};
if !saved.appearance.event_colors {
for (key, value) in
["r", "g", "b"].into_iter().zip(saved.appearance.particle)
{
pose["style"][key] = json!(value);
}
}
let hollow = pose["style"]["hollow"].as_bool().unwrap_or(true);
let lit = pose["state"]["lit"]
.as_f64()
.unwrap_or(1.0)
.clamp(0.18, 1.0);
let light = saved
.appearance
.background
.iter()
.map(|v| f64::from(*v))
.sum::<f64>()
> 510.0;
if let Some(materials) = pose["materials"].as_array_mut() {
for material in materials {
if let Some(channels) = material.as_array_mut().filter(|c| c.len() == 4)
{
for k in 0..3 {
let value = if hollow {
[153., 176., 184.][k]
} else if !saved.appearance.event_colors {
f64::from(saved.appearance.particle[k])
} else {
channels[k].as_f64().unwrap_or(160.0)
};
channels[k] = json!(if light { value * 0.48 } else { value });
}
let alpha = channels[3].as_f64().unwrap_or(0.5);
channels[3] = json!(
(alpha * saved.appearance.brightness * lit).clamp(0.0, 1.0)
);
}
}
}
pose["style"]["alpha"] = json!(
(pose["style"]["alpha"].as_f64().unwrap_or(0.5)
* saved.appearance.brightness)
.clamp(0.0, 1.0)
);
}
frame["appearance"] = json!(saved.appearance);
frame["producerConnected"] = json!(producer.is_some());
frame["storageAvailable"] = json!(!storage_error);
frame["audioOwner"] = json!(audio.as_ref().map(|(id, _)| id));
frame["audioUnavailable"] = json!(audio_error);
measurements.push_back(started.elapsed().as_secs_f64() * 1000.0);
if measurements.len() > 300 {
measurements.pop_front();
}
frame["performance"] = json!({"worldHz":30,"frames":ticks,"uptimeSeconds":origin.elapsed().as_secs_f64(),"workMs":measurements.back()});
let mut output = frames
.lock()
.map_err(|_| anyhow::anyhow!("Frame lock failed"))?;
output.push_back(frame);
if output.len() > 16 {
output.pop_front();
}
pose["style"]["alpha"] = json!(
(pose["style"]["alpha"].as_f64().unwrap_or(0.5) * saved.appearance.brightness)
.clamp(0.0, 1.0)
);
}
frame["appearance"] = json!(saved.appearance);
frame["producerConnected"] = json!(producer.is_some());
frame["storageAvailable"] = json!(!storage_error);
frame["audioOwner"] = json!(audio.as_ref().map(|(id, _)| id));
frame["audioUnavailable"] = json!(audio_error);
measurements.push_back(started.elapsed().as_secs_f64() * 1000.0);
if measurements.len() > 300 {
measurements.pop_front();
}
frame["performance"] = json!({"worldHz":30,"frames":ticks,"uptimeSeconds":origin.elapsed().as_secs_f64(),"workMs":measurements.back()});
let mut output = frames
.lock()
.map_err(|_| anyhow::anyhow!("Frame lock failed"))?;
output.push_back(frame);
if output.len() > 16 {
output.pop_front();
}
}
if (active || was_active || storage_error) && last_save.elapsed() >= Duration::from_secs(1)
+5 -1
View File
@@ -1139,7 +1139,11 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo
permission_chip,
permission_key: live_chord(ShellBindingId::PermissionCycle)
.filter(|_| {
!crate::tui::footer_hints::retired(
matches!(
app.approval_mode,
codewhale_execpolicy::ApprovalMode::Auto
| codewhale_execpolicy::ApprovalMode::Bypass
) || !crate::tui::footer_hints::retired(
&app.footer_hint_uses,
crate::tui::footer_hints::PERMISSION_CYCLE,
)
@@ -367,6 +367,7 @@ fn set_uses(app: &mut App, key: &str, uses: u8) {
#[test]
fn cycle_keys_show_at_zero_and_one_use_and_go_bare_at_two() {
let mut app = session_app();
app.approval_mode = codewhale_execpolicy::ApprovalMode::Suggest;
let facts = tideline_footer_from_app(&mut app, 120);
assert_eq!(facts.permission_key.as_deref(), Some("Shift+Tab to change"));
assert_eq!(facts.mode_key, Some("Tab"));
+44 -6
View File
@@ -197,6 +197,7 @@ pub struct ProviderPickerView {
/// the advanced management hotkeys from the decision surface. They remain
/// available from `/provider` after onboarding.
onboarding_mode: bool,
local_runtime_detected: bool,
query: String,
/// Explicit search keeps provider names from invoking legacy letter actions.
search_mode: bool,
@@ -1920,6 +1921,7 @@ impl ProviderPickerView {
view,
setup_mode: false,
onboarding_mode: false,
local_runtime_detected: true,
query: String::new(),
search_mode: false,
api_key_input: String::new(),
@@ -1970,6 +1972,29 @@ impl ProviderPickerView {
self
}
#[must_use]
pub(crate) fn with_local_runtime_detected(mut self, detected: bool) -> Self {
self.local_runtime_detected = detected;
if !self.rows.is_empty() && !self.row_visible(self.selected_idx) {
self.selected_idx = (0..self.rows.len())
.find(|idx| self.row_visible(*idx))
.unwrap_or(0);
}
self
}
fn hides_undetected_local(&self, idx: usize) -> bool {
self.onboarding_mode
&& !self.local_runtime_detected
&& !self.rows[idx].is_configured
&& self.rows[idx]
.provider
.provider()
.credential_help()
.acquisition
== CredentialAcquisition::LocalOptional
}
fn tr(&self, id: MessageId) -> Cow<'static, str> {
tr(self.locale, id)
}
@@ -2130,6 +2155,9 @@ impl ProviderPickerView {
}
fn row_visible(&self, idx: usize) -> bool {
if self.hides_undetected_local(idx) {
return false;
}
let query = self.query.trim();
if !query.is_empty() {
return self.rows[idx].matches_query(query);
@@ -2844,14 +2872,23 @@ impl ProviderPickerView {
} else {
self.tr(MessageId::PickerActionSetKey)
};
let title = if !self.onboarding_mode && (self.search_mode || !self.query.is_empty()) {
let title = if self.onboarding_mode {
if self.search_mode || !self.query.is_empty() {
format!(
" {} · {}: {} ",
self.tr(MessageId::OnboardProviderTitle),
self.tr(MessageId::SessionsActionSearch),
self.query
)
} else {
format!(" {} ", self.tr(MessageId::OnboardProviderTitle))
}
} else if self.search_mode || !self.query.is_empty() {
format!(
"{}: {}",
self.tr(MessageId::SessionsActionSearch),
self.query
)
} else if self.onboarding_mode {
format!(" {} ", self.tr(MessageId::OnboardProviderTitle))
} else {
match (self.setup_mode, self.view) {
(true, ProviderListView::Configured) => {
@@ -4708,7 +4745,8 @@ impl ModalView for ProviderPickerView {
KeyCode::Char(c)
if key.modifiers.is_empty()
&& self.query.is_empty()
&& c.eq_ignore_ascii_case(&'l') =>
&& c.eq_ignore_ascii_case(&'l')
&& (!self.onboarding_mode || self.local_runtime_detected) =>
{
self.show_local_routes();
ViewAction::None
@@ -8368,7 +8406,7 @@ mod tests {
"provider = \"openai\"\ndefault_text_model = \"gpui-fixture\"\n\
[providers.openai]\nbase_url = \"https://fixture.invalid/v1\"\nauth_mode = \"api_key\"\n",
|app, config| {
assert_eq!(app.onboarding, crate::tui::app::OnboardingState::Provider);
assert_eq!(app.onboarding, crate::tui::app::OnboardingState::None);
assert!(app.onboarding_recovers_configured_route());
let picker = ProviderPickerView::new_for_onboarding(
app.api_provider,
@@ -8386,7 +8424,7 @@ mod tests {
#[test]
fn first_run_unconfigured_route_keeps_picker_and_local_discovery() {
with_first_run_config("", |mut app, config| {
assert_eq!(app.onboarding, crate::tui::app::OnboardingState::Provider);
assert_eq!(app.onboarding, crate::tui::app::OnboardingState::None);
assert!(!app.onboarding_recovers_configured_route());
assert!(app.should_adopt_live_local_ollama());
let picker =
+7 -6
View File
@@ -644,16 +644,17 @@ impl SetupRuntimePreset {
pub fn permission_posture(self) -> &'static str {
match self {
Self::AskFirst | Self::NormalAgent => "ask",
Self::HighTrustLocal => "full-access",
Self::AskFirst | Self::NormalAgent | Self::HighTrustLocal => "ask",
}
}
pub fn full_access_requested(self) -> bool {
matches!(self, Self::HighTrustLocal)
}
pub fn approval_policy(self) -> Option<&'static str> {
match self {
Self::AskFirst | Self::NormalAgent => Some("on-request"),
// Full Access lives in TUI settings; it is intentionally not a
// top-level approval_policy value.
Self::HighTrustLocal => None,
}
}
@@ -676,7 +677,7 @@ impl SetupRuntimePreset {
pub fn result_summary(self) -> String {
let approval = self
.approval_policy()
.unwrap_or("unset (Full Access saved in TUI settings)");
.unwrap_or("unset (Full Access is confirmed per session)");
format!(
"preset={}, default_mode={}, permission_posture={}, approval_policy={}, allow_shell={}, sandbox_mode={}, network=unchanged, trust=unchanged",
self.id(),
@@ -4229,7 +4230,7 @@ fn runtime_preset_preview_text(
fn runtime_preset_diff_rows(preset: SetupRuntimePreset, facts: &SetupRuntimeFacts) -> Vec<String> {
let approval_target = preset.approval_policy().map_or_else(
|| "removed; Full Access comes from settings.permission_posture".to_string(),
|| "removed; Full Access is confirmed per session".to_string(),
ToString::to_string,
);
let mut rows = vec![
+101 -15
View File
@@ -2,8 +2,10 @@
use crate::tools::shell::{ShellJobDetail, ShellJobSnapshot, ShellResult, ShellStatus};
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
use crate::tui::pager::PagerView;
use crate::tui::views::{CommandPaletteAction, ViewEvent};
use codewhale_localization::MessageId;
fn status_label(status: &ShellStatus, stale: bool) -> &'static str {
if stale {
@@ -85,34 +87,108 @@ pub(super) fn format_shell_poll(result: &ShellResult) -> String {
lines.join("\n")
}
pub(super) fn open_shell_job_pager(app: &mut App, detail: &ShellJobDetail) {
const MAX_INSPECTOR_STREAM_BYTES: usize = 256 * 1024;
fn bounded_tail(text: &str) -> String {
if text.len() <= MAX_INSPECTOR_STREAM_BYTES {
return text.to_string();
}
let mut start = text.len() - MAX_INSPECTOR_STREAM_BYTES;
while !text.is_char_boundary(start) {
start += 1;
}
format!("... ({start} earlier bytes omitted)\n{}", &text[start..])
}
pub(crate) fn shell_job_for_cell(app: &App, cell_index: usize) -> Option<ShellJobDetail> {
let HistoryCell::Tool(ToolCell::Exec(exec)) = app.cell_at_virtual_index(cell_index)? else {
return None;
};
let session = app.current_session_id.as_deref()?;
let mut manager = app
.runtime_services
.shell_manager
.as_ref()?
.try_lock()
.ok()?;
let job_id = match exec.shell_task_id.clone() {
Some(id) => id,
None => {
let tool_id = app
.tool_cells
.iter()
.find(|(_, index)| **index == cell_index)
.map(|(tool_id, _)| tool_id.clone())?;
manager
.list_jobs_for_session(session)
.into_iter()
.find(|job| job.origin_tool_call_id.as_deref() == Some(tool_id.as_str()))?
.id
}
};
manager.inspect_job_for_session(session, &job_id).ok()
}
pub(crate) fn newest_running_shell_cell(app: &App) -> Option<usize> {
(0..app.virtual_cell_count()).rev().find(|&index| {
matches!(
app.cell_at_virtual_index(index),
Some(HistoryCell::Tool(ToolCell::Exec(exec))) if exec.status == ToolStatus::Running
)
})
}
pub(crate) fn open_shell_job_pager(app: &mut App, detail: &ShellJobDetail) {
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(100)
.saturating_sub(4);
app.view_stack.push(PagerView::from_text(
let live = detail.snapshot.status == ShellStatus::Running && !detail.snapshot.stale;
let body = format_shell_job_detail(detail);
let mut pager = PagerView::from_text(
format!("Bash Job {}", detail.snapshot.id),
&format_shell_job_detail(detail),
&body,
width.max(60),
));
)
.with_copy_text(body);
if live {
pager = pager.with_destructive_action(
's',
app.tr(MessageId::SidebarStopControl),
app.tr(MessageId::WorkSurfaceStopConfirmHint),
ViewEvent::CommandPaletteSelected {
action: CommandPaletteAction::ExecuteCommand {
command: format!("/jobs cancel {}", detail.snapshot.id),
},
},
);
}
app.view_stack.push(pager);
}
fn format_shell_job_detail(detail: &ShellJobDetail) -> String {
pub(crate) fn format_shell_job_detail(detail: &ShellJobDetail) -> String {
let job = &detail.snapshot;
let mut lines = vec![
format!("Job: {}", job.id),
format!("Status: {}", status_label(&job.status, job.stale)),
format!("Command: {}", job.command),
format!("Cwd: {}", crate::utils::display_path(&job.cwd)),
format!(
"Elapsed: {}",
codewhale_command_contract::elapsed::format_elapsed_ms(job.elapsed_ms)
),
format!("Exit Code: {:?}", job.exit_code),
format!("Stdin Available: {}", job.stdin_available),
];
if let Some(owner) = job
.owner_agent_name
.as_ref()
.or(job.owner_agent_id.as_ref())
{
lines.push(format!("Owner: {owner}"));
}
lines.push(format!(
"Elapsed: {}",
codewhale_command_contract::elapsed::format_elapsed_ms(job.elapsed_ms)
));
lines.push(format!("Exit Code: {:?}", job.exit_code));
lines.push(format!("Stdin Available: {}", job.stdin_available));
if let Some(task_id) = job.linked_task_id.as_ref() {
lines.push(format!("Linked Task: {task_id}"));
}
@@ -120,22 +196,32 @@ fn format_shell_job_detail(detail: &ShellJobDetail) -> String {
lines.push(
"Completion state: stale after restart; the process is no longer running.".to_string(),
);
} else if job.status == ShellStatus::Running {
lines.push("Completion State: still running in this TUI process.".to_string());
} else {
lines.push("Completion State: live in this TUI process.".to_string());
lines.push("Completion State: finished.".to_string());
}
lines.push(String::new());
if detail.stdout.is_empty()
&& detail.stderr.is_empty()
&& job.status == ShellStatus::Running
&& !job.stale
{
lines.push("(no output yet)".to_string());
return lines.join("\n");
}
lines.push(format!("STDOUT ({} bytes):", job.stdout_len));
lines.push(if detail.stdout.is_empty() {
"(empty)".to_string()
} else {
detail.stdout.clone()
bounded_tail(&detail.stdout)
});
lines.push(String::new());
lines.push(format!("STDERR ({} bytes):", job.stderr_len));
lines.push(if detail.stderr.is_empty() {
"(empty)".to_string()
} else {
detail.stderr.clone()
bounded_tail(&detail.stderr)
});
lines.join("\n")
}
+22 -1
View File
@@ -275,7 +275,18 @@ const RAW_DETAIL_PAGER_INTRO: &str =
"Raw detail for the selected item — press Ctrl+Alt+O for the whole-turn overview.";
pub(super) fn open_tool_details_pager(app: &mut App) -> bool {
let target_cell = detail_target_cell_index(app);
let target_cell = if app
.viewport
.transcript_selection
.ordered_endpoints()
.is_some()
{
detail_target_cell_index(app)
} else {
crate::tui::shell_job_routing::newest_running_shell_cell(app)
.filter(|&idx| crate::tui::shell_job_routing::shell_job_for_cell(app, idx).is_some())
.or_else(|| detail_target_cell_index(app))
};
let Some(cell_index) = target_cell else {
app.status_message = Some(NO_RAW_DETAIL_HINT.to_string());
@@ -389,6 +400,16 @@ fn canonical_owned_file(
}
pub(crate) fn open_details_pager_for_cell(app: &mut App, cell_index: usize) -> bool {
if app.cell_at_virtual_index(cell_index).is_some_and(|cell| {
matches!(
cell,
HistoryCell::Tool(ToolCell::Exec(exec)) if exec.status == ToolStatus::Running
)
}) && let Some(detail) = crate::tui::shell_job_routing::shell_job_for_cell(app, cell_index)
{
crate::tui::shell_job_routing::open_shell_job_pager(app, &detail);
return true;
}
if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
let input = serde_json::to_string_pretty(&detail.input)
.unwrap_or_else(|_| detail.input.to_string());
+4 -1
View File
@@ -3481,11 +3481,14 @@ pub(crate) fn apply_setup_runtime_preset(
let trust_mode = match preset {
crate::tui::setup::SetupRuntimePreset::AskFirst => false,
crate::tui::setup::SetupRuntimePreset::NormalAgent => app.agent_trust_baseline(),
crate::tui::setup::SetupRuntimePreset::HighTrustLocal => true,
crate::tui::setup::SetupRuntimePreset::HighTrustLocal => false,
};
app.set_agent_runtime_baseline(preset.allow_shell(), trust_mode, approval_mode);
let mode = AppMode::from_setting(preset.default_mode());
app.set_mode(mode);
if preset.full_access_requested() {
app.request_full_access(crate::tui::full_access_confirm::FullAccessOrigin::Setup);
}
app.needs_redraw = true;
Ok(format!("Applied {}.", preset.result_summary()))
+31 -10
View File
@@ -1212,16 +1212,6 @@ pub async fn run_tui(
persist_offline_queue_state(&app);
}
// A launch without a usable key opens the picker immediately (#6566).
// A configured user's picker focuses the saved route so recovery cannot
// silently replace it; an unconfigured user sees the provider list rather
// than the built-in default's missing key.
if app.onboarding == OnboardingState::Provider && app.onboarding_missing_key_recovery {
let recover_configured_route = app.onboarding_recovers_configured_route();
open_onboarding_provider_picker(&mut app, config, &engine_handle, recover_configured_route)
.await;
}
// #4605: create the dispatch completion channel before any submit path so
// initial input and queued follow-ups can dispatch without blocking the
// startup sequence.
@@ -1641,8 +1631,24 @@ async fn submit_decided_composer_input(
let slash_menu_open = !slash_menu_entries.is_empty();
let selecting_inline_skill = slash_menu_open
&& partial_inline_skill_mention_at_cursor(&app.input, app.cursor_position).is_some();
let typed_before_selection = app.input.trim().to_string();
if slash_menu_open && apply_slash_menu_selection(app, &slash_menu_entries, false) {
app.close_slash_menu();
let resolved = app
.input
.trim()
.trim_start_matches('/')
.to_ascii_lowercase();
if !selecting_inline_skill
&& !typed_before_selection.eq_ignore_ascii_case(app.input.trim())
&& matches!(
resolved.as_str(),
"exit" | "quit" | "clear" | "new" | "logout" | "restore"
)
{
app.cursor_position = app.input.chars().count();
return Ok(false);
}
if selecting_inline_skill {
return Ok(false);
}
@@ -2019,6 +2025,17 @@ pub(crate) async fn run_event_loop(
super::feedback_host::dispatch_ready(app, config, &engine_handle).await?;
}
if app.onboarding == OnboardingState::Provider && app.view_stack.top_kind().is_none() {
let recover_configured_route = app.onboarding_recovers_configured_route();
open_onboarding_provider_picker(
app,
&*config,
&engine_handle,
recover_configured_route,
)
.await;
}
// Drain the version-check handle once; re-assign None so we
// don't poll it again.
let mut done = false;
@@ -2051,6 +2068,7 @@ pub(crate) async fn run_event_loop(
local_done = handle.is_finished();
}
if local_done && let Ok(Some(catalog)) = local_ollama_probe.take().unwrap().await {
app.local_runtime_detected = true;
adopt_live_local_ollama_catalog(app, &mut engine_handle, config, catalog).await;
}
@@ -2281,6 +2299,9 @@ pub(crate) async fn run_event_loop(
// Discovery and callback delivery never park terminal input.
poll_mcp_login(app);
if let Some(server) = app.mcp_login_reconnect.take() {
start_mcp_retry(app, &engine_handle, server);
}
poll_mcp_retries(app);
// #1830/#2317: service any already-arrived terminal keys before a
+20 -9
View File
@@ -646,6 +646,10 @@ pub(crate) fn poll_mcp_login(app: &mut App) {
McpLoginProgress::Finished(outcome) => {
app.mcp_login = None;
match outcome {
Ok(()) if crate::mcp::mcp_name_is_command_safe(&server) => {
app.mcp_login_reconnect = Some(server);
return;
}
Ok(()) => (
app.tr(MessageId::McpLoginStored)
.replace("{server}", &server)
@@ -732,7 +736,7 @@ fn refresh_open_extensions(app: &mut App) {
/// on the UI loop. While a turn runs, the op waits in the engine mailbox and
/// runs as soon as the turn ends — the person asked once, so they are not
/// told to ask again.
fn start_mcp_retry(app: &mut App, engine_handle: &EngineHandle, name: String) {
pub(crate) fn start_mcp_retry(app: &mut App, engine_handle: &EngineHandle, name: String) {
use crate::tui::app::PendingMcpRetry;
let already = app
@@ -1230,14 +1234,7 @@ pub(crate) fn handle_shell_job_action(app: &mut App, action: crate::tui::app::Sh
match action {
crate::tui::app::ShellJobAction::List => {
let jobs = manager.list_jobs_for_session(&active_session_id);
let mut text = format_shell_job_list(&jobs);
if let Ok(cloud) =
crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.list())
&& !cloud.is_empty()
{
text.push_str("\n\n");
text.push_str(&crate::cloud_dispatch::format_job_list(&cloud));
}
let text = format_shell_job_list(&jobs);
add_shell_job_message(app, text);
}
crate::tui::app::ShellJobAction::Show { id } => {
@@ -3200,6 +3197,20 @@ pub(crate) async fn handle_view_events(
}
app.needs_redraw = true;
}
ViewEvent::FullAccessConfirmed { scope, origin } => {
if app.apply_confirmed_full_access(scope, origin) {
if matches!(
origin,
crate::tui::full_access_confirm::FullAccessOrigin::RootCycle
| crate::tui::full_access_confirm::FullAccessOrigin::RootConfig
) {
config.approval_policy = None;
}
sync_mode_update(app, engine_handle).await;
refresh_config_view_if_open(app, "permission_posture");
}
app.needs_redraw = true;
}
ViewEvent::BacktrackConfirm => {
if let Some(depth) = app.backtrack.confirm() {
// Reserve the slot before mutating history (#6150): the
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff. Show more