Skip to content

Team Cloud (Round 6 hardening in progress — do not merge)#929

Draft
dohooo wants to merge 208 commits into
mainfrom
dohooo/team-cloud-sandbox
Draft

Team Cloud (Round 6 hardening in progress — do not merge)#929
dohooo wants to merge 208 commits into
mainfrom
dohooo/team-cloud-sandbox

Conversation

@dohooo

@dohooo dohooo commented Jul 10, 2026

Copy link
Copy Markdown
Owner

Draft PR opened solely to run CI on dohooo/team-cloud-sandbox — Round 6 class 0 (gate foundation) acceptance requires the first real CI run of the new cloud-test job and ci-green aggregate gate.

Not a merge proposal. Round 6 classes 1–4 are still pending on this branch; merge timing is decided explicitly by the repo owner.

Class 0 changes under test:

  • cloud suite wired into root test/typecheck + CI (cloud-test job, ubuntu)
  • companion-port-inflight.test.ts unhandled-rejection exit-1 fix
  • red-light hardening: workflow-wide shell: bash (pipefail), terminal ci-green aggregate gate
  • setup-cloud composite action (cloud has its own lockfile)

dohooo and others added 30 commits June 9, 2026 10:57
Add claude-fable-5[1m] to the Claude Code model catalogs (Rust +
sidecar), placed above Opus 4.8. Fable 5 burns limits ~2x faster than
Opus, so useEnsureDefaultModel now pins the app default to the `default`
(Opus) entry instead of blindly taking the first listed option.

Bump @anthropic-ai/claude-code 2.1.154 -> 2.1.170 and
@anthropic-ai/claude-agent-sdk 0.3.154 -> 0.3.170 (the bundled 2.1.154
CLI predates Fable 5), with new SHA256 pins for both arch tarballs.

No fast mode on Fable 5 (Opus 4.6+ only); effort levels low-max
including xhigh, verified against the 2.1.170 CLI metadata.
Single-user cloud workspace: the desktop app can point its backend at a
remote Helmor companion running headless inside a Cloudflare Sandbox,
with zero UI changes (a transport-only mode switch).

- PR1 bootstrap::init_core(): extract the deterministic startup core
  (data dir, logging, DB schema, pools) shared by GUI + serve.
- PR2 helmor serve: build_app(mode) shares one Tauri builder; a
  windowless Wry host wires the functional trio + companion bound to
  0.0.0.0 with an injected capability token. Adds a serve smoke test.
- PR3 vendor Linux: vendor-platform.ts gains a linux target; the serve
  image stages only codex + gh.
- PR4 Dockerfile: 4-stage image FROM cloudflare/sandbox with webkit/gtk
  + xvfb; start-serve.sh runs an independent Xvfb daemon. ENTRYPOINT is
  left to the CF base.
- PR5 cloud/: stateless thin Worker proxy (ensureServe -> startProcess
  -> containerFetch / wsConnect), @cloudflare/sandbox 0.12.1.
- PR6 workflow closure: boot.sh reuses CLI verbs; cloud_autopush commits
  + pushes after each agent turn (env-gated; desktop is a hard no-op).
- PR7 desktop entry: team-mode.ts + ipc.ts runtime transport; a Settings
  Team panel and a sidebar Local<->Team switch.

Desktop behavior is unchanged: every path is either a pure refactor or
env-gated. The full Rust and frontend test suites stay green.
…hooo/initial-greeting

# Conflicts:
#	sidecar/bun.lock
#	sidecar/package.json
#	sidecar/scripts/vendor-platform.ts
Action Item A surfaced 5 real bugs (the Dockerfile/linux-vendor path had
never been built before):
- Dockerfile: declare SANDBOX_VERSION in the global scope so the runtime
  FROM can interpolate it (was scoped after earlier stages -> empty ref).
- Dockerfile: pin oven/bun to 1.3.2 (matches packageManager + bun.lock;
  the floating :1.3 tag pulled 1.3.14 and broke --frozen-lockfile).
- Dockerfile: frontend stage builds the (arch-independent) dist/ on
  $BUILDPLATFORM so vite's rolldown native module doesn't SIGILL under QEMU.
- stage-vendor: gate mimo with !IS_LINUX (desktop-only; was crashing the
  Linux vendor stage on an undefined mimoPkg).
- vendor-platform: fill LINUX_TARGETS mimoPkg/mimoNpmSuffix (type hole) + test.

CI: native amd64 build avoids the QEMU rustc segfault that blocks local
cross-builds on Apple Silicon. Manual-dispatch wrangler deploy.
First real CF cold start 503'd (companion didn't bind within the 60s poll).
- wrangler.toml: instance_type standard(½ vCPU) -> standard-3 (2 vCPU/8GiB);
  WebKitGTK + Xvfb init needs real CPU. Also clears the standard->standard-1
  rename warning.
- index.ts: health poll 60s -> 120s for a heavy first-boot init.
- start-serve.sh: skip if a serve host is already running (the Worker calls
  startProcess on every not-ready request; a 2nd serve fought for the port).
Root cause of the Stage 3 cold-start 503: helmor serve started, Xvfb was
up, env was correct (HOME=/root, port 8080) — but no data dir, no logs, and
nothing listening on 8080. The Tauri `setup` hook (which runs init_core +
serve::setup, the companion bind) only fires once the event loop runs, and
serve() merely parked the main thread, so setup never executed: the process
was alive but completely uninitialised.

Replace the park loop with app.run() (matching the desktop path) + an
ExitRequested prevent_exit so the windowless app stays alive. The serve smoke
test only exercised start_serve() directly, never the full serve() path, so
it missed this. The D-α 'no app.run() needed' assumption was wrong.
…ntainer

Verified end-to-end in the live container BEFORE wiring (temp /__diag probe):
vendored codex read a dropped ~/.codex/auth.json, `codex login status` =
'Logged in using ChatGPT', container egressed, real turn completed (gpt-5.5,
HELLO_FROM_CLOUD). Subscription path works headless — no API key.

- index.ts: pass CODEX_AUTH_JSON through startProcess env to the container.
- start-serve.sh: write it to $CODEX_HOME/auth.json (0600) before serve starts,
  so Helmor's sidecar-spawned codex authenticates as the user's subscription.

Phase-1 will swap the whole-credential passthrough for a control-plane token
broker (mint per-turn access_token via auth.openai.com/oauth/token public
client; inject a ChatgptAuthTokens auth.json), keeping the rotating refresh
token in the durable control plane, never the ephemeral container.
…e 2a)

Nullable session_messages.author_id column (CREATE TABLE + idempotent
migration) and a ThreadMessageLike.author: Option<MessageAuthor> field
(camelCase, skip-when-None) with TS mirror. Seam shape only; population
is deferred to the team registry phase. Existing pipeline snapshots are
unchanged (author omitted when None). Adds migration + serialization tests.
Show a small author avatar on user messages when ThreadMessageLike.author
is present (the team-room case). Single-user messages carry no author, so
this is a no-op for the local path. Uses the cached avatar component with
initials fallback. Adds render tests.
…3 P3.1)

Carry a team member's SERVER-DERIVED author id from the companion's trusted
X-Helmor-Member-Id header onto their persisted user-prompt row, surfacing it
as ThreadMessageLike.author. The companion overwrites request.author_id from
the header after deserialization (never client-asserted; resolve_request helper
+ direct tests). Sandbox SQLite stores only author_id; display fields are
hydrated frontend-side. Desktop/local IPC sets no author_id, so author stays
None and the single-user wire shape (and all pipeline snapshots) is unchanged.
Adds non-null author E2E, wire camelCase, and trusted-header-over-body tests.
… (Phase 3 P3.2/P3.3)

D1 control-plane registry (members/invites/teams/workspaces) behind /team/*
JSON routes, plus the proxy-path member derivation: the invite token is the
member's bearer; the Worker maps it to member_id, strips any client-supplied
X-Helmor-Member-Id, injects the derived id, and swaps in the shared companion
token for the container hop (never client-asserted). Verified on real CF:
invite round-trip (bootstrap/invite/accept/members) + gating (garbage->401,
shared->200, accepted-invite->200). Additive — the Phase 0 proxy is untouched.
- BLOCKER: an unauthenticated request (no Authorization) no longer gets the
  shared companion token injected — the Worker only swaps in the shared token
  for an authenticated caller (shared token or valid invite), so the companion
  still gates /rpc (401) while /v1/health stays public.
- Strip any client-supplied X-Helmor-Member-Id at the very top of fetch()
  (before proxyToSandbox) so the never-client-asserted invariant holds on every
  path, including the SDK preview path — not just the derived proxy hop.
- acceptInvite: fail-closed on an unparseable expires_at, and reject re-accept
  of an already-claimed invite with a different id (409) to block seat takeover.

Re-verified on real CF: no-auth->401, health->200, garbage->401, shared->200,
accepted-invite->200, re-accept-different-id->409.
…(Phase 3 P3.4)

Frontend for the cloud team registry: a ?invite=<token> deep link (or pasted
link) raises a confirm dialog, resolves the user's GitHub identity, POSTs
/team/accept, saves team config + activates team mode. The sidebar team
section renders real members (avatars) + workspaces from /team/members and
/team/workspaces (React Query). Worker HTTP client lives in src/lib/team-api.ts
(distinct from the Tauri-invoke bridge). Adds the load-bearing GitHub numeric
id to ForgeAccount (members.id = GitHub numeric id). All team UI is gated behind
isTeamModeActive() so single-user mode is unchanged.

Verified: typecheck, biome, vitest (1668 pass), cargo test forge (208), clippy.
Persist the container's Helmor data dir to R2 so chat/session history survives
a sandbox sleep (CF wipes the disk on sleep). Per finished agent turn the serve
process folds the SQLite WAL into helmor.db (synchronous wal_checkpoint(TRUNCATE),
env-gated on HELMOR_CLOUD_AUTOPUSH); after the turn stream closes the Worker
snapshots /home/helmor to R2 in localBucket mode (excluding the git worktrees,
which are restored via autopush) and stores the DirectoryBackup handle in D1
teams.backup_handle. On cold start ensureServe restores from that handle before
launching serve. Relocate the data dir to /home/helmor (an allowed backup root)
via HELMOR_DATA_DIR in both start-serve.sh and boot.sh so serve and boot share
one DB. Zero pipeline-snapshot drift; all team UI/proxy paths unchanged.
…hase 5)

When the team-mode client's stream to the Worker drops (sandbox slept or a
deploy disconnected it), reconnect with full-jitter exponential backoff (1s base,
30s cap, never give up — a cold start legitimately takes ~120s) instead of the
old fixed 2s retry, surface a 'reconnecting' connection state, and show a thin
team-mode-only banner. On reconnect, re-fetch active streams so the watcher
re-attaches to the R2-restored session. Frontend-only; single-user path
unchanged.
Per-member Durable Object holds the user's ChatGPT OAuth refresh_token encrypted at rest (AES-GCM) and mints short-lived, empty-RT ChatgptAuthTokens auth.json at container cold-start. Never an API key — subscription only. The refresh_token never enters the container, D1, or the webview.

- cloud: CodexIdentity DO (serial-locked rotation, persist-new-RT-before-use crash-safe, ~10-day cache reuse), PUT/GET /team/cloud-identity (member_id derived solely from the team bearer), ensureServe cold-start mint hook, teams.cloud_identity_member_id, wrangler DO migration v2.
- desktop: authorize_cloud_codex_identity command (throwaway 0700 CODEX_HOME codex login, RAII-shredded, Zeroizing RT) + cloud-identity settings panel (team-mode gated; single-user byte-unchanged).
- tests: 19 workerd DO tests (real AES-GCM + storage) + 9 Rust unit tests; reviewed (auth boundary + RT secrecy PASS). Deployed + live auth-boundary verified (all 401).
- Instant team/local switch with no full-page reload; in-app "Connecting" banner while a cold backend wakes (IPC transport hot-swap + generation guard).

- Persistent team-mode add-repo (BUG-3): the Worker clones via the Sandbox SDK gitCheckout into the persistent /workspace, then registers via the existing add_repository_from_local_path RPC.

- Team-mode add-repo UI (BUG-2): hide local-only Open project/Browse; default the clone path to /workspace.

- Route authorize_cloud_codex_identity locally even in team mode so the broker login runs on the desktop (BUG-4).

- Stage claude-code on linux for the cloud image (BUG-1).

- Worker CORS for the desktop webview; exclude cloud/ from the frontend vitest run; biome-ignore .agent-contexts.
codex 0.139's AuthMode enum renames this variant to camelCase "chatgptAuthTokens". The broker wrote lowercase "chatgptauthtokens", so the whole auth.json failed serde parsing -> auth=None -> codex fell back to api.openai.com with no bearer (401 "Missing bearer"). Corrected to camelCase. Verified live: cloud Codex turns now authenticate via the subscription broker and respond (incl. web-search tools).

Also add /admin/restart-sandbox (member/admin-gated, calls sandbox.stop()) so the next request cold-starts and re-mints the auth.json -- applies a re-authorization or broker change without waiting for an idle sleep.
Cloud Claude agents now authenticate via the user's Claude subscription (never an API key), mirroring the Codex broker. A per-team ClaudeIdentity Durable Object holds the `claude setup-token` OAuth token (sk-ant-oat01..., a ~1-year inference-only token) encrypted at rest (AES-GCM via the shared BROKER_ENC_KEY); the Worker injects it as the CLAUDE_CODE_OAUTH_TOKEN env var on serve cold-start (no file write; the token is consumed verbatim, no auth_mode field to mis-case).

Desktop authorize_cloud_claude_identity captures the token by running `claude setup-token` in a PTY (it is a raw-mode Ink TTY app, so Stdio::piped fails), ANSI-stripping + loose sk-ant- matching the output (longest match, wide PTY so it never wraps), then PUTs it to /team/claude-identity. Held in Zeroizing; never logged, returned to the frontend, or persisted in plaintext. Timeout path kills the process tree before wait().

start-serve.sh unsets ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN (they outrank the OAuth token non-interactively) + seeds ~/.claude.json hasCompletedOnboarding; HOME pinned so the seed dir matches the SDK child. Settings -> Cloud Run Identity gains a Claude section (status + Authorize button). Verified doc-first against claude-code 2.1.173.

Also corrects the codex auth.json test assertion to camelCase chatgptAuthTokens (regression guard for the earlier casing fix).
…opback)

Replace the `claude setup-token` PTY/manual-paste capture with an in-process PKCE OAuth (Rust hyper loopback + token exchange), exposed as the single `authorize_cloud_claude_identity` command. UX matches Codex: browser sign-in + approve, no copy-paste, no HTTP 431.

Endpoints/params verified against decompiled claude-code 2.1.174; the issued token is the long-lived (~1yr) inference-only CLAUDE_CODE_OAUTH_TOKEN, so cloud/** is unchanged and no refresh is needed. Secrets held in Zeroizing — never logged, returned over IPC, or persisted in plaintext. client_id overridable via CLAUDE_CODE_OAUTH_CLIENT_ID; 0 new packages in Cargo.lock.
…)authorize

IS_SANDBOX=1: claude-code refuses --dangerously-skip-permissions as root (binary guard getuid()===0 && IS_SANDBOX!=="1" && !CLAUDE_CODE_BUBBLEWRAP). The cloud Agent SDK runs claude-code as root with bypassPermissions, so every claude cloud turn exited with code 1. Inject IS_SANDBOX=1 into the serve startProcess env — the CF container is an isolated, ephemeral sandbox. Live-verified: a cloud Opus turn now succeeds.

auto-restart: a changed cloud identity (PUT /team/claude-identity or /team/codex-identity) now stops the sandbox so the next request cold-starts and injects the new credential. start-serve.sh is idempotent (bails if a serve is already running), so a serve started BEFORE authorization never picked up the new token — previously this required a manual restart.
…loud-error CTA; CI/changeset hygiene

Preview A — make the single-team product usable from the app: TeamAdminPanel (Settings -> Team) to create a team + mint/copy an invite link (admin-token-gated, mirrors the cloud-identity panel); populate the D1 workspaces mirror on team clone so the sidebar shared-workspaces list fills and rows open the workspace; composer cloud-error recovery CTA (auth/billing failures surface a Re-authorize / View Team settings action routing to Settings -> Team). All team UI gated on isTeamModeActive() so single-user is byte-identical.

Merge hygiene — narrow the cloud-sandbox image CI workflow to workflow_dispatch-only (drop the feature-branch push trigger before merging to main); add a changeset for the Codex + Claude subscription brokers.
dohooo added 30 commits July 10, 2026 20:27
start-serve.sh already put vendor/glab on PATH and wired its
credential helper, but stage-vendor.ts explicitly skipped glab on
Linux — so GitLab MR actions in team mode deterministically failed on
a missing binary. glab now stages on every platform:

- glabArchivePlan gains a linux branch (same tar.gz layout nesting
  bin/glab as darwin — verified against the real artifact).
- GLAB_SHA256 restructured per-OS; Linux arm64/amd64 SHAs pinned from
  upstream checksums.txt and re-verified by hand-hashing the arm64
  tarball.

Takes effect with the SAME team-image bump as P1-2a/8a/2b (image
content change only — no container Rust). Tests written red-first
(linux plan assertions).
…ices (P1-5a)

A failed post-turn auto-push was swallowed as tracing::warn only — with the
CF sandbox disk wiped on idle destroy, a push failure (creator token revoked,
network down, rematerialize edge) silently lost the turn's code. The detached
autopush thread now records a role=system / type:"error" row in
session_messages (existing content shape — renders through the adapter's
error arm, no pipeline change) and broadcasts SessionTurnPersisted so live
clients see the notice before the workspace sleeps.

Failure detail is git stderr-only (handle_git_failure) and truncated; the
test pins that no auth material (argv http.extraheader token) can reach the
persisted notice.

Face: container Rust (src-tauri) — requires a team image bump at rollout
before it takes effect in the cloud sandbox. No desktop behavior change
(HELMOR_CLOUD_AUTOPUSH env gate remains off there).
The idle (pre-sleep) backup carried its own inline exclude list whose comment
claimed it was 'IDENTICAL to backupAndStore's' — it had silently drifted:
R3-D added .codex/.tmp (Codex's 76MB+ plugin-marketplace cache) to
BACKUP_EXCLUDES but not to the inline copy. Any Codex team that slept via the
idle path re-inflated its archive (~48MB) and re-opened the R3-D restore-OOM
wake wedge.

The idle path now builds its options via idleBackupOptions(), which
references the shared BACKUP_EXCLUDES constant (a test pins the reference by
identity, so a copy can't drift again), and gains the warnIfBackupOversized
tripwire the post-turn path already had. Comment rewritten to describe the
real mechanism; the idle-archive autopsy (.claude/.codex present,
.codex/.tmp absent) stays a rollout acceptance gate.

Face: Worker JS only (wrangler deploy) — no container image impact.
…(P1-3a)

A team idle past the backup TTL (3 days) has its archive deleted, but the D1
backup_handle lived forever — every wake then failed restore and R3-D's
loud-503 (correct for OOM) wedged the team permanently on data that was
already gone at expiry.

ensureServe now runs a DETERMINISTIC pre-restore check, backupGone(): it
reads the SDK's own R2 metadata (backups/<id>/meta.json) and applies the
exact createdAt+ttl+60s-buffer arithmetic restoreBackup itself uses. Proven
gone (meta deleted or past TTL) → clear the stale D1 handle + cold-start
empty like a brand-new team (info log, no degrade). Everything else is
untouched: restore FAILURES are never interpreted as expiry — OOM, timeout,
corruption, and R2 pre-check hiccups all still hit the loud typed 503, so a
dead DO can't hide behind a silent empty start (R3-D regression-guarded in
tests). Deliberately NOT classifying on error.name: the SDK's typed
BackupExpiredError doesn't reliably survive the DO RPC boundary.

Face: Worker JS only (wrangler deploy) — no container image impact.
…e-authorize (P1-4a)

Two sub-fixes, both Worker-only (no container Rust, no image bump):

Sub-problem 1 — forge (re-)authorize never re-injected: injectForgeMembers
ran ONLY at cold start, so PUT /team/forge-identity left a warm container's
members file stale until a manual destroy (live-reproduced in the class2
rollout as 'could not read Username' on a private clone). The in-container
member_creds loader already hot-reloads the file via mtime, so the fix is a
guarded live rewrite: putForgeIdentity now fires reinjectForgeMembersIfServing
(healthOk-gated, best-effort, no restart). Forge collection/injection moved to
cloud/src/forge-creds.ts so both injection sites share one implementation
without a team.ts <-> index.ts import cycle.

Sub-problem 2 — cloud-identity re-authorize used stop(): SIGTERM that
helmor serve ignores, so the VM stayed up caching the old startProcess-env
credential and nothing ever re-minted (the docstring's 'the NEXT request
cold-starts' was a lying comment; rewritten to the real mechanism). Replaced
with a guarded backup-then-DESTROY: a new Sandbox DO method
backupThenDestroyForReauth snapshots /home/helmor first (same budget/handle
semantics as the idle path — NEVER a bare destroy, which would roll back to
the last idle snapshot), then SIGKILLs so the next request truly cold-starts.
healthOk-gated: a scaled-to-zero container is skipped entirely (no serve to
kill, no real disk to snapshot). /admin/restart-sandbox switched to the same
path; /admin/destroy-sandbox intentionally unchanged (deliberate reset).

Supporting moves: containerFetchThroughPort/withTimeout/healthOk extracted to
cloud/src/container-net.ts so team.ts can probe liveness without importing
index.ts. Identity PUT side-effects are injectable store deps so the wiring
is pinned by tests (forge-reinject.test.ts, reauth-restart.test.ts —
red-verified against the neutered fixes).
…1-4b)

The F-B lockout (live-verified in class1 run #2): provision rotated
HELMOR_COMPANION_TOKEN via 'secret put' BEFORE deploy/verify, and the desktop
only receives the token on the terminal 'deployed' line — so any mid-run
failure stranded the Worker on a NEW token the desktop never saw
(desktop/Worker/container three-way mismatch, existing teams only).

Fix (architect-approved failure matrix in class4-design.md par.2): reuse-or-
generate. On a RE-provision the frontend passes the desktop's stored admin
token (getTeamAdminToken) -> deploy_team_cloud forwards it to the script as
HELMOR_EXISTING_COMPANION_TOKEN (env only, never logged) -> provision reuses
it, so 'secret put' writes the value the desktop ALREADY holds. After that
point every failure row has desktop == Worker; a pre-existing F-B mismatch
even self-heals (the put forces the Worker back to the desktop's value).

The step ORDER is deliberately unchanged: rotate-then-put stays for FRESH
teams (nothing held -> nothing to lock out; 'wrangler secret put' before the
first deploy also auto-creates the draft Worker, a load-bearing behavior both
rollouts exercised). F-C's 401 propagation grace and destroy-warm keep their
semantics — on the reuse path the propagation window physically disappears
(same value), on the fresh path they still do their verified job. Forced
rotation remains reachable via Leave team -> Create team (clears the token).

resolveAdminToken extracted pure (cloud/scripts/admin-token.ts) with unit
tests; the frontend wiring is pinned by team-create-flow tests (red-verified
against the unwired call).
R3-B fixed the Rust half: a failed post_room_chat_message travels IN-BAND as
a terminal {kind:"error"} event (stream.rs run_and_surface) because on the
companion transport the 200 x-ndjson headers have already left. But the
sender's frontend callback was a no-op — the error was swallowed, the
optimistic bubble stayed in the thread cache, and the user believed a message
was sent that never persisted (P1-6a, the frontend half R3-B left open).

The room-chat callback now routes {kind:"error"} through the same failure
path as a desktop-transport invoke rejection (shared failRoomChat helper):
roll back the optimistic row, restore the draft via composerRestore, and set
the composer send error. Test red-verified against the swallowed callback.
…(P1-6b)

The WP3 race R2-E's correction A reopened (the architect's own regression):
on_ui_mutation spawned the Stage-C broadcast and the Stage-B D1 mirror
CONCURRENTLY, and the broadcast (one POST) almost always beat the mirror's
read-DB+PUT. Correction A had just made the displayed-session refetch ACTIVE
on roomChatMessageAppended, so receivers read a D1 that didn't have the row
yet — a teammate's message silently missed realtime delivery until the next
event or a manual refetch (D1 eventually consistent, not permanent loss).

Fix (architect-ruled Option B, entanglement verdict reported first): a local
reorder in on_ui_mutation only — an event with a Stage-B target now rides the
SAME task as its mirror and broadcasts after the mirror attempt completes, so
on every success path the row is in D1 by fan-out time. Non-mirrored events
(presence, git, workspace list) keep the immediate broadcast path. No contact
with F-4/keepalive (both legs are plain outbound HTTP the Worker handles
before ensureServe); cursors/throttles/backfill untouched. Honest residual:
a FAILED best-effort mirror still broadcasts — that rare case falls back to
the next event / startup backfill, and the frontend comment now says so (the
'Stage B wrote the row before this event fanned out' comment was a lie under
the concurrent spawns; both it and the receiver-side rationale rewritten to
the real mechanism).

defers_broadcast classification pinned by unit tests (red-verified against a
constant-false stub); the intra-task order is a single sequential await.
NOTE image accounting: this touches container Rust (team_sync.rs runs in the
serve host) — it rides the SAME image bump class3's P1-5a already forces for
the merged rollout, so no additional bump.
P1-7a — the asleep micro-write queue (companion-asleep.ts) is a module-level
singleton the transport-switch effect never cleared: Team A's queued writes
(read marks, pins, drafts) replayed against Team B on the first successful
post-switch invoke. Fixes: resetCompanionAsleep() (queue + stale asleep flag;
listeners kept — they belong to mounted components) wired into the switch
effect next to resetSubmitQueue, plus an in-flight guard — flushMicroWrites
snapshots the transport generation and stops its replay loop the moment the
generation moves.

P1-7b — old async firing into the new backend: queued submits are now stamped
with the transport generation at enqueue; popNext DROPS entries from a
previous generation (covers a drain racing the switch-effect reset), and
handleComposerSubmit re-checks the generation after its pre-send await
(loadRepoPreferences) so a submit that spanned an in-place switch bails
BEFORE any side effect (no title seed, no optimistic row, no send).

Per the architect ruling, the queue-clearing draft loss stays a documented
trade-off (the module docblock already declares queue contents as in-memory
short-lived intent — a transport switch is semantically an app restart); no
fake cross-backend composerRestore. P3 scope: guards + tests, no
re-architecture. All four guards red-verified against neutered stubs.
…l-suite exit-1 flake (round6 hygiene)

Radix hover-card's handleOpen (fired by both trigger pointerenter and
focus, i.e. twice per click) overwrites its pending open-timer ref
without clearing it, orphaning a timer that survives unmount and — via
the controlled useControllableState path — synchronously invokes our
handleOpenChange up to openDelay later. After vitest environment
teardown that callback's document access threw an unhandled
"document is not defined" (workspace-hover-card.tsx:594), failing the
whole frontend run with all tests passing. Upstream 1.1.19 still has
the bug; tooltip/popover are unaffected.

Invalidate the callback on unmount and pin the behavior with a
fake-timer regression test that reproduces the orphaned timer
deterministically.
…ed backup exclude (P1-3b / R2-F4a Codex half)

The class3+4 rollout autopsy proved the container's older squashfs-tools
drops the ENTIRE parent tree for a nested exclude pattern: the
'.codex/.tmp' entry in BACKUP_EXCLUDES was silently discarding the whole
.codex session-thread tree from BOTH backup paths (idle AND post-turn),
so Codex resume came back empty after every sleep — the R2-F4a failure
mode, on the Codex half this time. The R3-D autopsy had only asserted
.claude + archive size, never .codex, so this hid since R3-D.

Fix, per the architect-reviewed design (p13b-prune-design.md):
- BACKUP_EXCLUDES is top-level names only; a new test pins 'no / in any
  pattern' (red-verified against the nested entry).
- The R3-D OOM guard moves container-side: CodexTmpPruneGuard prunes
  $CODEX_HOME/.tmp with FINALLY semantics when the stream worker exits —
  success, persist failure, abort, or panic (red-verified against a
  no-op body; panic path pinned via catch_unwind). A local guard drops
  before the closure's captured event Channel, so the prune also lands
  before the EOF that triggers the Worker's post-stream backup.
- Accepted residual window (architect): R3-A's idle expiry ignores
  in-flight requests, so a LONG turn can be snapshotted before its
  prune — rare, non-silent (warnIfBackupOversized is the tripwire),
  self-healing on the next turn. Documented at both sites.

Touches container Rust -> requires the team image bump to :0.1.12 at
rollout (bump commit follows).
…rs (REG-R6-1)

P1-5a made autopush failures loud (session-level error notice), but a team
chat room's working dir is a plain directory — working_tree_clean's 'not a
git repository' error was classified as a real push failure, so every chat
turn stamped a red 'Cloud auto-push failed' notice. Not-a-repo is 'nothing
to push', not a failure: autopush() now early-returns Ok when no .git
(dir or worktree file) exists, with no warn and no notice row.

Red-first test pins the zero-notice contract for a plain directory; the
existing P1-5a tests (real repo + failing push still records a notice, clean
repo records nothing) are the regression rails and stay green.

Face: container Rust (src-tauri) — requires a team image bump at rollout.
No desktop behavior change (HELMOR_CLOUD_AUTOPUSH env gate off there).
…tion (OBS-R6-1)

The Codex DO's PutResult.changed is account hygiene only (account_id
switched), but putCloudIdentity reused it as the restart trigger — so a
same-account re-authorization (the common brick-recovery path) never
restarted the warm container, which kept serving with the old
startProcess-env auth (401 token_invalidated) until natural sleep.

PutResult grows an independent `stored` signal ("a token was actually
written by this call") and the restart condition becomes
stored || changed. Claude path unchanged (its changed already covers
re-auth); restartSandboxForReauth still guards the not-serving case.
…auth failure (DF-R6-C)

The Agent status card derived 'Valid for Xd' purely from the DO's stored
accessExp, which can't see a server-side invalidation — turns 401'd with
token_invalidated while the card kept claiming Valid, and nothing told
the user. v1 fix:

- turn errors with a known auth fingerprint (token_invalidated added to
  the classifier) flip a per-provider session-scoped 'authorization
  invalid' flag, invalidate the identity status query, and toast once
- the status card consumes the flag: 'Re-authorize needed' overrides the
  storage-derived lifetime; a successful re-authorization clears it
- honest copy: unverified stored state reads 'Stored · expires in Xd'
  instead of 'Valid for Xd'

No active probing (>v1, backlogged).
…oast dedupe (DF-R6-A)

Switching to a sleeping team fired the typed CompanionAsleepError through
several catch-and-toast sites (inspector sync, reveal-in-Finder, codex
goal, linked dirs), stacking up to 3 identical red toasts even though the
sidebar's gray cloud already communicates Sleeping.

New toastCaughtError helper: silent for typed companion-asleep errors,
and a message-stable sonner id so identical toasts replace instead of
stack. Applied at every toast.error site whose caught error can originate
from the companion transport.
… mode (DF-R6-D)

team-mode "Connect GitHub" showed "Unable to start login." because the forge / agent-login / keychain auth PTYs were classified WAKE/PASSIVE and routed to the cloud container: spawn hit the streamer's unknown-command path and the stop/write/resize verbs silently no-op'd in companion/rpc.rs. They run on THIS Mac (gh|glab auth login OAuth to localhost, claude|codex login driving the Mac browser + main window, security add-generic-password to the macOS keychain), so classify all three families LOCAL_ONLY.

Classification alone was insufficient: in team mode new Channel() yields a CompanionChannel the native Tauri command can't deserialize (no __TAURI_TO_IPC_KEY__), so spawn would still reject and the PTY never streams. Bridge any CompanionChannel in a LOCAL_ONLY command's args back to a real Tauri Channel on the native invoke path so events flow.

The workspace terminal / repo_script families are deliberately left WAKE — they run inside the container where the workspace lives; LOCAL_ONLY would misroute container work to the desktop. rpc.rs is untouched (dispatch arms stay; a frontend routing decision is not a container capability gap — same precedent as R2-D/R3-A detect_installed_editors).

Tests: assert the 13 auth-terminal commands are LOCAL_ONLY + a boundary guard that terminal/repo_script stay non-LOCAL; assert the team-mode CompanionChannel->TauriChannel bridge hands a real Channel to the native backend and forwards its events.
…account enumeration

In team mode the shared container runs all git/gh network ops under the injected member token (member_creds -> acting_github_token), but that token is never written to gh's hosts.yml. So gh auth status -- which backs list_forge_logins AND auto_bind_repo_account -- reported zero accounts, and the git panel showed "Connect GitHub / GitHub account is not connected" for a repo the container can actually push to.

Surface the injected github.com login(s) (the creator, plus any member with their own token) from member_creds into the GitHub account enumeration (list_github_logins). Now list_forge_logins is non-empty, auto_bind_repo_account can probe repo access with the injected token and bind, and the Connected badge reflects the identity the container acts under. Desktop has no injected store -> byte-identical (no-op).

Container-side: takes effect after a container image rebuild + deploy. Tests: member_creds::github_logins surfaces only token-bearing members / empty on desktop; dedup_append merge is order-stable + de-duped.
…enumeration + round6 fixes

Rebuilds the sandbox container image from the branch HEAD so the deployed team cloud picks up: forge account enumeration surfacing injected member creds (Connect GitHub status), DF-R6-D LOCAL_ONLY auth terminals, DF-R6-A/C toast + status-card fixes, and OBS-R6-1 container restart on Codex re-auth.
… toast (debug-loop)

Switching Local<->Team while the sandbox slept spammed stacked red "team sandbox is asleep" toasts. Root cause: sync_global_hotkey registers a macOS OS global hotkey (a desktop op) but was classified PASSIVE, so team mode routed it to the sleeping container; the typed CompanionAsleepError hit useGlobalHotkeySync's catch -> toast.error, and each Local<->Team switch remounts the shell (resetting the per-failure dedup ref) -> ~2 toasts per switch, stacking to 5+.

- command-classes: sync_global_hotkey PASSIVE -> LOCAL_ONLY (root fix; also lets the hotkey actually register on this Mac in team mode). Frontend routing only; the rpc.rs dispatch arm stays (R2-D/R3-A precedent).

- use-global-hotkey-sync: toast.error -> toastCaughtError (DF-R6-A) as defense-in-depth: silent for the typed asleep error, message-stable dedup id for a real registration failure.

Tests: assert sync_global_hotkey is LOCAL_ONLY; hotkey sync silences the typed CompanionAsleepError and dedups real failures. Frontend fix -> live via HMR, no container rebuild/deploy.
…rly-return

WorkspaceGitStateChanged/WorkspaceFilesChanged carry no Stage-B session/
workspace target, so the no-target early-return in on_ui_mutation fired
before the git-snapshot mirror block — making it dead code. The D1
git_snapshots table was never written for any team workspace, so every
team client's inspector read the empty default (no changes / branch not
published) regardless of what the agent committed + autopushed.

Extract git_snapshot_target() and run the mirror BEFORE the early-return.
Add a regression test pinning that git events have no Stage-B target yet
ARE git-snapshot targets.
Forces the Worker to pull a fresh sandbox image carrying the
on_ui_mutation git-snapshot-mirror ordering fix (8331cfe) so the
team-mode inspector reflects real git state instead of the empty default.
…reads

The on_ui_mutation dead-code fix alone wasn't enough: live testing showed
the D1 git-snapshot mirror is still never written, because after a turn's
cloud auto-push commits + pushes, NO WorkspaceGitStateChanged fires — the
container git fs-watcher doesn't fire reliably (and races the aggressive
post-turn idle-destroy), so sync_git_snapshot is never triggered.

autopush is the authoritative source of the git change it just made, so it
now announces it directly: autopush() returns whether it committed, and on a
real commit+push maybe_autopush_after_turn publishes WorkspaceGitStateChanged
(workspace resolved best-effort from the session). This fires the git-snapshot
mirror in the same window the push completes in (before idle-destroy), instead
of depending on the fs-watcher.
…ounce)

Forces the Worker to pull a fresh sandbox image carrying the autopush
WorkspaceGitStateChanged announce (0777359) on top of the mirror-ordering
fix, so the team inspector actually reflects real git state.
… reject

Lever B of the gray-cloud switch flicker fix. With the container asleep,
get_workspace (the only switch-time input answered by the live container)
fast-fails with ContainerAsleep; the prime's Promise.all rejected instantly,
collapsing the cold-display hold into a blank placeholder long before the
D1-served session list arrived — the old→blank→new flicker.

- primeWorkspaceDisplay: workspace detail is best-effort (.catch(() => null));
  the D1 session list is the load-bearing input, so the hold now survives an
  asleep reject and lands a single old→new commit.
- workspaceDetailQueryOptions: persist (meta: PERSIST_META) so the header
  renders last-known data immediately, stale-while-revalidate on focus/wake.
- Panel: new sessionsResolved gate — EmptyState only when the session list
  resolved AND is empty; pending/error keeps the cold placeholder instead of
  flashing "Nothing here yet".
- selectSession: cold thread fetch bounded by COLD_DISPLAY_HOLD_MAX_MS — the
  cap lands the target session (loading pane) instead of a dead click.
…e off-container

Lever A of the gray-cloud switch fix: get_workspace was the ONLY switch-time
input still proxied to the live container. For worktree-mode (container)
workspaces its result is 100% DB-derived, so mirror it and answer from the
control plane — switching never depends on the container being awake.

- D1: new workspace_details table (opaque payload, write-path self-heal
  CREATE — the git_snapshots precedent, no migration step needed).
- PUT /team/sync: new workspaceDetails rows (exact WorkspaceDetail
  serialization); GET /team/workspace-detail serves them member-readable,
  D1-only. Local-dev registry mirrors the semantics in-memory.
- Container (team_sync.rs): push one workspace's detail on WorkspaceChanged /
  SessionListChanged / WorkspaceChangeRequestChanged, all workspaces on
  WorkspaceListChanged, plus a full push in the startup backfill. Detail-
  mirrored events defer their realtime broadcast until the mirror lands
  (same P1-6b contract as the session mirror).
- Desktop: loadWorkspaceDetail branches to the mirror in team mode (the
  loadWorkspaceSessions pattern); a null mirror falls through to /rpc.
Forces the Worker to pull a fresh sandbox image carrying the team_sync
workspace-detail mirror push (e3b54f6), so switching workspaces in team
mode reads get_workspace from D1 with the container asleep.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants