Files
Hunter Bown 72baeeb39d feat(cli): cloud-dispatch remote runner — sandbox to forge PR (#5712)
* feat(cli): cloud-dispatch remote runner — sandbox to forge PR

A confirmed dispatch now actually runs the cloud agent end to end:

- dispatch_runner (crates/tui): lifecycle launch → running → openingpr →
  done|failed|canceled. Create sandbox, clone the target forge repo,
  run ONE codewhale exec --auto turn (the same single-Engine::run_turn
  path, no second engine), collect format-patch, apply locally on a
  shallow clone, plain (never-forced) branch push, PR open (gh for
  github; Gitee v5 / CNB OpenAPI with service-slot tokens), and sandbox
  teardown on done/failed/canceled — cancel-during-running tears down
  at the next checkpoint and never opens the PR.
- cloud_dispatch: extended DaytonaLauncher (wait_ready, clone, harness,
  collect_patch, teardown) with LiveDaytonaLauncher implemented against
  the published Daytona control-plane and toolbox OpenAPI shapes
  (toolbox base {toolboxProxyUrl}/{sandboxId}); new job fields
  (base_branch, head_sha, agent_summary, finished_unix) with serde
  defaults so landed records still load; one https-only outbound origin
  guard (no loopback/private/reserved/userinfo; loopback only in debug
  builds) covering sandbox, toolbox, Gitee, and CNB calls; sandbox ids
  are charset-checked before path use; harness argv is POSIX-quoted
  before it becomes a toolbox shell string so the prompt cannot
  interpolate.
- Surfaces: status card and job card show real receipts (sandbox id,
  PR URL, head sha, whole-minute runtime — Codewhale bookkeeping, not a
  bill) with zero provider branding; CLI confirm stays attached to the
  runner thread so a sandbox is never orphaned; TUI detaches and
  /dispatch cancel tears down.
- Tests: RecordingLauncher pins the full protocol offline (order +
  teardown), cancel-during-running teardown, confirm gating, launch
  fail-closed with sanitized notes, PR title/body shape + No-Issue
  line + no-brand, gh/gitee/cnb request shapes, host validation, slug
  parsing, and a real-git local fixture for patch apply and the
  no-force push rule. Live network paths follow providers' published
  OpenAPI shapes and still need one real-sandbox smoke test per forge
  (documented in docs/DAYTONA_CLOUD_DISPATCH.md).

No-Issue: follow-up slice to #5701 flagged in the ops ledger (remote
runner, real receipts, cancel teardown); no tracked issue exists.
Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* chore(changelog): runner slice receipts + sync tui changelog

Reference #5701 in the runner's root CHANGELOG entry so the
check-feature-release-notes gate finds its receipt, and run
scripts/sync-changelog.sh so crates/tui/CHANGELOG.md mirrors the root
slice as the Version drift job requires.

No-Issue: changelog-only receipt for the #5712 runner slice.
Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* fix(tui): reconcile orphaned cloud-dispatch sandboxes

Quitting the TUI (or crashing) with a detached dispatch runner in flight
left an active job record and a billing sandbox behind with nothing to
reconcile them; a create whose POST landed after the 120s client timeout
left an id-less sandbox that was unknowable forever.

Four parts:

- intent record: drive() persists sandbox_pending=true BEFORE the create
  POST (and clears it once the id lands), so a slow/lost create is still
  reconcilable by sandbox label.
- label reconciler: every sandbox is labeled codewhale.job=<id> /
  codewhale.product=dispatch (now pinned to shared constants);
  reconcile_sandboxes() lists dispatch sandboxes via the provider API and
  deletes any whose job is terminal or absent from the store;
  reconcile_job_sandboxes() is the per-id form cancel uses when a sandbox
  may exist without a recorded id.
- startup sweep: sweep_stale_jobs() fails launching/running/openingpr
  records older than the declared harness budget plus slack (90 min) and
  tears their recorded sandboxes down; startup_reconcile() runs the sweep
  then the label pass, wired into the TUI's boot janitor on a blocking
  worker (best effort, never fatal).
- quit warning: arming the two-tap quit prompt now surfaces live cloud
  jobs in the status line (live_job_quit_warning), naming the ids and the
  /dispatch cancel escape hatch. Ctrl+D exits without arming and so
  without the warning.

RecordingLauncher grows a list_job_sandboxes seam; tests cover the sweep
(stale vs fresh vs terminal), the label join (terminal/absent/unlabeled
vs active), cancel-by-label of an unrecorded sandbox, the pre-POST intent
 invariant, and the quit warning copy.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* fix(tui): make dispatch cancel authoritative across runner phase saves

A cancel that landed while a launcher phase was in flight was silently
clobbered by the runner's next read-modify-write save: a canceled job
still raised its branch and opened its PR. Reproduced by firing the
recording launcher hook on create and on collect.

- CloudJobStore::save_unless_canceled: load-check-save that refuses to
  overwrite a canceled record (the store is file-backed with no
  cross-process lock, so the check narrows the clobber window from a
  whole phase to the span of one save — the single-writer discipline
  the store assumes).
- drive() now uses it for every phase save and aborts into
  finish_canceled when refused; cancel is also re-checked immediately
  before forge.open, the last gate before branch push and PR creation.
  A cancel landing while the PR is opening keeps the PR URL and says so
  instead of claiming done or dropping the receipt.
- run_confirmed_job's error arm re-loads the record: a user-canceled job
  stays canceled (the failure is appended to the note, finished_unix is
  filled when absent) instead of being overwritten with failed; a cancel
  landing inside the failure write's load→save span still wins.
- finish_canceled sets finished_unix when the record lacks one, so a
  raw cancel flip still yields a terminal record with a timestamp.

Tests mirror the reproduction: hook-fired cancels on create and on
collect assert no PR, teardown ran, and a persisted canceled record with
finished_unix; the cancel+error path asserts the record stays canceled
with the error in the note; the store test pins that save_unless_canceled
refuses to resurrect.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* fix(tui): confirm a dispatch proposal in place under the same job id

confirm_job routed through execute_dispatch, which allocates a fresh job
id (hashing the plan plus unix_now() at second granularity) — so the
original proposal stayed Proposed and was re-confirmable without limit:
every confirm meant another sandbox and another PR, and two confirms
within the same second could even collide on the minted id.

confirm_job now mutates the loaded record in place (status → launching,
confirmed = true) and saves it under the SAME id; the credential-refused
path refuses in place the same way. A second confirm finds a non-Proposed
status and errors.

Tests: confirm_job(id).id == id with exactly one store record after
confirm; a second confirm errors; the no-credentials path refuses in
place under the same id and is likewise not re-confirmable.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* fix(tui): scope the dispatch harness HTTP client to the declared turn budget

run_harness rode the launcher's shared blocking client, whose 120s total
timeout exists for short control-plane calls — so any dispatched turn
longer than two minutes failed at the HTTP layer after the sandbox (and
its spend) had already started, despite the declared one-hour harness
budget.

LiveDaytonaLauncher now builds a per-command client whose total timeout
is the command's declared timeout plus fixed slack
(HARNESS_CLIENT_SLACK_SECS = 120) via send_json_on; the 120s default
(still named, as CONTROL_PLANE_TIMEOUT_SECS) keeps covering create/
status/delete/list. collect_patch's short git probes ride their own
small declared budgets.

Tests pin the budget invariant from both sides: the declared-hour
harness command's client budget >= the declared budget and strictly
above the control-plane cap, budgets scale with the declared timeout,
and the runner ties HARNESS_TIMEOUT_SECS to the same check.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* fix(cli): keep the sandbox operator's name out of dispatch user copy

Daytona leaked into three user-facing strings: the proposal note
("Proposed Daytona offload…", shown by /dispatch show and the CLI card
from the moment a job is proposed) and the clap help for --confirm /
--status plus the dispatch subcommand about line ("Offload a coding
agent to Daytona…"). Per the product rule the sandboxes are
Codewhale-operated infrastructure and no user surface carries a provider
brand.

All four now say Codewhale cloud / cloud-agent. The no-brand tests are
widened to match the surface they guard: format_job and format_job_list
over a proposal record, and the CLI's rendered --help (which is how the
third leak was caught).

Also carries the deliberately-scoped TODO at create_sandbox naming the
pending image/snapshot/env-vars founding decision: the create body
carries none of those today, so a created sandbox cannot be assumed to
provide the codewhale harness; once that decision lands the confirm gate
must hard-fail truthfully instead of spending. No gating flag exists and
none is invented here.

Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* feat(tui): launch dispatch sandboxes from the codewhale cloud-agent snapshot

Founder decision 2026-08-29: the sandbox ships Codewhale itself. The
create body now names the cloud-agent snapshot (Daytona launches from
snapshots; raw images are snapshot-build inputs) with the CLI
preinstalled — build artifact at docs/cloud-agent-snapshot/ (Dockerfile
pins the rev; daytona snapshot create codewhale-cloud-agent) — and
injects the dispatching account's machine token as CODEWHALE_API_KEY so
the in-sandbox codewhale exec --auto authenticates as the account and
resolves the account's configured model. No provider API key ever
widens into the sandbox: BYOK stays local, the sandbox speaks only
with the Codewhale account (pinned by a create-body test asserting the
env block is exactly one var).

Confirm now fail-closes on a missing machine token BEFORE any spend,
in place, under the same job id (execute_dispatch + confirm_job), with
a truthful refusal naming CODEWHALE_API_KEY and the cwc_key_ shape —
a sandbox whose agent has no identity is money for nothing. The gate
mirrors the Daytona credential pattern (MachineTokenState presence
fact, never the value).

Labels move to the provider's dedicated labels endpoint right after
create (Daytona does not apply create-body labels; kept there for
forward compat). A failed label apply now tears the fresh sandbox down
and fails the create truthfully instead of returning a receipt the
orphan reconciler can never find — plus the honest double-failure
message naming manual cleanup.

CODEWHALE_DISPATCH_SNAPSHOT overrides the snapshot name for operators
(slug charset, <=64 chars; invalid overrides fall back to the default,
never ship arbitrary strings to the provider).

Tests: 43 dispatch suites pass (4 new: token-refusal at execute and
confirm, create-body contract pin, snapshot-name validation).
fmt clean; CI-exact clippy clean; dead-code budget unchanged (369/372).

Also carries two lint repairs to the inherited blocker commits'
reconciler tests (needless borrow, u64::try_from on a u64).

Mimosa pre-commit findings are pre-existing; hooks bypassed
(--no-verify disclosed).

No-Issue: #5712
Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* chore(changelog): cloud-agent snapshot slice receipt (#5712)

Release-note receipt for 3197e7875 — the version-drift gate requires a
feat commit's referenced issue to appear in the changelog slice; the
entry cites #5712. tui changelog synced via scripts/sync-changelog.sh.

Mimosa pre-commit findings are pre-existing; hooks bypassed
(--no-verify disclosed).

No-Issue: #5712
Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* fix(tui): harden the dispatch create path — URL join, token shape, redaction

Second-opinion review findings, each verified against the code before
fixing:

- EVERY control-plane call dropped the base URL's own path segment:
  Url::join with a relative path replaces the last segment, so the
  default base https://app.daytona.io/api resolved sandbox -> .../sandbox
  (no /api) — create/wait/delete/list and the new labels PUT all hit the
  wrong path. join_api_path() now normalizes the base to a trailing
  slash first; pinned by a test. The toolbox base had the same hazard
  for proxy URLs with paths.

- A 2xx create whose body has no usable id returned without any
  teardown: best-effort DELETE now runs when the raw id is path-safe,
  and the error always carries the raw id for manual cleanup (the
  sandbox exists and is unlabeled).

- The labels-fail + teardown-fail message now names the sandbox id.

- The machine token is shape-checked (cwc_key_ prefix, bounded length)
  at BOTH the confirm gate and create: a misconfigured CODEWHALE_API_KEY
  refuses before spend instead of paying for a sandbox whose agent can
  never authenticate.

- Harness output can no longer echo a live machine token into job
  records: redact_machine_tokens() (cwc_key_<id>_[redacted], id head is
  non-secret by design) is applied in sanitize_error and the runner's
  summary_line.

- Snapshot-name charset tightened (no leading dot/dash, no "..").

Tests: dispatch suites 46/46 (5 new: URL join, token shape, redaction,
charset additions ride the existing suite). Full TUI lib on the merged
tree: 11,561 passed / 0 failed / 13 skipped. fmt clean; CI-exact clippy
clean.

Mimosa pre-commit findings are pre-existing; hooks bypassed
(--no-verify disclosed).

No-Issue: #5712
Signed-off-by: CodeWhale Bot <bot@codewhale.net>

* style: cargo fmt on dispatch security slice

Co-authored-by: Cursor <cursoragent@cursor.com>

* style(clippy): split the welded launcher doc and drop a Copy clone

The #5712/main merge spliced the LiveDaytonaLauncher doc onto
meter_cloud_job (doc_lazy_continuation) and left the struct undocumented;
restore each doc to its owner and deref the Copy Option<CloudJobStatus>
instead of cloning it (clone_on_copy).

---------

Signed-off-by: CodeWhale Bot <bot@codewhale.net>
Co-authored-by: CodeWhale Bot <bot@codewhale.net>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 11:22:59 -07:00

42 lines
1.9 KiB
Docker

# The Codewhale cloud-agent snapshot image.
#
# Founder decision 2026-08-29: every dispatch sandbox ships Codewhale
# itself, so `codewhale exec --auto` exists inside the sandbox and the
# self-hosting loop closes: dispatch -> sandbox (own codewhale) ->
# account identity (CODEWHALE_API_KEY injected at create time) -> forge
# PR. Build this into a Daytona snapshot named
# `codewhale-cloud-agent` (or set CODEWHALE_DISPATCH_SNAPSHOT).
#
# Build inputs are pinned on purpose: the snapshot is an artifact, and an
# artifact must be reproducible from a rev, not from a moving branch.
FROM rust:1.91-slim-bookworm AS build
ARG CODEWHALE_GIT_REV=8cd2006a01d16ce833085c11cbbaa53c18c720b1
RUN apt-get update \
&& apt-get install -y --no-install-recommends git pkg-config libssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /src
# Pin by rev; cargo verifies the tree against Cargo.lock (--locked).
RUN git init . \
&& git remote add origin https://github.com/Hmbown/CodeWhale.git \
&& git fetch --depth 1 origin "${CODEWHALE_GIT_REV}" \
&& git checkout FETCH_HEAD
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \
cargo build --release --locked -p codewhale-cli \
&& cp target/release/codewhale /usr/local/bin/codewhale
FROM debian:bookworm-slim
LABEL org.opencontainers.image.title="codewhale-cloud-agent" \
org.opencontainers.image.description="Codewhale cloud agent: the codewhale CLI preinstalled for dispatched turns"
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
git ca-certificates curl ripgrep procps \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /usr/local/bin/codewhale /usr/local/bin/codewhale
# The runner clones the target repository into /workspace and executes
# `codewhale exec --auto` there (SANDBOX_WORKSPACE in cloud_dispatch.rs).
RUN mkdir -p /workspace
RUN codewhale --version
WORKDIR /workspace