Commit Graph

7 Commits

Author SHA1 Message Date
Caspian Zhao e3b54f66b8 feat(team-cloud): mirror workspace detail into D1, serve get_workspace 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.
2026-07-13 08:46:37 -07:00
Caspian Zhao ecc41ef7c0 fix(team-cloud): container gets ONLY the forge creator's tokens (P1-2a)
Cold start previously minted EVERY member's ForgeIdentity DO and wrote
the whole team's gh/glab tokens into the shared container — one induced
'cat' away from a full-team credential leak. Per the user product
ruling the creator-identity model stays (everyone's pushes reuse the
creator's forge identity); only the storage narrows:

- teams.forge_identity_member_id: the forge 'creator', bound
  FIRST-authorizer-wins on PUT /team/forge-identity (mirrors
  cloud_identity_member_id's single-row upsert, but COALESCE keeps an
  existing binding; a non-creator racing to authorize first is the same
  accepted TOFU edge as cloud identity). Added via the P1-4c idempotent
  D1 ALTER runner (0002) + schema.sql for fresh DBs.
- collectMemberForgeCreds injects tokens ONLY for the creator entry
  (flagged creator:true); every other member is login-only (kept for
  per-member commit authorship). Local-docker launcher mirrors the same
  narrowing.
- member_creds.rs (CONTAINER RUST — needs the next team-image bump):
  token lookups fall back to the creator's tokens when the acting
  member has none, and when no acting member is bound at all (the
  detached auto-push thread). Old injected files without the creator
  flag still parse (no fallback). Authorship lookups (member_for) do
  NOT fall back.

NOTE: P1-8a (scrubbing the token out of .git/config) depends on this
creator fallback for push auth — rolling back this commit requires
rolling back the P1-8a commit with it.
2026-07-10 20:04:27 -07:00
Caspian Zhao 8485f5f829 fix(team-cloud): duplicate-tolerant D1 migrations run at every provision (P1-4c)
schema.sql claimed "the orchestrator runs the ALTER once at deploy" — no such
orchestrator existed. Any D1 predating a column (long-lived dev DBs;
re-provision onto an already-set-up account, where d1 create and the schema
apply both no-op) kept missing cloud_identity_member_id and team.ts's
INSERT/SELECT hit "no such column" → 500, and the next additive column would
have hit the same wall with no runner.

- d1-migrations.ts: additive-migration list + classifyMigrationError —
  SQLite's "duplicate column name" (fresh DB / re-run) is already-applied,
  everything else is fatal. Comment documents the upgrade path: if migrations
  ever stop being additive/duplicate-classifiable, move to a versioned
  schema_migrations table instead of stretching this list.
- provision-team.ts executes the list right after the schema apply on every
  provision / re-provision.
- schema.sql's lying migration comment rewritten to the real mechanism.

Validated against a real D1 (scratch DB, deleted after): old-shape teams
table → migration exit 0 and the column SELECTs; re-run → wrangler-wrapped
"duplicate column name … SQLITE_ERROR [code: 7500]" (captured verbatim into
the test) → classified already-applied.
2026-07-10 15:49:05 -07:00
Caspian Zhao ad2fac53bf feat(team-cloud): WP5 — serve the model catalog from the control plane (D2)
The composer's model list (list_agent_model_sections) previously proxied
through ensureServe, so opening the app against a sleeping container burned a
cold start (up to 180s) just to render the model picker — and the WP1
readiness probe (/v1/health through the same proxy) woke the container too.

Cache the catalog (metadata only, never credentials) in a new D1
`model_catalog` row and answer the RPC BEFORE ensureServe:

- Worker intercept: authed (member/admin, mirrors /admin/*) cache hit returns
  the verbatim payload with `x-helmor-model-catalog: cached` and ZERO
  container interaction; a miss falls through to the normal ensureServe+proxy
  path (the one legitimate non-@agent wake: brand-new/reset teams —
  provision's WP6 verify normally seeds the cache at setup).
- Invalidation, no TTL: write-through on every LIVE pass of the RPC, plus
  ensureServe now reports `coldStarted` and every wake (including
  /admin/warm-up) refreshes the cache in the background — an image update is
  picked up on the first real wake after it.
- Self-healing schema: the write path batches the CREATE TABLE, so a pre-WP5
  D1 that never re-ran schema.sql works without a manual migration; a missing
  table reads as a plain cache miss.
- local-dev proxy mirrors the intercept with an in-memory registry cache so
  the no-wake behavior is e2e-verifiable on local-docker.

No container/Rust changes — no image publish needed for prod rollout.
2026-07-02 10:43:50 -07:00
Caspian Zhao 6eb47f3cd1 feat(team-cloud): Stage B.1 — D1 sessions/messages mirror + Worker sync/read routes
Data-plane foundation (additive; dead until the desktop is wired in B.3):
- schema.sql: new `sessions` + `messages` mirror tables (browse-essential session
  columns; append-only message rows w/ verbatim content JSON). Mirrors the
  sandbox's SQLite so history is readable with the container asleep.
- team-gateway/core.ts: TeamSyncInput types + 3 routes — PUT /team/sync (container
  write-through, companion-gated), GET /team/sessions, GET /team/messages
  (member-or-admin reads).
- team.ts: D1-backed store impls — idempotent id-keyed upserts, append-only
  message inserts (ON CONFLICT DO NOTHING), cascade deletes, batched.
- local-dev registry + proxy: matching in-memory mirror so `bun run dev:team`
  serves /team/sessions|messages the same way.
- contract test: round-trip + auth (member can't write the mirror; unauth can't read).

Worker typecheck + 46 cloud tests green. Schema is applied to the live D1 and the
Worker redeployed in B.3, when the desktop starts reading these routes.
2026-06-25 15:19:55 -07:00
Caspian Zhao c56043786d feat(cloud): Codex subscription token broker (Phase 1)
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).
2026-06-14 08:44:38 -07:00
Caspian Zhao 4704d7947c feat(cloud): team registry + invites + server-derived member identity (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.
2026-06-13 21:49:15 -07:00