From 30745f1494a92b7c0a2d69d9c6b1818142760696 Mon Sep 17 00:00:00 2001 From: Travis Gilbert <1travisgilbert@gmail.com> Date: Thu, 6 Aug 2026 10:53:34 -0400 Subject: [PATCH] Start workspace tenancy: no boot clone, welcome root, WT9 decision. Board SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0, record Verify-first, accept single-instance isolation, refuse WORKSPACE_REPO at entrypoint, and add the first gate:multitenant cut plus workspace path types. --- ...rkspace-tenancy--plan-local-20260806a.json | 83 +++++++ .../src/agent/session-opener.ts | 7 +- .../workspace-tenancy/DECISION-ISOLATION.md | 51 +++++ .../plans/workspace-tenancy/EXECUTE-REPORT.md | 46 ++++ docs/plans/workspace-tenancy/PLAN.md | 32 +++ .../SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0.md | 206 ++++++++++++++++++ docs/plans/workspace-tenancy/VERIFY-FIRST.md | 62 ++++++ packages/workspace-tenancy/package.json | 19 ++ packages/workspace-tenancy/src/index.ts | 2 + .../workspace-tenancy/src/workspace.test.ts | 13 ++ packages/workspace-tenancy/src/workspace.ts | 30 +++ packages/workspace-tenancy/tsconfig.json | 12 + packages/workspace-tenancy/vitest.config.ts | 5 + packaging/workspace/Dockerfile | 24 +- packaging/workspace/entrypoint.sh | 134 +++++------- packaging/workspace/railway.toml | 15 +- scripts/check-multitenant.mjs | 66 ++++++ 17 files changed, 697 insertions(+), 110 deletions(-) create mode 100644 .harness/checklists/workspace-tenancy--plan-local-20260806a.json create mode 100644 docs/plans/workspace-tenancy/DECISION-ISOLATION.md create mode 100644 docs/plans/workspace-tenancy/EXECUTE-REPORT.md create mode 100644 docs/plans/workspace-tenancy/PLAN.md create mode 100644 docs/plans/workspace-tenancy/SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0.md create mode 100644 docs/plans/workspace-tenancy/VERIFY-FIRST.md create mode 100644 packages/workspace-tenancy/package.json create mode 100644 packages/workspace-tenancy/src/index.ts create mode 100644 packages/workspace-tenancy/src/workspace.test.ts create mode 100644 packages/workspace-tenancy/src/workspace.ts create mode 100644 packages/workspace-tenancy/tsconfig.json create mode 100644 packages/workspace-tenancy/vitest.config.ts create mode 100644 scripts/check-multitenant.mjs diff --git a/.harness/checklists/workspace-tenancy--plan-local-20260806a.json b/.harness/checklists/workspace-tenancy--plan-local-20260806a.json new file mode 100644 index 00000000..78650e98 --- /dev/null +++ b/.harness/checklists/workspace-tenancy--plan-local-20260806a.json @@ -0,0 +1,83 @@ +{ + "schema_version": 1, + "plan_id": "plan-commonplace-workspace-tenancy-20260806a", + "tenant_slug": "Travis-Gilbert", + "workstream": "workspace-tenancy", + "created_at": "2026-08-06", + "sources": [ + "docs/plans/workspace-tenancy/SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0.md", + "docs/plans/workspace-tenancy/VERIFY-FIRST.md", + "docs/plans/workspace-tenancy/DECISION-ISOLATION.md" + ], + "items": [ + { + "id": "WT0", + "title": "Durable SPEC + PLAN + Verify-first", + "status": "done", + "oracle_class": "artifact", + "evidence_class": "artifact" + }, + { + "id": "WT9", + "title": "DECISION-ISOLATION.md", + "status": "done", + "oracle_class": "artifact", + "evidence_class": "artifact", + "evidence_note": "Near-term single instance multi-workspace-object; revisit triggers named." + }, + { + "id": "WT4", + "title": "Remove boot clone; welcome root; refuse WORKSPACE_REPO", + "status": "verifying", + "oracle_class": "live", + "evidence_class": "artifact", + "evidence_note": "entrypoint/Dockerfile/railway updated; live proof after deploy + unset Railway WORKSPACE_REPO." + }, + { + "id": "WT8", + "title": "gate:multitenant first cut", + "status": "partial", + "oracle_class": "test", + "evidence_class": "test", + "evidence_note": "scripts/check-multitenant.mjs; inventory burn-down incomplete (shell-state, mobile, harness tenant)." + }, + { + "id": "WT1", + "title": "GitHub App + Connect flow", + "status": "pending", + "oracle_class": "live", + "evidence_class": "artifact", + "evidence_note": "Blocked on vendor App registration + secrets; types package scaffolded." + }, + { + "id": "WT2", + "title": "Repo picker", + "status": "pending", + "oracle_class": "live" + }, + { + "id": "WT3", + "title": "Token mint + credential helper", + "status": "pending", + "oracle_class": "live" + }, + { + "id": "WT5", + "title": "One tree three doors", + "status": "pending", + "oracle_class": "live" + }, + { + "id": "WT6", + "title": "Tenant keying through substrate", + "status": "pending", + "oracle_class": "live" + }, + { + "id": "WT7", + "title": "Disconnect cascade", + "status": "pending", + "oracle_class": "live" + } + ] +} diff --git a/apps/theorem-vscode/src/agent/session-opener.ts b/apps/theorem-vscode/src/agent/session-opener.ts index 2b9f926b..e191974c 100644 --- a/apps/theorem-vscode/src/agent/session-opener.ts +++ b/apps/theorem-vscode/src/agent/session-opener.ts @@ -27,7 +27,12 @@ export async function openIdeSession( const agentUrl = pack.agentUrl || pack.consoleOrigin; const token = pack.token; - const cwd = workspaceRoot && workspaceRoot.length > 0 ? workspaceRoot : '/workspace/repo'; + const cwd = workspaceRoot && workspaceRoot.length > 0 ? workspaceRoot : undefined; + if (!cwd) { + throw new Error( + 'theorem.chat: open a workspace folder first (no /workspace/repo default; SPEC WT4)', + ); + } const client = await HostedAcpClient.connect({ agentId: 'theorem', diff --git a/docs/plans/workspace-tenancy/DECISION-ISOLATION.md b/docs/plans/workspace-tenancy/DECISION-ISOLATION.md new file mode 100644 index 00000000..1d42d8b6 --- /dev/null +++ b/docs/plans/workspace-tenancy/DECISION-ISOLATION.md @@ -0,0 +1,51 @@ +# DECISION-ISOLATION: workspace tenancy (WT9) + +**Date:** 2026-08-06 +**Spec:** SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 +**Status:** accepted for near-term + +## Decision + +**Near-term:** one Railway `commonplace-workspace` instance (and its sticky +volume) serves **many workspace objects**. Isolation is by contract: + +- path `/workspace/{workspace_id}` +- per-workspace `--user-data-dir` / extensions dir (when a session is bound) +- installation token minted per git request (never in env for user code) +- tenant segment on every graph key (WT6) + +Not by one container per workspace. + +## Evidence + +- Today: one Studio/`setsid` IDE process, one OpenWork process, one volume + (`packaging/workspace/entrypoint.sh`). +- Folder selection is launch-time `--default-folder` in packaging; `?folder=` + unproven for Studio reh-web (`VERIFY-FIRST.md` §1). +- Railway volume is already the durable mount; spinning N Studio processes per + active user is a later cost, not required for the object contract. + +## What the contract must hold regardless of posture + +1. No `WORKSPACE_REPO` / `WORKSPACE_REPO_URL` / boot clone. +2. Workspace id in every checkout path. +3. Token per git request via credential helper → API (WT3). +4. Folder binding per session (mechanism may evolve: process restart, + `?folder=` if proven, or per-session server). + +## Revisit trigger + +Revisit when any of: + +- concurrent sessions on different workspaces collide on a single Studio + process (proven live), or +- volume IOPS / disk for N checkouts exceeds a measured Railway ceiling, or +- compliance requires process-level isolation between tenants. + +Then swap deployment (N replicas or per-workspace workers) without redesigning +the workspace object, paths, or token lanes. + +## Non-dependency + +WT1–WT8 acceptance does not depend on this posture choice; only WT4’s open +mechanism references the Verify-first folder finding. diff --git a/docs/plans/workspace-tenancy/EXECUTE-REPORT.md b/docs/plans/workspace-tenancy/EXECUTE-REPORT.md new file mode 100644 index 00000000..92e6658b --- /dev/null +++ b/docs/plans/workspace-tenancy/EXECUTE-REPORT.md @@ -0,0 +1,46 @@ +# Execute Report: workspace-tenancy (SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0) + +Plan: `plan-commonplace-workspace-tenancy-20260806a` + +## Summary +- Final condition: Spec boarded; Verify-first recorded; WT9 isolation decision accepted (single instance, multi workspace object); **WT4 source** removes boot clone and refuses `WORKSPACE_REPO` / `WORKSPACE_REPO_URL`; WT8 first-cut gate added; workspace path types package added. **WT1–WT3, WT5–WT7 not done.** +- Goal achieved: **partial** — product no longer *designed* to open CommonPlace via env; Connect GitHub + provision API still required before users can open arbitrary repos. +- Biggest remaining risk: Railway still has `WORKSPACE_REPO=Travis-Gilbert/CommonPlace` — **must delete before deploying WT4 entrypoint** or the container exits 78. +- Next action: unset Railway `WORKSPACE_REPO`; deploy workspace; then WT1 App registration. + +## Checklist Reconciliation +| ID | Status | Evidence | +|---|---|---| +| WT0 | done | SPEC, PLAN, VERIFY-FIRST, checklist | +| WT9 | done | DECISION-ISOLATION.md | +| WT4 | verifying | entrypoint/Dockerfile/railway; live pending | +| WT8 | partial | `node scripts/check-multitenant.mjs` | +| WT1–WT3 | pending | App/picker/tokens | +| WT5–WT7 | pending | shared tree, tenant keys, cascade | + +## Verify-first (leading findings) +1. Studio folder = launch `--default-folder` only; `?folder=` unproven. +2. Shared user-data dir today; WT4 needs per-workspace profiles. +3. Identity is GitHub OAuth (`githubTenantSlug`), not WorkOS (SPEC named WorkOS — record divergence). +4. Inventory: see VERIFY-FIRST.md §4. + +## Changes Made +| Area | Summary | +|---|---| +| `packaging/workspace/entrypoint.sh` | No boot clone; refuse repo env; welcome dir | +| `packaging/workspace/Dockerfile` | `/workspace/welcome`; drop tenant/repo ENV defaults | +| `packaging/workspace/railway.toml` | Document retirement | +| `apps/theorem-vscode/.../session-opener.ts` | No `/workspace/repo` fallback | +| `packages/workspace-tenancy` | `workspacePath` + types | +| `scripts/check-multitenant.mjs` | WT8 first cut | + +## Validation +| Check | Result | +|---|---| +| `node scripts/check-multitenant.mjs` | run in session | +| `pnpm --filter @commonplace/workspace-tenancy test` | run in session | +| Live `/IDE` empty welcome | **not run** until deploy + unset WORKSPACE_REPO | +| WT1 connect one-click | **not run** | + +## Remaining Work +WT1 App + console connect; WT2 picker; WT3 credential helper; WT4 live proof + per-workspace user-data; WT5–WT7; finish WT8 inventory burn-down (shell-state, mobile, `CONSOLE_HARNESS_TENANT`). diff --git a/docs/plans/workspace-tenancy/PLAN.md b/docs/plans/workspace-tenancy/PLAN.md new file mode 100644 index 00000000..46d5fdfb --- /dev/null +++ b/docs/plans/workspace-tenancy/PLAN.md @@ -0,0 +1,32 @@ +# Planning-Theorem: CommonPlace workspace tenancy + +- **Plan id:** `plan-commonplace-workspace-tenancy-20260806a` +- **Tenant:** identity-derived (not env) +- **Created:** 2026-08-06 +- **Spec:** [`SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0.md`](./SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0.md) +- **Supersedes:** service `WORKSPACE_REPO` / `WORKSPACE_REPO_URL` / boot clone into `/workspace/repo` (dissolves CR-002 sticky-singleton posture) + +## Executive summary + +- **Goal:** A workspace is a graph object resolved per session. Users Connect GitHub (vendor App), pick a repo, get `/workspace/{workspace_id}` shared by IDE, chat, and agents. Env never names tenant, repo, or user. +- **Law:** Environment carries how to reach a service, never which tenant, repo, or user. +- **Sequence:** Verify-first → WT9 isolation decision → WT4 remove boot clone → WT8 gate scaffold → WT1–WT3 App/picker/tokens → WT5–WT7 shared tree + cascade. + +## Checklist + +| ID | Task | Status | +|---|---|---| +| WT0 | Durable SPEC + PLAN + checklist + Verify-first record | in progress | +| WT9 | `DECISION-ISOLATION.md` | pending | +| WT4 | Remove boot clone; per-workspace paths; delete repo env contract | pending | +| WT8 | `gate:multitenant` + inventory burn-down | pending | +| WT1 | GitHub App + Connect flow | pending | +| WT2 | Repo picker → workspace object | pending | +| WT3 | Installation token mint + git credential helper | pending | +| WT5 | One tree three doors (live non-CommonPlace fixture) | pending | +| WT6 | Tenant keying through substrate | pending | +| WT7 | Disconnect cascade + receipt | pending | + +## Validation (acceptance oracles from SPEC) + +See SPEC §§ WT1–WT9 Accepted when. Leading with what is not done in EXECUTE-REPORT. diff --git a/docs/plans/workspace-tenancy/SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0.md b/docs/plans/workspace-tenancy/SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0.md new file mode 100644 index 00000000..70ca0fdf --- /dev/null +++ b/docs/plans/workspace-tenancy/SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0.md @@ -0,0 +1,206 @@ +# SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 + +2026-08-05. `Travis-Gilbert/CommonPlace`, `Travis-Gilbert/Theorem`. Architecture decision plus +execution handoff. Deliverables WT1 through WT9. + +The verdict: a workspace is an object resolved per session, never a service environment variable. +Users connect GitHub in one click through a vendor-owned GitHub App, pick a repo, and get a +checkout that the IDE door, the chat register, and any agent head share. Three layers with +separate owners: GitHub is the source of truth for code, the workspace volume is a disposable +re-mintable cache, and RustyRed holds what Theorem has learned. One law governs the whole spec: +**environment carries how to reach a service, never which tenant, repo, or user.** This is an +execution handoff; CONVENTIONS.md applies in full. + +Companions. This supersedes the `WORKSPACE_REPO` / `WORKSPACE_REPO_URL` / `--default-folder` +contract that made the Studio deployment single-tenant by construction, and it dissolves CR-002 of +`plan-theorem-chat-register-20260805a` rather than satisfying it: the sticky empty +`/workspace/repo` is a boot-time singleton, and WT4 removes boot-time cloning entirely. +SPEC-WORKSPACE-SUBSTRATE-JETBRAINS-MINING-1.0 already owns the VFS, local history, workspace +model, and readiness organs; this spec provisions the tree those organs observe and adds the +tenancy keying they need. SPEC-THEOREM-CHAT-REGISTER-1.0's shared-checkout choice binds to WT4's +per-workspace path. + +## Verify first + +- Whether the Studio serve-web binary accepts a folder query parameter (code-server does; confirm + for this build) or whether per-session folder selection requires a launch-per-session model. + WT4's mechanism depends on the finding; record it before building. +- Per-session `--user-data-dir` and extensions-dir behavior in the Studio build, since a shared + `User/settings.json` on one volume is one profile serving every user. The entrypoint settings + bug fixed in #199 is evidence this is real, not theoretical. +- Current WorkOS integration surface: what identity claims reach the console session, and how a + tenant is derived from them today. +- The full single-user inventory by search, not memory, before WT8 writes its gate: literals + naming a tenant or repo, env vars naming one (`WORKSPACE_REPO`, `WORKSPACE_REPO_URL`, + `CONSOLE_HARNESS_TENANT`, `CONSOLE_HARNESS_ROOM` all fail the law today), singleton paths, + shared-profile paths, shared API keys where per-tenant credentials belong, `room:ungrouped` + defaults, and any blob or index path without a tenant segment. +- Which graph keys already carry a tenant segment and which do not, per repository and per organ, + since WT7's cascade cannot be honest about deleting what it cannot address. +- GitHub App permission minimum for the flows here: Contents for clone, Metadata, and whatever + the picker's repository listing requires; grant nothing beyond what a deliverable uses. +- Railway's practical ceiling for per-workspace isolation, so WT9 records the orchestration + decision with evidence rather than assumption. + +## Named choices + +1. **One vendor-owned GitHub App.** Created once by Anthropic-style vendor pattern: the user + clicks Connect GitHub, GitHub presents install and authorize with all-repositories or + selected-repositories choice, the user approves, the redirect returns them to CommonPlace. No + user ever creates an app, pastes a token, or configures a webhook. This is the same shape + Claude Code's install-github-app flow and Codex's repo picker present. +2. **Installation, not OAuth `repo` scope.** The `repo` scope is all-or-nothing across everything + the user can reach, acts as the user, and dies with their seat; the installation is per-repo + consent, acts as the product, survives membership changes, and carries its own rate limits. +3. **Three credential lanes that never mix.** Identity is WorkOS. Repo grants are the + installation, kept current by installation webhooks against a mapping table. Local CLI use + rides the developer's ambient git credentials with no connect flow at all, because the app + lane exists to give hosted workspaces credentials they otherwise lack. +4. **Tokens are minted, never stored in the tree.** A git credential helper mints an installation + token per request; no token is written into a remote URL or `.git/config`, because installation + tokens expire in an hour and a persisted credential on a shared volume is a leak. Tokens are + never logged, never returned to the browser, and never placed in an env var read by user code. +5. **The workspace is a graph object.** Id, tenant, repo full name, installation id, ref, path, + state, provisioned lazily and addressable by id. Every path, key, and token derives from the + object, so nothing about which repo is known at container boot. +6. **Tenant is part of every key.** Workspace nodes, VFS journal entries, blobs, index shards, and + derived semantics all carry the tenant segment. `gate:multitenant` extends past environment + variables into graph key shapes. +7. **Deletion is a first-class cascade with receipts.** Disconnecting the app or deleting a + workspace removes derived nodes, blobs, and index shards provably, because keeping learned + structure from someone's code is only defensible if its departure is equally demonstrable. +8. **Layers own different things.** GitHub owns code. The volume owns a disposable working tree. + RustyRed owns observation and derivation: the workspace object, the VFS journal, local history + revisions with blobs, code intelligence built lazily under the readiness registry, and the + plans, runs, and receipts that reference files by path and hash. The graph is never a git + remote and never stores the checkout. +9. **The federation line holds.** Code content and derived semantics never cross tenants; only + content-free structural signatures are ever shareable, and only opt-in, per existing federation + doctrine. + +## Deliverables + +### WT1. The app and the connect flow + +Paths: the GitHub App registration, `apps/console` connect surface, `apps/commonplace-api` +callback and webhook endpoints. + +The vendor app with minimum permissions per Verify first; a Connect GitHub action in the console; +callback handling that reconciles installations through the API rather than trusting the redirect, +because a second tenant installing on an already-installed org returns without an installation +code; an installation webhook consumer maintaining the mapping table as repos are added, removed, +suspended, or the app uninstalled. + +Accepted when a fresh account connects in one click and lands back authenticated with its +installation recorded; a repo added later in GitHub appears without reconnecting; uninstalling in +GitHub marks the installation revoked in the mapping table within one webhook delivery. + +### WT2. The repo picker + +Path: `apps/console`. + +Repositories listed from the connected installations, grouped by owner, with a pending state for +org installations awaiting admin approval that reads as pending rather than failure, and a path to +add another owner since installation tokens are scoped to a single owner. + +Accepted when a user with repos across two owners sees both after two installations; a repo +requiring org approval renders the pending state with its request surfaced; selecting a repo +creates a workspace object and returns its id. + +### WT3. Token minting and the credential helper + +Paths: `apps/commonplace-api` token service, the workspace image's git credential helper. + +JWT signed with the app key, exchanged for an installation access token scoped to the workspace's +installation; a credential helper in the image that calls the service per git request; no token +persisted anywhere in the tree. + +Accepted when a clone and a fetch an hour apart both succeed without re-provisioning; a grep of +`.git/config` and the volume finds no token; the token service refuses a request whose workspace +does not belong to the caller's tenant, and the refusal is receipted. + +### WT4. Per-workspace provisioning + +Paths: `packaging/workspace/entrypoint.sh`, the provisioning API, the Studio launch path. + +Boot-time cloning is removed. Provisioning clones into `/workspace/{workspace_id}` on demand, with +a per-workspace user-data-dir and extensions-dir, and the session opens that folder by the +mechanism the Verify first finding names. `WORKSPACE_REPO` and `WORKSPACE_REPO_URL` are deleted. + +Accepted when two workspaces on different repos are open in one deployment without collision, +each with its own settings and MRU list; a container restart re-provisions from the workspace +object with no manual repair; the entrypoint contains no clone and no repo-naming env var; the +sticky-empty-repo failure cannot be reproduced. + +### WT5. One tree, three doors + +Paths: the workspace image, the chat register mount, the IDE mount, the agent session path. + +The IDE door, the chat register, and agent sessions all resolve the same +`/workspace/{workspace_id}` tree for a given session. + +Accepted when an edit made in the IDE door is visible in the chat register's file surface with no +sync step and an agent session's tool call reads the same bytes, all three verified live in one +session against a non-CommonPlace fixture repo. + +### WT6. Tenant keying through the substrate + +Paths: the VFS, history, workspace-model, and index organs per the mining spec. + +Every derived node, journal entry, blob, and index shard carries its tenant segment; queries are +tenant-scoped by construction rather than by filter discipline. + +Accepted when a fixture second tenant's workspace produces derived nodes that the first tenant's +queries cannot reach, demonstrated by an attempted cross-tenant read that refuses with a receipt; +`gate:multitenant` passes on graph key shapes. + +### WT7. Disconnect and cascade + +Paths: the deletion path across graph, blobs, index, and volume. + +Workspace deletion and app disconnection remove derived nodes, blobs, index shards, and the +working tree, producing a receipt enumerating what was removed. + +Accepted when deleting a fixture workspace leaves no derived node, blob, or index shard +addressable afterward, verified by query rather than assertion; the receipt enumerates the classes +removed; a subsequent reconnect starts clean. + +### WT8. `gate:multitenant` + +Path: `scripts/`, wired into CI beside the existing gates. + +The gate fails on: literals naming a tenant, user, or repo outside packaging metadata and +fixtures; env vars naming a tenant, repo, user, or room; singleton workspace paths; shared-profile +paths; single shared API keys where per-tenant credentials belong; graph key shapes without a +tenant segment. The full inventory from Verify first is burned down, each entry fixed or recorded +with an owner. + +Accepted when the gate is green on main and demonstrably red on a branch reintroducing +`WORKSPACE_REPO`, a hardcoded owner string, and an untenanted graph key; the inventory has no +unowned entries. + +### WT9. The orchestration decision + +Path: `docs/plans/workspace-tenancy/DECISION-ISOLATION.md`. + +A written decision on per-workspace isolation with evidence: what Railway supports, what the +contract needs, and what the swap costs later. The near-term posture may be a single instance +serving multiple workspace objects, provided the contract, workspace id in every path, token per +session, folder per request, holds, so the later swap is a deployment change and not a redesign. + +Accepted when the decision exists with its evidence and its named trigger for revisiting, and +nothing in WT1 through WT8 depends on which posture was chosen. + +## Out of scope + +Non-GitHub forges, which follow the same object contract in their own handoff; per-workspace +billing and quotas; the Symphony workspace door; a code mirror or git-remote role for the graph, +permanently excluded by named choice 8; org-level SSO beyond what WorkOS already provides; mobile +workspace management. Each is its own handoff; none gates anything above. + +## Reporting + +Per CONVENTIONS: scannable status per deliverable, acceptance verified or not and how, leading +with what is not done. Include the folder-selection finding, the app's granted permissions, the +single-user inventory as found with owners, the cross-tenant refusal receipt, the cascade receipt, +and the isolation decision with its trigger. diff --git a/docs/plans/workspace-tenancy/VERIFY-FIRST.md b/docs/plans/workspace-tenancy/VERIFY-FIRST.md new file mode 100644 index 00000000..9a8cfaa6 --- /dev/null +++ b/docs/plans/workspace-tenancy/VERIFY-FIRST.md @@ -0,0 +1,62 @@ +# Verify-first: SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 + +Recorded 2026-08-06 from source + live packaging, before WT4 mechanism choice. + +## 1. Studio folder selection + +**Finding:** Launch-time only. Entrypoint starts one Studio process with +`--default-folder "${WORKSPACE_DIR}"`. No packaging smoke or docs prove +`?folder=` for `commonplace-studio-server` (reh-web). Console edge preserves +query strings (`edge-proxy.mjs`) but does not construct or verify folder +tenancy. + +**Implication for WT4:** Near-term posture (WT9) = single instance + provision +API cloning into `/workspace/{workspace_id}` + session open via +`--default-folder` swap or documented `?folder=` after a live oracle. Do not +assume query-param tenancy until proven. + +**Evidence:** [Explore chat register seams](bb581c43-9969-4150-aeff-85d59b9501d6) +style search of `packaging/workspace/entrypoint.sh`, +`packaging/commonplace-studio/scripts/smoke-server.sh`. + +## 2. Shared user-data / extensions + +**Finding:** One volume-backed `--user-data-dir` and `--extensions-dir` per +container. #199 proved settings merge is real and shared. WT4 requires +per-workspace user-data and extensions dirs under +`/workspace/state/workspaces/{workspace_id}/…`. + +## 3. Identity surface (WorkOS claim in SPEC) + +**Finding:** CommonPlace console has **no WorkOS**. Identity is Auth.js + +GitHub OAuth → `githubTenantSlug(login)` (`packages/theorem-acp/src/identity.ts`, +`apps/console/src/lib/auth.ts`). SPEC named choice 3 says WorkOS; implement +against the **existing GitHub identity lane** for WT1–WT8 and record WorkOS as +a later identity-provider swap that must not change the workspace-object +contract. + +## 4. Single-user inventory (seed for WT8) + +| Pattern | Class | Owner | +|---|---|---| +| `WORKSPACE_REPO` / `WORKSPACE_REPO_URL` + boot clone | fail-law (product) | WT4 | +| `THEOREM_GIT_TOKEN` composing product clone URL | fail-law | WT4 / WT3 | +| `/workspace/repo` ACP cwd fallback | fail-law | WT4 / pack | +| `CONSOLE_HARNESS_TENANT` legacy principal | fail-law | WT8 burn | +| `tenantAtom = 'Travis-Gilbert'` shell-state | fail-law | WT8 burn | +| mobile/desktop `Travis-Gilbert` defaults | fail-law | WT8 burn | +| Shared `User/settings.json` | needs-owner | WT4 | +| `COMMONPLACE_SERVICE_ALLOWED_TENANTS` default | packaging → fail-law | WT4/WT6 | +| Dockerfile Theorem clone via token | packaging-ok (build) | — | +| `room:ungrouped` in harness checklists | fixture-ok | — | + +## 5. GitHub App permissions (minimum) + +Contents (clone/fetch), Metadata, Repository listing for picker. Webhooks: +`installation`, `installation_repositories`. Nothing beyond deliverable use. + +## 6. Railway isolation ceiling + +Single `commonplace-workspace` service + one volume is today's deploy. WT9 +records near-term multi-workspace-object on one instance; revisit when +per-workspace process isolation or volume caps force a swap. diff --git a/packages/workspace-tenancy/package.json b/packages/workspace-tenancy/package.json new file mode 100644 index 00000000..f1393b74 --- /dev/null +++ b/packages/workspace-tenancy/package.json @@ -0,0 +1,19 @@ +{ + "name": "@commonplace/workspace-tenancy", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Workspace tenancy types and path helpers (SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0).", + "exports": { + ".": "./src/index.ts", + "./workspace": "./src/workspace.ts" + }, + "scripts": { + "test": "vitest run", + "check": "npx --yes -p typescript@5.7.3 tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "typescript": "^5.7.3", + "vitest": "^4.1.6" + } +} diff --git a/packages/workspace-tenancy/src/index.ts b/packages/workspace-tenancy/src/index.ts new file mode 100644 index 00000000..0b23f3a9 --- /dev/null +++ b/packages/workspace-tenancy/src/index.ts @@ -0,0 +1,2 @@ +// SOURCING: none. Package barrel. +export { workspacePath, type WorkspaceObject, type WorkspaceState } from './workspace'; diff --git a/packages/workspace-tenancy/src/workspace.test.ts b/packages/workspace-tenancy/src/workspace.test.ts new file mode 100644 index 00000000..a8e16cb4 --- /dev/null +++ b/packages/workspace-tenancy/src/workspace.test.ts @@ -0,0 +1,13 @@ +// SOURCING: none. Path helper tests. +import { describe, expect, it } from 'vitest'; +import { workspacePath } from './workspace'; + +describe('workspacePath', () => { + it('builds /workspace/{id}', () => { + expect(workspacePath('ws_abc')).toBe('/workspace/ws_abc'); + }); + it('refuses path traversal', () => { + expect(() => workspacePath('../etc')).toThrow(/invalid/); + expect(() => workspacePath('a/b')).toThrow(/invalid/); + }); +}); diff --git a/packages/workspace-tenancy/src/workspace.ts b/packages/workspace-tenancy/src/workspace.ts new file mode 100644 index 00000000..a591098b --- /dev/null +++ b/packages/workspace-tenancy/src/workspace.ts @@ -0,0 +1,30 @@ +// SOURCING: none. SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 workspace object types. +/** + * Workspace graph object (WT2/WT4). Pure types; persistence lands with the API. + */ + +export type WorkspaceState = + | 'pending_install' + | 'provisioning' + | 'ready' + | 'revoked' + | 'deleted'; + +export type WorkspaceObject = { + readonly id: string; + readonly tenant: string; + readonly repoFullName: string; + readonly installationId: number; + readonly ref: string; + /** Absolute path on the workspace volume: /workspace/{id} */ + readonly path: string; + readonly state: WorkspaceState; +}; + +export function workspacePath(workspaceId: string, root = '/workspace'): string { + const id = workspaceId.trim(); + if (!id || id.includes('/') || id.includes('..')) { + throw new Error('invalid workspace id'); + } + return `${root.replace(/\/$/, '')}/${id}`; +} diff --git a/packages/workspace-tenancy/tsconfig.json b/packages/workspace-tenancy/tsconfig.json new file mode 100644 index 00000000..8acb7f4b --- /dev/null +++ b/packages/workspace-tenancy/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/workspace-tenancy/vitest.config.ts b/packages/workspace-tenancy/vitest.config.ts new file mode 100644 index 00000000..a35e76ef --- /dev/null +++ b/packages/workspace-tenancy/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { include: ['src/**/*.test.ts'] }, +}); diff --git a/packaging/workspace/Dockerfile b/packaging/workspace/Dockerfile index f791c6c2..888124c3 100644 --- a/packaging/workspace/Dockerfile +++ b/packaging/workspace/Dockerfile @@ -1,11 +1,8 @@ # One workspace container, two doors. -# SPEC-COMMONPLACE-OPENWORK-FORK-1.0 OW5, named choice 6. +# SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 WT4/WT5 + OW5 two-door posture. # -# The chat register and the IDE register see the same filesystem because they -# are the same filesystem: one checkout at ${WORKSPACE_DIR}, two processes over -# it. There is no sync step because there is nothing to sync. Any design where -# the two doors hold separate copies needs a reconciler, and a reconciler is a -# thing that can be wrong; this cannot. +# Checkouts are /workspace/{workspace_id} after provision. Boot opens +# /workspace/welcome — never a product git clone via env. # # Build context is the repository root: # docker build -f packaging/workspace/Dockerfile -t commonplace-workspace . @@ -39,7 +36,7 @@ COPY apps/console/src/styles apps/console/src/styles RUN pnpm --filter @commonplace/chat build:web # --- Stage 1a: co-located editor substrate (commonplace-api) -------------- -# Remote Railway commonplace-api cannot see /workspace/repo inodes. IDE-006 +# Remote Railway commonplace-api cannot see workspace inodes. IDE-006 # runs the same binary beside the volume so createProject(rootPath) indexes # the checkout both doors edit. # @@ -281,17 +278,16 @@ COPY --from=web /build/apps/chat/dist /srv/openwork/public COPY packaging/workspace/entrypoint.sh /usr/local/bin/workspace-entrypoint RUN chmod +x /usr/local/bin/workspace-entrypoint -# The single checkout both doors open. Mount a volume here: everything else in -# the image is replaceable, and this is the only thing that is not. -ENV WORKSPACE_DIR=/workspace/repo +# Volume root. Per-workspace checkouts land at /workspace/{workspace_id} (WT4). +# Boot opens /workspace/welcome — never a product git clone. +ENV WORKSPACE_ROOT=/workspace +ENV WORKSPACE_DIR=/workspace/welcome ENV OPENWORK_PORT=8787 ENV CODE_SERVER_PORT=8080 ENV EDITOR_SUBSTRATE_PORT=50090 # Durable local editor graph beside the checkout volume (not the remote API). ENV COMMONPLACE_INSTANCE_ID=workspace-editor -ENV COMMONPLACE_SERVICE_ALLOWED_TENANTS=Travis-Gilbert -# JSON map tenant → absolute roots the co-located API may mount (createProject). -ENV COMMONPLACE_WORKSPACE_TENANT_ALLOWED_ROOTS={"Travis-Gilbert":["/workspace/repo"]} +# No default tenant in image env (SPEC tenancy law). Set at provision time. ENV THEOREM_CONSOLE_ORIGIN=https://v2.theoremharness.com # Without this the daemon starts with no engine and sessions cannot run # (apps/chat-server/src/cli.ts gates the managed engine on exactly "1"). @@ -311,7 +307,7 @@ ENV XDG_CONFIG_HOME=/workspace/state/config # $HOME/.openwork and reads OPENWORK_DATA_DIR, so without this the log behind # /workspace/:id/audit was still discarded on every redeploy. ENV OPENWORK_DATA_DIR=/workspace/state/openwork -RUN mkdir -p "${WORKSPACE_DIR}" "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}" "${OPENWORK_DATA_DIR}" +RUN mkdir -p "${WORKSPACE_ROOT}/welcome" "${XDG_DATA_HOME}" "${XDG_CONFIG_HOME}" "${OPENWORK_DATA_DIR}" # Railway rejects Dockerfile VOLUME. Persist /workspace with a Railway Volume # mount on the commonplace-workspace service instead (OW5 / cutover GL7). diff --git a/packaging/workspace/entrypoint.sh b/packaging/workspace/entrypoint.sh index abe6a977..58de6284 100755 --- a/packaging/workspace/entrypoint.sh +++ b/packaging/workspace/entrypoint.sh @@ -1,23 +1,22 @@ #!/usr/bin/env bash # SOURCING: none. Pure process supervision, no upstream component applies. # -# SPEC-COMMONPLACE-OPENWORK-FORK-1.0 OW5: two doors, one checkout, one token. -# IDE-006 adds a third process: co-located commonplace-api over the same -# ${WORKSPACE_DIR} so theorem-vscode can query live diagnostics. +# SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 WT4: boot never clones a product repo. +# Checkouts live at /workspace/{workspace_id} after on-demand provision. +# Env carries how to reach services (ports, tokens for *doors*), never which +# tenant, repo, or user. WORKSPACE_REPO / WORKSPACE_REPO_URL are refused. # -# Both processes are started against the same ${WORKSPACE_DIR}. That is the -# whole mechanism: the chat register's file engine and the IDE register's -# explorer are reading the same inodes, so an edit through one is visible to -# the other with no sync step because there is no second copy to sync. -# -# Chat authenticates against ${WORKSPACE_TOKEN}. The IDE door runs --auth none -# on the private network; the console /IDE edge checks cp_active_workspace and -# is the only public path to :8080. Revoking WORKSPACE_TOKEN still closes chat; -# revoking the active-workspace cookie closes the IDE edge. +# IDE :8080 and chat :8787 still share this container near-term (WT9). The +# active folder is bound per session once the provision API exists; until then +# Studio opens an empty welcome root, not CommonPlace source. set -euo pipefail -WORKSPACE_DIR="${WORKSPACE_DIR:-/workspace/repo}" +WORKSPACE_ROOT="${WORKSPACE_ROOT:-/workspace}" +# Empty welcome tree for the IDE until a workspace object is provisioned. +# Not a git clone of product source. Dissolves CR-002 sticky-singleton clone. +WELCOME_DIR="${WELCOME_DIR:-${WORKSPACE_ROOT}/welcome}" +WORKSPACE_DIR="${WORKSPACE_DIR:-${WELCOME_DIR}}" # Railway healthchecks and public routing use $PORT. The chat door owns that # port. code-server also reads $PORT and will ignore --bind-addr when it is set, # so it must be started with PORT unset (GL7 collision 2026-08-03). @@ -33,71 +32,24 @@ if [ -z "${WORKSPACE_TOKEN:-}" ]; then exit 64 fi -mkdir -p "${WORKSPACE_DIR}" - -# Prefer an explicit clone URL. Otherwise compose one from WORKSPACE_REPO + -# THEOREM_GIT_TOKEN so the secret never has to be duplicated into -# WORKSPACE_REPO_URL on the service (CR-002: production had the token but no -# URL, so the volume stuck on an empty `git init`). -if [ -z "${WORKSPACE_REPO_URL:-}" ] && [ -n "${WORKSPACE_REPO:-}" ] && [ -n "${THEOREM_GIT_TOKEN:-}" ]; then - WORKSPACE_REPO_URL="https://x-access-token:${THEOREM_GIT_TOKEN}@github.com/${WORKSPACE_REPO}.git" - echo "workspace: composed WORKSPACE_REPO_URL from WORKSPACE_REPO=${WORKSPACE_REPO}" +# SPEC law: env never names which repo. Operators must delete these vars. +if [ -n "${WORKSPACE_REPO:-}" ] || [ -n "${WORKSPACE_REPO_URL:-}" ]; then + echo "workspace: WORKSPACE_REPO / WORKSPACE_REPO_URL are retired (SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 WT4)." >&2 + echo "workspace: remove them from the service. Checkouts are provisioned per workspace object." >&2 + exit 78 fi -# True when the volume has no usable checkout: missing .git, or a sticky empty -# `git init` with no HEAD (the Aug 3 production scar). -workspace_repo_needs_seed() { - if [ ! -d "${WORKSPACE_DIR}/.git" ]; then - return 0 - fi - if ! git -C "${WORKSPACE_DIR}" rev-parse --verify HEAD >/dev/null 2>&1; then - return 0 - fi - return 1 -} +mkdir -p "${WORKSPACE_ROOT}" "${WELCOME_DIR}" +if [ ! -f "${WELCOME_DIR}/README.md" ]; then + cat > "${WELCOME_DIR}/README.md" <<'EOF' +# CommonPlace workspace -# A fresh volume is an empty directory, not a repository. Both doors behave -# better against a real one (git status, diffs, the daemon's VCS reads), and -# an operator who mounts an existing checkout with commits keeps theirs untouched. -if workspace_repo_needs_seed; then - if [ -n "${WORKSPACE_REPO_URL:-}" ]; then - # A private HTTPS clone URL can carry a deploy token in its userinfo. - # Printing it publishes the credential to container logs, and git would - # persist the same string as remote.origin.url on a volume any code-server - # user can read, so the credential would outlive WORKSPACE_TOKEN and be - # recoverable independently of it. Log a redacted form, and rewrite the - # stored remote to the same URL without userinfo after cloning. - redacted_url="$(printf '%s' "${WORKSPACE_REPO_URL}" | sed -E 's#(://)[^/@]*@#\1#')" - if [ -d "${WORKSPACE_DIR}/.git" ]; then - echo "workspace: repairing sticky empty git init at ${WORKSPACE_DIR} from ${redacted_url}" - # Drop only the broken checkout contents; the parent volume keeps state/. - find "${WORKSPACE_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} + - else - echo "workspace: cloning ${redacted_url} into ${WORKSPACE_DIR}" - fi - git clone --depth "${WORKSPACE_CLONE_DEPTH:-1}" "${WORKSPACE_REPO_URL}" "${WORKSPACE_DIR}" - git -C "${WORKSPACE_DIR}" remote set-url origin "${redacted_url}" +No repository is open yet. - # Sanitizing the remote alone would leave the checkout able to clone once - # and never fetch again: git does not read WORKSPACE_REPO_URL, and this - # image configures no credential helper. Supply the credential through a - # helper that reads the environment at call time, so it is never written - # to the volume while fetch, pull, and push keep working. - # - # The helper is per-repository rather than global, and the variable stays - # in the daemon's environment where WORKSPACE_TOKEN already lives. - if printf '%s' "${WORKSPACE_REPO_URL}" | grep -qE '://[^/@]+@'; then - git -C "${WORKSPACE_DIR}" config credential.helper \ - '!f() { printf "%s\n" "url=${WORKSPACE_REPO_URL}"; }; f' - fi - else - if [ -d "${WORKSPACE_DIR}/.git" ]; then - echo "workspace: sticky empty git init at ${WORKSPACE_DIR} and no WORKSPACE_REPO_URL/WORKSPACE_REPO; leaving it" >&2 - else - echo "workspace: initializing an empty repository at ${WORKSPACE_DIR}" - git init --quiet "${WORKSPACE_DIR}" - fi - fi +Connect GitHub in the console, pick a repository, and this IDE will open +`/workspace/{workspace_id}` for that checkout. Product source is never the +default folder. +EOF fi # The IDE door is reached only through the console's /IDE edge proxy on the @@ -137,7 +89,7 @@ shutdown() { } trap shutdown TERM INT -echo "workspace: chat door on :${OPENWORK_PORT}, IDE door on :${CODE_SERVER_PORT}, both over ${WORKSPACE_DIR}" +echo "workspace: chat door on :${OPENWORK_PORT}, IDE door on :${CODE_SERVER_PORT}, welcome=${WELCOME_DIR} (no boot clone)" # --- IDE-006: co-located editor substrate --------------------------------- # Remote commonplace-api cannot see these inodes. When the binary is in the @@ -151,29 +103,41 @@ if [ -x /usr/local/bin/commonplace-api ]; then export COMMONPLACE_API_KEY="${COMMONPLACE_API_KEY:-${WORKSPACE_TOKEN}}" export COMMONPLACE_DATA_DIR="${COMMONPLACE_DATA_DIR:-${EDITOR_SUBSTRATE_STATE_DIR}/data}" export COMMONPLACE_INSTANCE_ID="${COMMONPLACE_INSTANCE_ID:-workspace-editor}" - export COMMONPLACE_SERVICE_ALLOWED_TENANTS="${COMMONPLACE_SERVICE_ALLOWED_TENANTS:-Travis-Gilbert}" + # Never default a product tenant in env (SPEC tenancy law). Empty allow-list + # means the co-located API uses its own identity path; do not inject Travis-Gilbert. + if [ -n "${COMMONPLACE_SERVICE_ALLOWED_TENANTS:-}" ]; then + export COMMONPLACE_SERVICE_ALLOWED_TENANTS + else + unset COMMONPLACE_SERVICE_ALLOWED_TENANTS || true + fi # Local service-key registry only. Never pull console cookie secrets or the # shared control-plane pepper into this container. unset THEOREM_CONTROL_DATABASE_URL THEOREM_API_KEY_PEPPER || true - echo "workspace: editor substrate on :${EDITOR_SUBSTRATE_PORT} over ${WORKSPACE_DIR}" + echo "workspace: editor substrate on :${EDITOR_SUBSTRATE_PORT} (bootstrap deferred until a workspace is provisioned)" # Bind [::]:PORT like the hosted API so private-network doctor probes reach it. # Do not leak this PORT into chat or code-server children. env PORT="${EDITOR_SUBSTRATE_PORT}" \ COMMONPLACE_API_KEY="${COMMONPLACE_API_KEY}" \ COMMONPLACE_DATA_DIR="${COMMONPLACE_DATA_DIR}" \ COMMONPLACE_INSTANCE_ID="${COMMONPLACE_INSTANCE_ID}" \ - COMMONPLACE_SERVICE_ALLOWED_TENANTS="${COMMONPLACE_SERVICE_ALLOWED_TENANTS}" \ + ${COMMONPLACE_SERVICE_ALLOWED_TENANTS:+COMMONPLACE_SERVICE_ALLOWED_TENANTS="${COMMONPLACE_SERVICE_ALLOWED_TENANTS}"} \ setsid commonplace-api & pids+=($!) - EDITOR_SUBSTRATE_URL="http://127.0.0.1:${EDITOR_SUBSTRATE_PORT}" \ - EDITOR_SUBSTRATE_STATE_DIR="${EDITOR_SUBSTRATE_STATE_DIR}" \ - EDITOR_SUBSTRATE_ENV_FILE="${EDITOR_SUBSTRATE_ENV_FILE}" \ - THEOREM_EDITOR_API_KEY="${COMMONPLACE_API_KEY}" \ - WORKSPACE_DIR="${WORKSPACE_DIR}" \ - node /usr/local/bin/bootstrap-editor-substrate.mjs \ - || echo "workspace: editor substrate bootstrap failed; starting doors without project_id" >&2 + # WT4: do not createProject against a product checkout at boot. Provision + # API will bootstrap per /workspace/{workspace_id} later. + if [ -n "${WORKSPACE_PROVISION_BOOTSTRAP:-}" ]; then + EDITOR_SUBSTRATE_URL="http://127.0.0.1:${EDITOR_SUBSTRATE_PORT}" \ + EDITOR_SUBSTRATE_STATE_DIR="${EDITOR_SUBSTRATE_STATE_DIR}" \ + EDITOR_SUBSTRATE_ENV_FILE="${EDITOR_SUBSTRATE_ENV_FILE}" \ + THEOREM_EDITOR_API_KEY="${COMMONPLACE_API_KEY}" \ + WORKSPACE_DIR="${WORKSPACE_DIR}" \ + node /usr/local/bin/bootstrap-editor-substrate.mjs \ + || echo "workspace: editor substrate bootstrap failed; starting doors without project_id" >&2 + else + echo "workspace: skipping editor createProject at boot (SPEC WT4)" + fi if [ -f "${EDITOR_SUBSTRATE_ENV_FILE}" ]; then # shellcheck disable=SC1090 diff --git a/packaging/workspace/railway.toml b/packaging/workspace/railway.toml index 15164f18..a1ad52a5 100644 --- a/packaging/workspace/railway.toml +++ b/packaging/workspace/railway.toml @@ -19,24 +19,19 @@ # Optional environment (IDE substrate / theorem-vscode): # Co-located commonplace-api is built into the image when # INCLUDE_EDITOR_SUBSTRATE=1 (Dockerfile default). Entrypoint starts it on -# :50090, bootstraps createProject(/workspace/repo), and exports -# THEOREM_EDITOR_* for the pack. Railway env overrides still win when set. +# :50090. Per-workspace createProject is deferred until provision (WT4). # THEOREM_ACP_WS_URL hosted ACP websocket # THEOREM_ACP_TOKEN bearer for ACP (may equal editor key) # THEOREM_CONSOLE_ORIGIN deep links (default v2 console) # +# Retired (SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 WT4 — entrypoint exits 78 if set): +# WORKSPACE_REPO / WORKSPACE_REPO_URL +# Checkouts are /workspace/{workspace_id} after Connect GitHub + picker. +# # Optional environment: -# WORKSPACE_REPO_URL Cloned into WORKSPACE_DIR on a fresh or sticky-empty -# volume. Absent, see WORKSPACE_REPO. -# WORKSPACE_REPO owner/name (e.g. Travis-Gilbert/CommonPlace). When -# WORKSPACE_REPO_URL is unset, composed with -# THEOREM_GIT_TOKEN into an HTTPS clone URL so the -# token is not duplicated into a second secret. -# THEOREM_GIT_TOKEN GitHub token used only when composing the clone URL. # THEOREM_MCP_URL The head's graph door (OW2). Absent, the head runs # without the graph and says so. # THEOREM_API_KEY Bearer credential for the above. -# THEOREM_TENANT_ID Tenant scope for the above. # Deliberately NOT set here: # COMMONPLACE_ACTIVE_WORKSPACE_SECRET # The console's cookie signing key is symmetric, so a diff --git a/scripts/check-multitenant.mjs b/scripts/check-multitenant.mjs new file mode 100644 index 00000000..96132112 --- /dev/null +++ b/scripts/check-multitenant.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +// SOURCING: none. SPEC-COMMONPLACE-WORKSPACE-TENANCY-1.0 WT8 first cut. +// Fails when packaging reintroduces boot-clone or image ENV that names a tenant/repo. + +import { readFileSync, existsSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const problems = []; + +function read(rel) { + const full = path.join(root, rel); + if (!existsSync(full)) { + problems.push(`missing: ${rel}`); + return ''; + } + return readFileSync(full, 'utf8'); +} + +const entry = read('packaging/workspace/entrypoint.sh'); +if (entry) { + const activeClone = entry + .split('\n') + .filter((line) => /^\s*git clone\b/.test(line)); + if (activeClone.length) { + problems.push('entrypoint.sh: boot-time git clone still present'); + } + if (!/WORKSPACE_REPO/.test(entry) || !/retired/.test(entry)) { + problems.push('entrypoint.sh: must refuse WORKSPACE_REPO (retired)'); + } + // Must not compose a clone URL from THEOREM_GIT_TOKEN anymore. + if (/x-access-token:\$\{THEOREM_GIT_TOKEN\}/.test(entry)) { + problems.push('entrypoint.sh: still embeds THEOREM_GIT_TOKEN in clone URL'); + } +} + +const docker = read('packaging/workspace/Dockerfile'); +if (docker) { + if (/COMMONPLACE_SERVICE_ALLOWED_TENANTS=Travis-Gilbert/.test(docker)) { + problems.push('Dockerfile: default tenant ENV Travis-Gilbert'); + } + if (/WORKSPACE_DIR=\/workspace\/repo/.test(docker)) { + problems.push('Dockerfile: WORKSPACE_DIR still /workspace/repo'); + } + if (/COMMONPLACE_WORKSPACE_TENANT_ALLOWED_ROOTS=.*workspace\/repo/.test(docker)) { + problems.push('Dockerfile: tenant roots still pin /workspace/repo'); + } +} + +const railway = read('packaging/workspace/railway.toml'); +if (railway && /WORKSPACE_REPO_URL\s+Cloned/.test(railway)) { + problems.push('railway.toml: still documents WORKSPACE_REPO as optional clone'); +} + +const opener = read('apps/theorem-vscode/src/agent/session-opener.ts'); +if (opener && (/['"]\/workspace\/repo['"]/.test(opener))) { + problems.push('session-opener.ts: hardcoded /workspace/repo cwd fallback'); +} + +if (problems.length) { + console.error('gate:multitenant FAILED'); + for (const p of problems) console.error(` - ${p}`); + process.exit(1); +} +console.log('gate:multitenant ok (WT8 first cut)');