From 8635c4b24a7b2785a290477e2a742b081f54af01 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 17:19:03 -0700 Subject: [PATCH 1/7] Add tests for setup-time corbits-tools publish Signup and seedTenant must not pack tarballs. workbench setup publishes the platform registry onto the root tenant; descendants inherit it. Dirty src/ fails publish, not bench create. CL-7071 --- packages/cli/test/seed.test.ts | 1 - packages/cli/test/setup.test.ts | 139 +++++++++++++++++- packages/hub-client/test/seed.test.ts | 34 ++--- .../test/tool-registry-coverage.test.ts | 13 +- .../test/complete-credential.test.ts | 26 +--- packages/onboarding/test/provision.test.ts | 26 +--- packages/onboarding/test/routes.test.ts | 9 +- scripts/e2e/local-rip.test.ts | 39 +++-- 8 files changed, 190 insertions(+), 97 deletions(-) diff --git a/packages/cli/test/seed.test.ts b/packages/cli/test/seed.test.ts index a26a36b05..0afc3930f 100644 --- a/packages/cli/test/seed.test.ts +++ b/packages/cli/test/seed.test.ts @@ -37,7 +37,6 @@ function deps(overrides: Partial & Pick): SeedDeps { outcome: "pushed" as const, commitSha: "a".repeat(40), }), - publishToolRegistry: async () => undefined, log, ...overrides, }; diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index 94b3be7b3..d1ce6aad1 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -23,6 +23,7 @@ const CONFIG: SetupConfig = { }; const okDbSetup = async () => {}; +const noopPublishToolRegistry = async () => undefined; describe("runSetup", () => { test("fresh run initializes, provisions, and reports what remains", async () => { @@ -49,6 +50,7 @@ describe("runSetup", () => { runDbSetup: async () => { dbSetupRuns += 1; }, + publishToolRegistry: noopPublishToolRegistry, log, }); @@ -56,6 +58,9 @@ describe("runSetup", () => { const output = lines.join("\n"); expect(output).toContain("created administrator admin@example.com"); expect(output).toContain("created bench workbench"); + expect(output).toContain( + `published the platform corbits-tools registry onto bench workbench (${TENANT_ID})`, + ); expect(output).toContain("role defaults in place: admin, member, owner"); expect(output).toContain("ANTHROPIC_API_KEY"); expect(output).toContain("workbench seed"); @@ -63,6 +68,7 @@ describe("runSetup", () => { test("re-run reports skips instead of duplicating", async () => { const { lines, log } = collector(); + const publishCalls: { tenantId: string }[] = []; const api = fakeAPI((method, path) => { if (method === "POST" && path === "/api/auth/sign-in/email") return signUpResponse(); @@ -78,13 +84,27 @@ describe("runSetup", () => { return undefined; }); - await runSetup({ config: CONFIG, api, runDbSetup: okDbSetup, log }); + await runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: async (args) => { + publishCalls.push({ tenantId: args.tenantId }); + return []; + }, + log, + }); + + expect(publishCalls).toEqual([{ tenantId: TENANT_ID }]); const output = lines.join("\n"); expect(output).toContain( "administrator admin@example.com already exists (skipped)", ); expect(output).toContain("bench workbench already exists (skipped)"); + expect(output).toContain( + "platform corbits-tools registry already on bench workbench (skipped)", + ); }); test("a database initialization failure stops the run before any hub call", async () => { @@ -96,7 +116,13 @@ describe("runSetup", () => { throw new CliError("database initialization failed", "start Postgres"); }; expect( - runSetup({ config: CONFIG, api, runDbSetup: failing, log }), + runSetup({ + config: CONFIG, + api, + runDbSetup: failing, + publishToolRegistry: noopPublishToolRegistry, + log, + }), ).rejects.toThrow("database initialization failed"); }); @@ -118,7 +144,13 @@ describe("runSetup", () => { }); expect( - runSetup({ config: CONFIG, api, runDbSetup: okDbSetup, log }), + runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: noopPublishToolRegistry, + log, + }), ).rejects.toThrow(/zero roles/); }); @@ -134,7 +166,13 @@ describe("runSetup", () => { let caught: unknown; try { - await runSetup({ config: CONFIG, api, runDbSetup: okDbSetup, log }); + await runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: noopPublishToolRegistry, + log, + }); } catch (error) { caught = error; } @@ -167,7 +205,13 @@ describe("runSetup", () => { let caught: unknown; try { - await runSetup({ config: CONFIG, api, runDbSetup: okDbSetup, log }); + await runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: noopPublishToolRegistry, + log, + }); } catch (error) { caught = error; } @@ -191,7 +235,90 @@ describe("runSetup", () => { }); expect( - runSetup({ config: CONFIG, api, runDbSetup: okDbSetup, log }), + runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: noopPublishToolRegistry, + log, + }), ).rejects.toThrow(/status 409/); }); + + test("publishes corbits-tools onto the created tenant", async () => { + const { log } = collector(); + const calls: { tenantId: string; hubUrl: string }[] = []; + const api = fakeAPI((method, path) => { + if (method === "POST" && path === "/api/auth/sign-in/email") + return signInMissing(); + if (method === "POST" && path === "/api/auth/sign-up/email") + return signUpResponse(); + if (method === "POST" && path === "/api/tenants") + return { status: 201, data: tenantRow() }; + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/roles`) + ) + return rolesResponse(["owner", "admin", "member"]); + return undefined; + }); + + await runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: async (args) => { + calls.push({ tenantId: args.tenantId, hubUrl: args.hubUrl }); + return [ + { + filename: "memory-tools-0.0.0.tgz", + commit: "c", + integrity: "i", + }, + ]; + }, + log, + }); + + expect(calls).toEqual([{ tenantId: TENANT_ID, hubUrl: CONFIG.hubUrl }]); + }); + + test("a publisher throw is a setup CliError, not a seed error", async () => { + const { log } = collector(); + const api = fakeAPI((method, path) => { + if (method === "POST" && path === "/api/auth/sign-in/email") + return signInMissing(); + if (method === "POST" && path === "/api/auth/sign-up/email") + return signUpResponse(); + if (method === "POST" && path === "/api/tenants") + return { status: 201, data: tenantRow() }; + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/roles`) + ) + return rolesResponse(["owner", "admin", "member"]); + return undefined; + }); + + let caught: unknown; + try { + await runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: async () => { + throw new Error("src/ changed without bumping version"); + }, + log, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CliError); + expect((caught as CliError).message).toContain( + "publishing the corbits-tools package-registry asset failed", + ); + expect((caught as CliError).fix).toContain("workbench setup"); + expect((caught as CliError).fix).not.toContain("workbench seed"); + }); }); diff --git a/packages/hub-client/test/seed.test.ts b/packages/hub-client/test/seed.test.ts index d4f672ad0..37a773b93 100644 --- a/packages/hub-client/test/seed.test.ts +++ b/packages/hub-client/test/seed.test.ts @@ -59,7 +59,6 @@ function args( }, model: MODEL, pushWorkflow: recordingPusher().push, - publishToolRegistry: async () => undefined, log, sleep: instantSleep, runStartTimeoutMs: 3, @@ -355,12 +354,17 @@ describe("seedTenant", () => { }); }); - test("publishes the tenant's corbits-tools registry before deploying any workflow", async () => { - const { push } = recordingPusher(); - const publishCalls: { tenantId: string; hubUrl: string }[] = []; - let assetCreated = false; + test("deploys workflows without publishing the corbits-tools registry", async () => { + const { pushes, push } = recordingPusher(); + const registryListCalls: string[] = []; let runsCalls = 0; const handler: FakeHandler = (method, path, _body) => { + if ( + path.includes("kind=package-registry") || + path.includes("/tarballs") + ) { + registryListCalls.push(`${method} ${path}`); + } const base = baseRoutes(method, path); if (base) return base; if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) @@ -409,26 +413,12 @@ describe("seedTenant", () => { api: fakeAPI(handler), pushWorkflow: push, workflows: echoOnly, - publishToolRegistry: async (publishArgs) => { - publishCalls.push({ - tenantId: publishArgs.tenantId, - hubUrl: publishArgs.hubUrl, - }); - assetCreated = true; - }, }), ); - expect(publishCalls).toEqual([ - { tenantId: TENANT_ID, hubUrl: "http://localhost:3000" }, - ]); - // The fake stands in for the real publish call, which must run - // before any workflow deploy call reaches the fake API — proven - // indirectly here by the deploy succeeding at all, since the fake - // handler above never special-cases ordering; the direct ordering - // guarantee lives in `seedTenant`'s own source (publish happens - // immediately after the grant loop, before the workflow loop). - expect(assetCreated).toBe(true); + expect(registryListCalls).toEqual([]); + expect(pushes).toHaveLength(1); + expect(pushes[0]?.remoteUrl).toContain("/echo.git"); }); test("fresh run pushes, deploys, and confirms the assistant workflow", async () => { diff --git a/packages/hub-client/test/tool-registry-coverage.test.ts b/packages/hub-client/test/tool-registry-coverage.test.ts index 9cbba4415..3edec6212 100644 --- a/packages/hub-client/test/tool-registry-coverage.test.ts +++ b/packages/hub-client/test/tool-registry-coverage.test.ts @@ -1,10 +1,11 @@ // Registry drift guard: every `@corbits`-scoped tool-package pin a -// workflow `seedTenant` actually deploys must be published by -// `CORBITS_TOOL_PACKAGE_DIRS`, or the closure resolver fails the -// launch with "unknown registry" the moment a fresh bench seeds. This -// suite fails loud, naming the exact missing package, so adding a pin -// to a default workflow without also registering its package dir is -// caught here instead of at a stranger's first login. +// workflow `seedTenant` actually deploys must be in +// `CORBITS_TOOL_PACKAGE_DIRS` so `workbench setup` publishes it onto +// the root. Missing a dir fails the closure resolver with "unknown +// registry" the moment a descendant launches. This suite fails loud, +// naming the exact missing package, so adding a pin to a default +// workflow without also registering its package dir is caught here +// instead of at a stranger's first login. import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 7b2ccf72e..c008bbb7f 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -1,9 +1,5 @@ import { describe, expect, test } from "bun:test"; -import type { - ApiCall, - ToolRegistryPublisher, - WorkflowPusher, -} from "@workbench/hub-client"; +import type { ApiCall, WorkflowPusher } from "@workbench/hub-client"; import { CATALOG_SEEDS, SETUP_AGENT_ASSET_NAME, @@ -36,7 +32,6 @@ const noopPush: WorkflowPusher = async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }); -const noopPublishToolRegistry: ToolRegistryPublisher = async () => undefined; // Stubs for the provider/credential half of the shared persist-and-seed // sequence (CL-6394) — paired with every stubbed `seedCatalogFn` so a @@ -296,7 +291,6 @@ describe("completeCredentialSetup", () => { provider: "anthropic", apiKey: "sk-ant-never-probed", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async (args) => { @@ -331,7 +325,6 @@ describe("completeCredentialSetup", () => { provider: "anthropic", apiKey: "sk-ant-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -363,7 +356,6 @@ describe("completeCredentialSetup", () => { provider: "anthropic", apiKey: "sk-ant-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async (args) => { @@ -414,7 +406,6 @@ describe("completeCredentialSetup", () => { provider: "openai", apiKey: "sk-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async (args) => { @@ -465,7 +456,6 @@ describe("completeCredentialSetup", () => { provider: "groq", apiKey: "gsk-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async (args) => { @@ -520,7 +510,6 @@ describe("completeCredentialSetup", () => { apiKey: "hf_oauth_minted", credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async (args) => { @@ -681,7 +670,6 @@ describe("completeCredentialSetup", () => { apiKey: "hf_freshly_minted_token", credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, // The real seedCatalog runs here (not mocked) so the rotation // actually happens through ensureCredential; only the workflow @@ -831,7 +819,6 @@ describe("completeCredentialSetup", () => { provider: "anthropic", apiKey: "sk-ant-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), @@ -1283,7 +1270,6 @@ describe("completeCredentialSetup", () => { provider: "anthropic", apiKey: "sk-ant-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -1333,7 +1319,6 @@ describe("completeCredentialSetup", () => { provider: "huggingface", apiKey: "hf_pasted_pat", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async (args) => { @@ -1386,7 +1371,6 @@ describe("completeCredentialSetup", () => { provider: "anthropic", apiKey: "sk-ant-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), @@ -1487,7 +1471,6 @@ describe("testAndPersistCredential (the fast half)", () => { provider: "anthropic", apiKey: "sk-ant-never-probed", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async (args) => { @@ -1530,7 +1513,6 @@ describe("testAndPersistCredential (the fast half)", () => { provider: "openrouter", apiKey: "sk-or-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, ...stubPersistFns, seedCatalogFn: async (args) => { @@ -1565,7 +1547,6 @@ describe("testAndPersistCredential (the fast half)", () => { provider: "anthropic", apiKey: "sk-ant-good", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -1601,7 +1582,6 @@ describe("ensureSeeded (the slow half)", () => { cookies: ["session=abc"], hubUrl: "http://localhost:3000", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, tenant: TENANT, provider: "anthropic", @@ -1640,7 +1620,6 @@ describe("ensureSeeded (the slow half)", () => { cookies: ["session=abc"], hubUrl: "http://localhost:3000", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, tenant: TENANT, provider: "anthropic", @@ -1826,7 +1805,6 @@ describe("ensureSeeded (the slow half)", () => { cookies: ["session=abc"], hubUrl: "http://localhost:3000", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, tenant: TENANT, provider: "anthropic", @@ -1901,7 +1879,6 @@ describe("ensureSeeded (the slow half)", () => { cookies: ["session=abc"], hubUrl: "http://localhost:3000", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, tenant: TENANT, provider: "anthropic", @@ -1931,7 +1908,6 @@ describe("ensureSeeded (the slow half)", () => { cookies: ["session=abc"], hubUrl: "http://localhost:3000", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, tenant: TENANT, provider: "anthropic", diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index 5f667d023..703b2d3d0 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -4,11 +4,7 @@ import { SEED_GRANTS, SETUP_AGENT_ASSET_NAME, } from "@workbench/hub-client"; -import type { ApiCall } from "@workbench/hub-client"; -import type { - WorkflowPusher, - ToolRegistryPublisher, -} from "@workbench/hub-client"; +import type { ApiCall, WorkflowPusher } from "@workbench/hub-client"; import { isFullySeeded, personalTenantSlug, @@ -32,7 +28,6 @@ const noopPush: WorkflowPusher = async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }); -const noopPublishToolRegistry: ToolRegistryPublisher = async () => undefined; function collector() { const lines: string[] = []; @@ -90,7 +85,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -150,7 +144,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, displayName: "Alice's Lab", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -186,7 +179,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, displayName: "Alice's Lab", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }), ).rejects.toThrow(/slug conflict/); @@ -257,7 +249,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, displayName: "Alice's Lab", pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log, }); @@ -295,7 +286,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log, }); @@ -306,7 +296,12 @@ describe("provisionPersonalTenantIfNeeded", () => { test("zero principals with a seed model configured: provisions under the operator tenant and seeds the default workflow", async () => { let principalsCalls = 0; const startedRuns: string[] = []; + const tarballPuts: string[] = []; const api: ApiCall = async (method, path, body) => { + if (path.includes("/tarballs/")) { + tarballPuts.push(`${method} ${path}`); + throw new Error(`signup must not pack: ${method} ${path}`); + } if (method === "GET" && path === "/api/me/principals") { principalsCalls += 1; if (principalsCalls === 1) { @@ -481,7 +476,6 @@ describe("provisionPersonalTenantIfNeeded", () => { operatorTenantId: "ten_operator", seedModel: MODEL, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log, }); @@ -491,6 +485,7 @@ describe("provisionPersonalTenantIfNeeded", () => { tenantSlug: TENANT_SLUG, seeded: true, }); + expect(tarballPuts).toEqual([]); }); test("a retry after tenant creation succeeded but seeding failed re-seeds instead of reporting a plain existing member", async () => { @@ -692,7 +687,6 @@ describe("provisionPersonalTenantIfNeeded", () => { displayName: "Alice's Lab", seedModel: MODEL, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); await expect(firstAttempt).rejects.toThrow(/asset service unavailable/); @@ -708,7 +702,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, seedModel: MODEL, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log, }); @@ -841,7 +834,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, // No seedModel needed: nothing left to seed on the workflow side. pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -932,7 +924,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -1012,7 +1003,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, // No seedModel — hub without ANTHROPIC_API_KEY. pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -1215,7 +1205,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, seedModel: MODEL, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); @@ -1451,7 +1440,6 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, seedModel: MODEL, pushWorkflow: noopPush, - publishToolRegistry: noopPublishToolRegistry, log: collector().log, }); diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts index 11c526f69..cf5a9a1d1 100644 --- a/packages/onboarding/test/routes.test.ts +++ b/packages/onboarding/test/routes.test.ts @@ -484,10 +484,11 @@ describe("POST /complete", () => { expect(body.error.code).toBe("credential_setup_failed"); }); - // CL-6360: the owner hit exactly this live — a `CliError` wrapping a - // package-registry freshness-check failure whose message names an - // absolute path on the hub's own disk. That text must never reach the - // client; only a fixed consumer sentence and a refId may. + // CL-6360: a `CliError` whose message names an absolute path on the + // hub's own disk must never reach the client; only a fixed consumer + // sentence and a refId may. The payload here is a historical + // freshness-check wrap; signup no longer packs, but the redaction + // still applies to any seed/setup CliError that names a path. test("a CliError naming an absolute file path never reaches the client", async () => { const lines: string[] = []; const routes = createOnboardingRoutes({ diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts index 16a31dfc4..320b74d8c 100644 --- a/scripts/e2e/local-rip.test.ts +++ b/scripts/e2e/local-rip.test.ts @@ -17,10 +17,12 @@ // hiding it: the "assistant" default workflow pins // `@corbits/memory-tools`, and that pin only resolved once an operator // had published a `package-registry`-kind asset named "corbits-tools" -// carrying its tarball. `seedTenant` now publishes that asset itself -// (`@corbits/tool-registry-publish`, wired in at -// `packages/hub-client/src/seed.ts`) ahead of deploying any workflow, -// so this suite asserts a full seed rather than a documented skip. +// carrying its tarball. CL-7071 moved that publish off `seedTenant` +// onto `workbench setup` (the root tenant; descendants inherit). This +// suite has no operator-root split (`OPERATOR_TENANT_ID` is unset), so +// the provisioned personal bench *is* the root: an explicit +// `publishCorbitsToolsRegistry` hop onto that tenant stands in for +// setup, then `ensureSeeded` deploys without packing. // // Stubbing note: onboarding's own `POST /api/onboarding/complete` route // (`testAndPersistCredential`, from `@workbench/onboarding`'s @@ -58,6 +60,7 @@ import { DEFAULT_WORKFLOWS, isLiveDeploymentStatus, parseAs, + publishCorbitsToolsRegistry, seedTenant, type ApiCall, } from "../../packages/hub-client/src/index.ts"; @@ -369,16 +372,24 @@ describe.skipIf(databaseUrl === undefined)( } } - // CL-6057 closed the platform gap the earlier version of this - // suite documented: `seedTenant` (via `ensureSeeded`) now - // publishes the tenant's `corbits-tools` package-registry asset - // — packing `@corbits/memory-tools` into a self-contained - // tarball through `@corbits/tool-registry-publish` — ahead of - // deploying any workflow, so the "assistant" default workflow's - // `@corbits/memory-tools` pin resolves instead of failing the - // closure resolver with "unknown registry". This hop proves the - // real, unmodified connect flow fully seeds a fresh bench: every - // default workflow deploys, with none skipped. + // CL-7071: seedTenant/ensureSeeded no longer pack. This suite's + // provisioned personal bench is the root (`OPERATOR_TENANT_ID` + // unset), so publish `corbits-tools` onto it the same way + // `workbench setup` does onto the operator-created bench. Then + // ensureSeeded deploys assistant without packing. + await hop( + "publish corbits-tools onto the provisioned root bench (setup's job, not seed's)", + async () => { + await publishCorbitsToolsRegistry({ + api: hubApi, + cookies: user.cookies, + hubUrl: hub.baseUrl, + tenantId: tenant.tenantId, + log: () => undefined, + }); + }, + ); + await hop( "the real, unmodified connect flow fully seeds every default workflow, including 'assistant'", async () => { From 543e28389190569a2de4ea02cf58ba31a29dc612 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 17:19:15 -0700 Subject: [PATCH 2/7] Publish corbits-tools at setup, not seed workbench setup packs and publishes the platform toolbox onto the root tenant. seedTenant deploys workflows only and inherits tarballs from the ancestor registry. Freshness stays a publish/build failure. CL-7071 --- apps/hub/src/index.ts | 10 ++-- packages/cli/src/seed.ts | 12 ++--- packages/cli/src/setup.ts | 45 +++++++++++++++-- packages/hub-client/src/index.ts | 5 ++ packages/hub-client/src/seed.ts | 48 +++++-------------- packages/onboarding/src/bench-provisioning.ts | 13 +---- .../onboarding/src/complete-credential.ts | 17 ++----- packages/onboarding/src/provision.ts | 21 +------- packages/onboarding/src/routes.ts | 12 ++--- packages/tool-registry-publish/src/publish.ts | 8 ++-- scripts/checks/tool-package-freshness.ts | 9 ++-- 11 files changed, 88 insertions(+), 112 deletions(-) diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 66db0cc6d..1e1e521b3 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -368,11 +368,11 @@ const MAX_TARBALL_BYTES = 10 * 1024 * 1024; // registries on a name collision — the platform-native alternative to // npm publishing the CL-5999 capability audit called for. Routing the // `@corbits` scope at this registry name means a `@corbits/*` pin -// resolves only once an operator seeds a `package-registry` asset named -// `CORBITS_TOOLS_REGISTRY` with the package's tarball — `workbench -// seed`'s `seedTenant` does exactly that, via -// `@corbits/tool-registry-publish`, ahead of deploying any workflow -// that pins a `@corbits/*` package; until then, resolution fails loud +// resolves only once an operator publishes a `package-registry` asset +// named `CORBITS_TOOLS_REGISTRY` with the package's tarball — +// `workbench setup` does exactly that onto the root tenant via +// `@corbits/tool-registry-publish`; descendants inherit it, and +// `seedTenant` does not pack. Until then, resolution fails loud // rather than silently falling through to npmjs (which could never // carry an unpublished scope anyway). const TENANT_PREFIX = "/api/tenants/:tenantId"; diff --git a/packages/cli/src/seed.ts b/packages/cli/src/seed.ts index 6f678f864..8e3337ca2 100644 --- a/packages/cli/src/seed.ts +++ b/packages/cli/src/seed.ts @@ -1,7 +1,8 @@ // `workbench seed`: authenticate as the administrator, resolve the -// configured bench by slug, and seed it with the default workflow set -// through `@workbench/hub-client`'s `seedTenant`. Safe to re-run; every -// skipped step says so. +// configured bench by slug, and deploy the default workflow set +// through `@workbench/hub-client`'s `seedTenant`. Does not pack +// tarballs — `workbench setup` publishes `corbits-tools` onto the +// root. Safe to re-run; every skipped step says so. import { paginatedSchema, PrincipalSummary, TenantResponse } from "@intx/types"; import { @@ -13,7 +14,6 @@ import { DEFAULT_WORKFLOWS, type ApiCall, type DefaultWorkflow, - type ToolRegistryPublisher, type WorkflowPusher, } from "@workbench/hub-client"; import { CliError } from "@workbench/hub-client"; @@ -23,8 +23,6 @@ export type SeedDeps = { config: SeedConfig; api: ApiCall; pushWorkflow: WorkflowPusher; - /** Passed through to `seedTenant`; a test double replaces the real corbits-tools publish the same way `pushWorkflow` replaces the real git push. */ - publishToolRegistry?: ToolRegistryPublisher; log: (line: string) => void; sleep?: (ms: number) => Promise; runStartTimeoutMs?: number; @@ -128,8 +126,6 @@ export async function runSeed( log, workflows: resolvedWorkflows, }; - if (deps.publishToolRegistry !== undefined) - seedArgs.publishToolRegistry = deps.publishToolRegistry; if (deps.sleep !== undefined) seedArgs.sleep = deps.sleep; if (deps.runStartTimeoutMs !== undefined) seedArgs.runStartTimeoutMs = deps.runStartTimeoutMs; diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index c6ff9947d..c232d6dbc 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -1,8 +1,9 @@ // `workbench setup`: initialize the database, provision the -// bench through the hub's native tenant-creation route, report -// the role defaults the platform created, and state exactly what the -// operator must still supply. Safe to re-run; every skipped step says -// so. +// bench through the hub's native tenant-creation route, publish +// the platform `corbits-tools` registry onto that tenant (the +// root; descendants inherit it), report the role defaults the +// platform created, and state exactly what the operator must +// still supply. Safe to re-run; every skipped step says so. import { paginatedSchema, @@ -14,7 +15,9 @@ import { authenticate, parseAs, CliError, + publishCorbitsToolsRegistry, type ApiCall, + type ToolRegistryPublisher, } from "@workbench/hub-client"; import { MODEL_CREDENTIAL_VARIABLES, type SetupConfig } from "./config"; @@ -24,6 +27,12 @@ export type SetupDeps = { /** Runs the shared database-initialization script; throws CliError. */ runDbSetup: () => Promise; log: (line: string) => void; + /** + * Publishes `corbits-tools` onto the bench this setup creates. + * Defaults to the real packer; tests pass a double so they never + * bundle a tarball. + */ + publishToolRegistry?: ToolRegistryPublisher; }; async function ensureTenant( @@ -132,6 +141,34 @@ export async function runSetup(deps: SetupDeps): Promise { .join(", ")}`, ); + const publishToolRegistry = + deps.publishToolRegistry ?? publishCorbitsToolsRegistry; + try { + const published = await publishToolRegistry({ + api, + cookies: session.cookies, + hubUrl: config.hubUrl, + tenantId, + log, + }); + if (Array.isArray(published) && published.length === 0) { + log( + `platform corbits-tools registry already on bench ${config.orgSlug} (skipped)`, + ); + } else { + log( + `published the platform corbits-tools registry onto bench ${config.orgSlug} (${tenantId})`, + ); + } + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new CliError( + `publishing the corbits-tools package-registry asset failed: ${message}`, + "check the hub logs for the underlying failure, then re-run: workbench setup", + { cause }, + ); + } + log(""); log("setup complete. next: workbench seed"); for (const variable of MODEL_CREDENTIAL_VARIABLES) { diff --git a/packages/hub-client/src/index.ts b/packages/hub-client/src/index.ts index af0c02377..a5bf68c04 100644 --- a/packages/hub-client/src/index.ts +++ b/packages/hub-client/src/index.ts @@ -46,6 +46,11 @@ export { isLiveDeploymentStatus, SETUP_AGENT_ASSET_NAME, } from "./seed"; +export { + publishCorbitsToolsRegistry, + type PublishCorbitsToolsRegistryArgs, + type PublishSummary, +} from "@corbits/tool-registry-publish"; export { CATALOG_SEEDS, deriveWorkbenchHostInferencePreferences, diff --git a/packages/hub-client/src/seed.ts b/packages/hub-client/src/seed.ts index 6abc18f8f..6893a7d2e 100644 --- a/packages/hub-client/src/seed.ts +++ b/packages/hub-client/src/seed.ts @@ -47,10 +47,7 @@ import { import { WORKFLOW_CATALOG } from "@corbits/workflow-catalog"; import { capabilitiesForDeployment } from "@corbits/inference-catalog/offering-capabilities"; import { quirksForDeployment } from "@corbits/inference-catalog/ollama-context-defaults"; -import { - publishCorbitsToolsRegistry, - type PublishCorbitsToolsRegistryArgs, -} from "@corbits/tool-registry-publish"; +import { type PublishCorbitsToolsRegistryArgs } from "@corbits/tool-registry-publish"; import { WORKFLOW_SOURCE_ENTRY } from "@corbits/workflow-source"; import { CliError, SidecarUnavailableError } from "./errors"; import { DEFAULT_SKILLS } from "./default-skills"; @@ -803,11 +800,12 @@ export type SeedTenant = { }; /** - * Publishes the tenant's `corbits-tools` package-registry asset ahead - * of any workflow deploy. Defaults to the real - * `publishCorbitsToolsRegistry`; a test double can replace it so a - * unit test never bundles a real tarball or dials the hub's tarball - * REST routes, the same way `pushWorkflow` replaces the real git push. + * Publishes a tenant's `corbits-tools` package-registry asset. Defaults + * to the real `publishCorbitsToolsRegistry`; a test double can replace + * it so a unit test never bundles a real tarball or dials the hub's + * tarball REST routes, the same way `pushWorkflow` replaces the real + * git push. Used by `workbench setup` (the root tenant), not by + * `seedTenant`. */ export type ToolRegistryPublisher = ( args: Omit, @@ -820,7 +818,6 @@ export type SeedTenantArgs = { tenant: SeedTenant; model: ModelSource; pushWorkflow: WorkflowPusher; - publishToolRegistry?: ToolRegistryPublisher; log: (line: string) => void; workflows?: readonly DefaultWorkflow[]; sleep?: (ms: number) => Promise; @@ -848,6 +845,10 @@ export type SeedTenantArgs = { * created tenant (the first-login provisioning hook, in particular) * seeds it without re-authenticating or re-resolving the tenant by * slug. + * + * Grants + workflows/routines only. Assumes the tenant hierarchy + * already exposes `corbits-tools` (published at `workbench setup` onto + * the root); seed does not pack tarballs or run freshness. */ export async function seedTenant(args: SeedTenantArgs): Promise { const { @@ -882,33 +883,6 @@ export async function seedTenant(args: SeedTenantArgs): Promise { await plantDefaultSkills(api, cookies, tenant.tenantId, log); - // Deploying any workflow that pins a `@corbits/*` tool package (the - // "assistant" default workflow pins `@corbits/memory-tools`) needs - // the tenant's `corbits-tools` package-registry asset to already - // carry that package's tarball, or the closure resolver fails the - // launch with "unknown registry". Publishing ahead of the deploy - // loop below — idempotent, and cheap relative to a workflow deploy — - // means every seed run is a full seed, not one that skips whichever - // workflow happens to pin an unresolved package. - const publishToolRegistry = - args.publishToolRegistry ?? publishCorbitsToolsRegistry; - try { - await publishToolRegistry({ - api, - cookies, - hubUrl, - tenantId: tenant.tenantId, - log, - }); - } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); - throw new CliError( - `publishing the corbits-tools package-registry asset failed: ${message}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - { cause }, - ); - } - let confirmed = 0; for (const workflow of workflows) { const workflowModel = workflow.modelSource?.(hubUrl) ?? model; diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts index 33633ed30..32d00d60c 100644 --- a/packages/onboarding/src/bench-provisioning.ts +++ b/packages/onboarding/src/bench-provisioning.ts @@ -31,11 +31,7 @@ // composition root decides how a session is minted for a user, and this // module stays out of the auth mechanism entirely. -import { - type ApiCall, - type WorkflowPusher, - type ToolRegistryPublisher, -} from "@workbench/hub-client"; +import { type ApiCall, type WorkflowPusher } from "@workbench/hub-client"; import { ensureSeeded } from "./complete-credential"; import { isFullySeeded } from "./provision"; import { @@ -64,7 +60,6 @@ export type BenchProvisionerDeps = { hubUrl: string; store: PendingSeedStore; pushWorkflow: WorkflowPusher; - publishToolRegistry?: ToolRegistryPublisher; sessionFor: SessionForUser; log: (line: string) => void; logError?: (line: string) => void; @@ -172,11 +167,7 @@ export function createBenchProvisioner( ? { baseURLOverride: seed.baseURLOverride } : {}), }; - const result = await runEnsureSeeded( - deps.publishToolRegistry !== undefined - ? { ...seededArgs, publishToolRegistry: deps.publishToolRegistry } - : seededArgs, - ); + const result = await runEnsureSeeded(seededArgs); if (result.kind === "seeded-pending-agents") { deps.log( diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts index fc9d3925f..350ff396b 100644 --- a/packages/onboarding/src/complete-credential.ts +++ b/packages/onboarding/src/complete-credential.ts @@ -70,7 +70,6 @@ import { type ModelSource, type SeedTenantArgs, type SupportedCredentialProvider, - type ToolRegistryPublisher, type WorkflowPusher, } from "@workbench/hub-client"; import { preferCompletionCapable } from "@workbench/hub-client/model-capability"; @@ -157,8 +156,6 @@ type CommonArgs = { cookies: string[]; hubUrl: string; pushWorkflow: WorkflowPusher; - /** Passed through to `seedTenant`; a test double replaces the real corbits-tools publish the same way `pushWorkflow` replaces the real git push. */ - publishToolRegistry?: ToolRegistryPublisher; log: (line: string) => void; }; @@ -489,11 +486,7 @@ export async function ensureSeeded( confirmDeployments: false, }; try { - await runSeedTenant( - args.publishToolRegistry !== undefined - ? { ...seedTenantArgs, publishToolRegistry: args.publishToolRegistry } - : seedTenantArgs, - ); + await runSeedTenant(seedTenantArgs); } catch (cause) { if (!isSidecarUnavailableError(cause)) throw cause; args.log( @@ -546,14 +539,10 @@ export async function completeCredentialSetup( args.baseURLOverride !== undefined ? { ...baseEnsureSeededArgs, baseURLOverride: args.baseURLOverride } : baseEnsureSeededArgs; - const withPublishToolRegistry = - args.publishToolRegistry !== undefined - ? { ...ensureSeededArgs, publishToolRegistry: args.publishToolRegistry } - : ensureSeededArgs; const seeded = await ensureSeeded( args.seedTenantFn !== undefined - ? { ...withPublishToolRegistry, seedTenantFn: args.seedTenantFn } - : withPublishToolRegistry, + ? { ...ensureSeededArgs, seedTenantFn: args.seedTenantFn } + : ensureSeededArgs, ); if (seeded.kind === "seeded-pending-agents") { diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts index f6390c9a4..1856ba2c6 100644 --- a/packages/onboarding/src/provision.ts +++ b/packages/onboarding/src/provision.ts @@ -21,7 +21,6 @@ import { seedTenant, type ApiCall, type ModelSource, - type ToolRegistryPublisher, type WorkflowPusher, isLiveDeploymentStatus, } from "@workbench/hub-client"; @@ -107,8 +106,6 @@ export type ProvisionArgs = { operatorTenantId?: string; seedModel?: ModelSource; pushWorkflow: WorkflowPusher; - /** Passed through to `seedTenant`; a test double replaces the real corbits-tools publish the same way `pushWorkflow` replaces the real git push. */ - publishToolRegistry?: ToolRegistryPublisher; log: (line: string) => void; /** The closed-by-default access-policy gate. Absent means this hub * runs with no access-policy package wired in at all — never a valid @@ -341,14 +338,7 @@ export async function provisionPersonalTenantIfNeeded( log: args.log, workflows: DEFAULT_WORKFLOWS, }; - await seedTenant( - args.publishToolRegistry !== undefined - ? { - ...existingMemberSeedArgs, - publishToolRegistry: args.publishToolRegistry, - } - : existingMemberSeedArgs, - ); + await seedTenant(existingMemberSeedArgs); return { kind: "existing-member", seeded: true, tenantId: own.tenantId }; } @@ -475,14 +465,7 @@ export async function provisionPersonalTenantIfNeeded( log: args.log, workflows: DEFAULT_WORKFLOWS, }; - await seedTenant( - args.publishToolRegistry !== undefined - ? { - ...provisionedSeedArgs, - publishToolRegistry: args.publishToolRegistry, - } - : provisionedSeedArgs, - ); + await seedTenant(provisionedSeedArgs); return { kind: "provisioned", diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 5eb621d4d..55b9783fb 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -61,7 +61,8 @@ function assertNonEmpty(arr: T[]): asserts arr is [T, ...T[]] { /** * Logs a caught failure's raw detail — the exact text a `CliError` or a * generic `Error` carries, which can and does include absolute file - * paths (see `StaleToolPackageError`) or other internals a user must + * paths (a `CliError` wrapping a publish/freshness failure names the + * package directory on disk) or other internals a user must * never see (CL-6360) — behind a `refId`, then returns the envelope the * client actually renders: a fixed consumer-language `userMessage` plus * that same `refId` so a person can quote it back for support. Never @@ -795,11 +796,10 @@ export function createOnboardingRoutes( return c.json(status, 200); } catch (cause) { // Neither `ProvisionError` nor `CliError` messages are safe to show - // verbatim: `CliError` in particular wraps failures like - // `StaleToolPackageError`, whose text names absolute file paths on - // the hub's own disk (CL-6360). The raw detail is logged behind a - // refId; the client only ever sees a fixed consumer sentence plus - // that refId. + // verbatim: `CliError` in particular can wrap a publish/freshness + // failure whose text names absolute file paths on the hub's own + // disk (CL-6360). The raw detail is logged behind a refId; the + // client only ever sees a fixed consumer sentence plus that refId. const envelope = reportOnboardingError(deps.logError ?? deps.log, { userAction: `credential setup for user ${user.id}`, code: "credential_setup_failed", diff --git a/packages/tool-registry-publish/src/publish.ts b/packages/tool-registry-publish/src/publish.ts index a8e940fc2..e6741145a 100644 --- a/packages/tool-registry-publish/src/publish.ts +++ b/packages/tool-registry-publish/src/publish.ts @@ -236,10 +236,10 @@ export type PublishCorbitsToolsRegistryArgs = { /** * Ensures the tenant's `corbits-tools` package-registry asset exists * and carries every package in `CORBITS_TOOL_PACKAGE_DIRS`, packing - * and pushing whatever is missing. Called ahead of deploying any - * workflow that pins a `@corbits/*` tool package, so the closure - * resolver finds a tarball instead of failing the launch with - * "unknown registry". + * and pushing whatever is missing. `workbench setup` calls this onto + * the root tenant so descendants inherit tarballs; it is not a + * signup/seed side effect. Freshness is owned here: `src/` that moved + * without a version bump fails this publish, not bench create. */ export async function publishCorbitsToolsRegistry( args: PublishCorbitsToolsRegistryArgs, diff --git a/scripts/checks/tool-package-freshness.ts b/scripts/checks/tool-package-freshness.ts index b8779399d..7eba091be 100644 --- a/scripts/checks/tool-package-freshness.ts +++ b/scripts/checks/tool-package-freshness.ts @@ -2,10 +2,11 @@ // a version bump in the same change. // // Tool resolution keys on `name@version`. New source under an unchanged -// version never reaches a running or freshly-launched agent, and the hub's -// publish step rejects it at seed time — which aborts seeding partway and -// leaves the tenant without its `assistant` definition, so every workbench -// template then fails with "No default setup agent found for this workbench". +// version never reaches a running or freshly-launched agent, and +// `publishCorbitsToolsRegistry` (`workbench setup`) rejects it at +// publish time — a publish/build failure, not a bench-create failure. +// Signup/`seedTenant` do not pack, so a dirty `src/` cannot abort +// minting a bench. // // `check:tool-package-pins` is the other half of this class: it compares a // `{ name, version }` pin literal against that package's manifest. It passes From 0767055705e0c961a0ad5e10a465b4b8c177a395 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 25 Aug 2026 17:19:24 -0700 Subject: [PATCH 3/7] Update docs: corbits-tools publishes at setup Point local-dev, local-rip, seed-reconciliation, and CLI/publish READMEs at workbench setup as the one-time registry publish. CL-7071 --- docs/local-dev.md | 9 +++++---- docs/local-rip.md | 12 ++++++------ docs/seed-reconciliation.md | 12 +++++++----- packages/cli/README.md | 10 ++++++---- packages/tool-registry-publish/README.md | 15 +++++++++------ 5 files changed, 33 insertions(+), 25 deletions(-) diff --git a/docs/local-dev.md b/docs/local-dev.md index 33fb7c477..81c33b487 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -33,12 +33,13 @@ applies what hasn't already run. A workflow that pins a `@corbits/*` tool package (e.g. **assistant** pinning `@corbits/memory-tools`) resolves that pin from a `package-registry` asset (`CORBITS_TOOLS_REGISTRY`) carrying the package's tarball, built by -`@corbits/tool-registry-publish`. `bun run seed` (`packages/hub-client/src/seed.ts`) -republishes that tarball every time it runs, so after changing a tool -package's source, republish and redeploy with: +`@corbits/tool-registry-publish`. `workbench setup` publishes that tarball +onto the root tenant (descendants inherit it); `workbench seed` does not +pack. After changing a tool package's source, bump its version, then +republish with: ```sh -bun run seed +workbench setup ``` This is safe to re-run. Changing a tool package's source requires bumping diff --git a/docs/local-rip.md b/docs/local-rip.md index 7dcb2b03d..cf6a21b65 100644 --- a/docs/local-rip.md +++ b/docs/local-rip.md @@ -100,12 +100,12 @@ The **assistant** default workflow pins the `@corbits/memory-tools` tool package (`workflows/assistant/src/index.ts`), and that pin only resolves once a `package-registry`-kind asset named `corbits-tools` carries its tarball (see `apps/hub/src/index.ts`'s `CORBITS_TOOLS_REGISTRY` comment). -`seedTenant` (`packages/hub-client/src/seed.ts`) publishes that asset -itself — via `@corbits/tool-registry-publish`, which bundles -`@corbits/memory-tools` into a self-contained tarball (every dependency -inlined, so the closure resolver has nothing further to fetch) and pushes -it through the hub's native asset REST routes — ahead of deploying any -workflow, so this step needs nothing from you: **echo**, +`workbench setup` publishes that asset onto the root tenant via +`@corbits/tool-registry-publish` (bundles `@corbits/memory-tools` into a +self-contained tarball and pushes it through the hub's native asset REST +routes). Descendants inherit it; `seedTenant` does not pack. This +walkthrough's personal bench is the root (`OPERATOR_TENANT_ID` unset), +so the same publish happens once onto that bench — then **echo**, **workbench-digest**, and **assistant** all come up live. `scripts/e2e/local-rip.test.ts` asserts exactly that. diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index 6307d7738..023de43d3 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -79,12 +79,14 @@ first checking `GET /api/tenants/:id/skills/:name`. "already exists" as done, not as a reason to abort the run the hub's own error advice told the operator to re-run. -## Tool registry publish (`workbench seed`, ahead of every workflow deploy) +## Tool registry publish (`workbench setup`, onto the root tenant) `publishCorbitsToolsRegistry` (`packages/tool-registry-publish/src/publish.ts`) finds-or-creates the tenant's `corbits-tools` package-registry asset, -then PUTs whatever tarball is missing. Two properties keep a failed -publish from stranding a usable-looking-but-empty asset: +then PUTs whatever tarball is missing. `workbench setup` calls this +onto the root tenant so descendants inherit tarballs; `workbench seed` +does not pack. Two properties keep a failed publish from stranding a +usable-looking-but-empty asset: - `checkToolPackageFreshness` runs **before** the asset is ever created — a version-bump violation aborts the publish with no HTTP @@ -96,8 +98,8 @@ publish from stranding a usable-looking-but-empty asset: so a re-run of `publishCorbitsToolsRegistry` treats it exactly like a brand-new registry and pushes every package, which is what actually creates the repo's first commit. Repairing a tenant with - this history is the same operation as seeding one for the first - time: re-run `workbench seed`. + this history is the same operation as publishing the registry for + the first time: re-run `workbench setup`. ## Workflow deployments (`workbench seed`) diff --git a/packages/cli/README.md b/packages/cli/README.md index d875fa813..d78c3da77 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -25,11 +25,13 @@ hosted provisioning, and every one of them is safe to re-run. a single arktype schema, and every missing/malformed variable is reported at once with the exact fix. - `setup.ts` — `workbench setup`: initializes the database, provisions - the bench through the hub's native tenant-creation route, and reports - the role defaults the platform created. + the bench through the hub's native tenant-creation route, publishes + the platform `corbits-tools` registry onto that root tenant + (descendants inherit it), and reports the role defaults the platform + created. - `seed.ts` — `workbench seed`: authenticates as the administrator, - resolves the configured bench by slug, and seeds it with the default - workflow set. + resolves the configured bench by slug, and deploys the default + workflow set. It does not pack tarballs. - `reset.ts` — `workbench reset`: tears down local state (platform schema and on-disk asset directories) directly, without a hub call. - `db-setup.ts` — child-process runners for the shared setup/reset diff --git a/packages/tool-registry-publish/README.md b/packages/tool-registry-publish/README.md index c3b1e25df..e4025710c 100644 --- a/packages/tool-registry-publish/README.md +++ b/packages/tool-registry-publish/README.md @@ -28,10 +28,12 @@ for how a pin resolves through it). process's lifetime, so concurrent or repeated calls never race two bundler invocations against the same input. - `publishCorbitsToolsRegistry` — find-or-create the tenant's - `corbits-tools` asset (409-tolerant, so two overlapping seed runs + `corbits-tools` asset (409-tolerant, so two overlapping publish runs for the same tenant never both fail on the asset's own name collision), then `PUT` every tarball `CORBITS_TOOL_PACKAGE_DIRS` - produces. + produces. `workbench setup` is the product caller (root tenant; + descendants inherit). Freshness (`src/` moved without a version bump) + fails this publish, not bench create. **A host injects:** @@ -48,12 +50,13 @@ for how a pin resolves through it). **Never imports:** - `@workbench/hub-client` — the dependency direction runs the other - way (`hub-client`'s `seedTenant` calls `publishCorbitsToolsRegistry`), - so this package declares its own structurally-compatible `ApiCall` - type rather than importing `hub-client`'s. + way (`workbench setup` calls `publishCorbitsToolsRegistry` via + hub-client's re-export), so this package declares its own + structurally-compatible `ApiCall` type rather than importing + `hub-client`'s. - `CliError` or any operator-facing error-wrapping convention — every failure here is a plain `Error`; wrapping it as an actionable - `CliError` (problem + fix) is the calling seed step's job, not this + `CliError` (problem + fix) is the calling setup step's job, not this package's. - Any workflow definition or `DEFAULT_WORKFLOWS` — this package knows which tool packages to publish, never which workflows pin them. From 8456cb470c0cba5bc4fe377cee410c42779a98f5 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 16:25:24 -0700 Subject: [PATCH 4/7] Add tests for unparented first-login benches --- packages/onboarding/test/provision.test.ts | 370 +++++++++++---------- 1 file changed, 200 insertions(+), 170 deletions(-) diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index 703b2d3d0..2a16067a9 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -34,6 +34,178 @@ function collector() { return { lines, log: (line: string) => lines.push(line) }; } +function firstLoginSeedHub(args: { expectedParentId?: string }) { + let principalsCalls = 0; + const startedRuns: string[] = []; + const tarballPuts: string[] = []; + const api: ApiCall = async (method, path, body) => { + if (path.includes("/tarballs/")) { + tarballPuts.push(`${method} ${path}`); + throw new Error(`signup must not pack: ${method} ${path}`); + } + if (method === "GET" && path === "/api/me/principals") { + principalsCalls += 1; + if (principalsCalls === 1) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + return { + status: 200, + data: { + data: [ + { + principalId: PRINCIPAL_ID, + tenantId: TENANT_ID, + tenantName: "alice's workbench", + tenantSlug: TENANT_SLUG, + kind: "user", + status: "active", + roles: [{ id: "rol_owner", name: "owner" }], + }, + ], + nextCursor: null, + }, + cookies: [], + }; + } + if (method === "POST" && path === "/api/tenants") { + const parsed = body as { + parentId?: string; + slug: string; + name: string; + }; + expect(parsed.parentId).toBe(args.expectedParentId); + expect(parsed.name).toBe("Alice's Lab"); + return { + status: 201, + data: { + id: TENANT_ID, + name: parsed.name, + slug: parsed.slug, + domain: `${parsed.slug}.localhost`, + ...(args.expectedParentId !== undefined + ? { parentId: args.expectedParentId } + : {}), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) + ) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { + return { status: 201, data: {}, cookies: [] }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { + return { + status: 201, + data: { + id: "ast_1", + tenantId: TENANT_ID, + kind: "workflow", + name: "echo", + displayName: null, + creatorPrincipalId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/git-tokens`) { + return { + status: 201, + data: { id: "tok_1", secret: "s3cret" }, + cookies: [], + }; + } + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) + ) { + return { status: 404, data: {}, cookies: [] }; + } + if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { + return { status: 201, data: {}, cookies: [] }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/definitions` + ) { + return { + status: 200, + data: { data: [], nextCursor: null }, + cookies: [], + }; + } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { + return { status: 200, data: { items: [] }, cookies: [] }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return { status: 200, data: [], cookies: [] }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/workflows/deployments` + ) { + return { + status: 201, + data: { + id: DEPLOYMENT_ID, + tenantId: TENANT_ID, + definitionAssetId: "ast_1", + status: "deployed", + createdAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } + if ( + method === "GET" && + path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/runs` + ) { + return { + status: 200, + data: { runIds: [...startedRuns] }, + cookies: [], + }; + } + if ( + method === "POST" && + path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/mail` + ) { + const runId = `run_${startedRuns.length + 1}`; + startedRuns.push(runId); + return { + status: 202, + data: { + runId: DEPLOYMENT_ID, + address: "echo@x", + messageId: `m${startedRuns.length}`, + }, + cookies: [], + }; + } + throw new Error(`unexpected call: ${method} ${path}`); + }; + return { api, tarballPuts }; +} + describe("personalTenantSlug", () => { test("derives a lowercase-kebab slug from the email and a user-id fragment", () => { expect(personalTenantSlug("Alice.Smith@example.com", "user_id_1")).toBe( @@ -294,176 +466,9 @@ describe("provisionPersonalTenantIfNeeded", () => { }); test("zero principals with a seed model configured: provisions under the operator tenant and seeds the default workflow", async () => { - let principalsCalls = 0; - const startedRuns: string[] = []; - const tarballPuts: string[] = []; - const api: ApiCall = async (method, path, body) => { - if (path.includes("/tarballs/")) { - tarballPuts.push(`${method} ${path}`); - throw new Error(`signup must not pack: ${method} ${path}`); - } - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [{ id: "rol_owner", name: "owner" }], - }, - ], - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "POST" && path === "/api/tenants") { - const parsed = body as { - parentId?: string; - slug: string; - name: string; - }; - expect(parsed.parentId).toBe("ten_operator"); - expect(parsed.name).toBe("Alice's Lab"); - return { - status: 201, - data: { - id: TENANT_ID, - name: parsed.name, - slug: parsed.slug, - domain: `${parsed.slug}.localhost`, - parentId: "ten_operator", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/grants?`) - ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - return { status: 201, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - return { - status: 201, - data: { - id: "ast_1", - tenantId: TENANT_ID, - kind: "workflow", - name: "echo", - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/git-tokens` - ) { - return { - status: 201, - data: { id: "tok_1", secret: "s3cret" }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/skills/`) - ) { - return { status: 404, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/skills`) { - return { status: 201, data: {}, cookies: [] }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/definitions` - ) { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/routines`) { - return { status: 200, data: { items: [] }, cookies: [] }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { status: 200, data: [], cookies: [] }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/deployments` - ) { - return { - status: 201, - data: { - id: DEPLOYMENT_ID, - tenantId: TENANT_ID, - definitionAssetId: "ast_1", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/runs` - ) { - return { - status: 200, - data: { runIds: [...startedRuns] }, - cookies: [], - }; - } - if ( - method === "POST" && - path === `/api/tenants/${TENANT_ID}/workflows/${DEPLOYMENT_ID}/mail` - ) { - const runId = `run_${startedRuns.length + 1}`; - startedRuns.push(runId); - return { - status: 202, - data: { - runId: DEPLOYMENT_ID, - address: "echo@x", - messageId: `m${startedRuns.length}`, - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - + const { api, tarballPuts } = firstLoginSeedHub({ + expectedParentId: "ten_operator", + }); const { log } = collector(); const result = await provisionPersonalTenantIfNeeded({ api, @@ -488,6 +493,31 @@ describe("provisionPersonalTenantIfNeeded", () => { expect(tarballPuts).toEqual([]); }); + test("an unparented personal bench still does not pack corbits-tools as a fallback", async () => { + const { api, tarballPuts } = firstLoginSeedHub({}); + const { log } = collector(); + const result = await provisionPersonalTenantIfNeeded({ + api, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + userId: "user_1", + userEmail: "alice@example.com", + userEmailVerified: true, + displayName: "Alice's Lab", + seedModel: MODEL, + pushWorkflow: noopPush, + log, + }); + + expect(result).toEqual({ + kind: "provisioned", + tenantId: TENANT_ID, + tenantSlug: TENANT_SLUG, + seeded: true, + }); + expect(tarballPuts).toEqual([]); + }); + test("a retry after tenant creation succeeded but seeding failed re-seeds instead of reporting a plain existing member", async () => { let assetCreateAttempts = 0; const startedRuns: string[] = []; From 202c552020baf93afb443eaf0db5a6f5b185c207 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 16:25:29 -0700 Subject: [PATCH 5/7] Persist OPERATOR_TENANT_ID from workbench setup --- packages/cli/src/env-file.test.ts | 66 +++++++++++++++++++ packages/cli/src/env-file.ts | 54 +++++++++++++++ packages/cli/src/index.ts | 3 + packages/cli/src/setup.ts | 25 ++++++- packages/cli/test/setup.test.ts | 105 ++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/env-file.test.ts create mode 100644 packages/cli/src/env-file.ts diff --git a/packages/cli/src/env-file.test.ts b/packages/cli/src/env-file.test.ts new file mode 100644 index 000000000..483bba13a --- /dev/null +++ b/packages/cli/src/env-file.test.ts @@ -0,0 +1,66 @@ +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { persistEnvVar, upsertEnvAssignment } from "./env-file"; + +describe("upsertEnvAssignment", () => { + test("appends when the key is absent", () => { + expect( + upsertEnvAssignment( + "BASE_URL=http://localhost:3000\n", + "OPERATOR_TENANT_ID", + "ten_1", + ), + ).toBe("BASE_URL=http://localhost:3000\nOPERATOR_TENANT_ID=ten_1\n"); + }); + + test("replaces an existing assignment", () => { + expect( + upsertEnvAssignment( + "OPERATOR_TENANT_ID=ten_old\nBASE_URL=x\n", + "OPERATOR_TENANT_ID", + "ten_1", + ), + ).toBe("OPERATOR_TENANT_ID=ten_1\nBASE_URL=x\n"); + }); + + test("uncomments the example assignment rather than appending a second copy", () => { + expect( + upsertEnvAssignment( + "# OPERATOR_TENANT_ID=\nBASE_URL=x\n", + "OPERATOR_TENANT_ID", + "ten_1", + ), + ).toBe("OPERATOR_TENANT_ID=ten_1\nBASE_URL=x\n"); + }); + + test("prefers an uncommented assignment when a commented example is also present", () => { + expect( + upsertEnvAssignment( + "# OPERATOR_TENANT_ID=\nOPERATOR_TENANT_ID=ten_old\n", + "OPERATOR_TENANT_ID", + "ten_1", + ), + ).toBe("# OPERATOR_TENANT_ID=\nOPERATOR_TENANT_ID=ten_1\n"); + }); +}); + +describe("persistEnvVar", () => { + test("creates the file when it is missing", async () => { + const dir = await mkdtemp(join(tmpdir(), "workbench-env-")); + const envPath = join(dir, ".env"); + await persistEnvVar(envPath, "OPERATOR_TENANT_ID", "ten_1"); + expect(await readFile(envPath, "utf8")).toBe("OPERATOR_TENANT_ID=ten_1\n"); + }); + + test("updates an existing .env in place", async () => { + const dir = await mkdtemp(join(tmpdir(), "workbench-env-")); + const envPath = join(dir, ".env"); + await writeFile(envPath, "BASE_URL=http://localhost:3000\n", "utf8"); + await persistEnvVar(envPath, "OPERATOR_TENANT_ID", "ten_1"); + expect(await readFile(envPath, "utf8")).toBe( + "BASE_URL=http://localhost:3000\nOPERATOR_TENANT_ID=ten_1\n", + ); + }); +}); diff --git a/packages/cli/src/env-file.ts b/packages/cli/src/env-file.ts new file mode 100644 index 000000000..6836e0abf --- /dev/null +++ b/packages/cli/src/env-file.ts @@ -0,0 +1,54 @@ +// Upsert a KEY=value assignment in a dotenv file. `workbench setup` +// uses this to persist OPERATOR_TENANT_ID into the repository `.env` +// after it creates the org tenant, so first-login provision can parent +// personal benches under it. + +import { readFile, writeFile } from "node:fs/promises"; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Replace or append `key=value` in dotenv `contents`. */ +export function upsertEnvAssignment( + contents: string, + key: string, + value: string, +): string { + const line = `${key}=${value}`; + const escaped = escapeRegExp(key); + const uncommented = new RegExp(`^[ \\t]*${escaped}=.*$`, "m"); + if (uncommented.test(contents)) { + return contents.replace(uncommented, line); + } + const commented = new RegExp(`^#[ \\t]*${escaped}=.*$`, "m"); + if (commented.test(contents)) { + return contents.replace(commented, line); + } + if (contents === "") return `${line}\n`; + const prefix = contents.endsWith("\n") ? contents : `${contents}\n`; + return `${prefix}${line}\n`; +} + +/** Read `envPath` (or start empty if missing), upsert, and write back. */ +export async function persistEnvVar( + envPath: string, + key: string, + value: string, +): Promise { + let contents = ""; + try { + contents = await readFile(envPath, "utf8"); + } catch (cause) { + if ( + !(cause instanceof Error) || + !("code" in cause) || + cause.code !== "ENOENT" + ) { + throw cause; + } + } + const next = upsertEnvAssignment(contents, key, value); + if (next === contents) return; + await writeFile(envPath, next, "utf8"); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index e5aff640a..182c9666a 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -10,6 +10,7 @@ import { } from "@workbench/hub-client"; import { readSeedConfig, readSetupConfig } from "./config"; import { createDbSetupRunner, createResetRunner } from "./db-setup"; +import { persistEnvVar } from "./env-file"; import { runReset } from "./reset"; import { runSeed } from "./seed"; import { runSetup } from "./setup"; @@ -60,6 +61,8 @@ async function main(argv: string[]): Promise { config, api: createHubAPI(config.hubUrl), runDbSetup: createDbSetupRunner(REPO_ROOT), + persistEnv: ({ key, value }) => + persistEnvVar(resolve(REPO_ROOT, ".env"), key, value), log: out, }); return; diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts index c232d6dbc..8e366c11c 100644 --- a/packages/cli/src/setup.ts +++ b/packages/cli/src/setup.ts @@ -1,5 +1,6 @@ // `workbench setup`: initialize the database, provision the -// bench through the hub's native tenant-creation route, publish +// bench through the hub's native tenant-creation route, persist +// OPERATOR_TENANT_ID so first-login benches parent under it, publish // the platform `corbits-tools` registry onto that tenant (the // root; descendants inherit it), report the role defaults the // platform created, and state exactly what the operator must @@ -33,6 +34,12 @@ export type SetupDeps = { * bundle a tarball. */ publishToolRegistry?: ToolRegistryPublisher; + /** + * Writes `OPERATOR_TENANT_ID` into the operator `.env` so first-login + * personal benches parent under the org tenant this setup created. + * Isolated tests omit this; the CLI always supplies it. + */ + persistEnv?: (args: { key: string; value: string }) => Promise; }; async function ensureTenant( @@ -117,6 +124,22 @@ export async function runSetup(deps: SetupDeps): Promise { log, ); + if (deps.persistEnv !== undefined) { + try { + await deps.persistEnv({ key: "OPERATOR_TENANT_ID", value: tenantId }); + } catch (cause) { + const message = cause instanceof Error ? cause.message : String(cause); + throw new CliError( + `could not persist OPERATOR_TENANT_ID=${tenantId}: ${message}`, + "check that .env at the repository root is writable, then re-run: workbench setup", + { cause }, + ); + } + log( + `OPERATOR_TENANT_ID=${tenantId} written so first-login benches parent under this org`, + ); + } + const roles = await api( "GET", `/api/tenants/${tenantId}/roles?limit=100`, diff --git a/packages/cli/test/setup.test.ts b/packages/cli/test/setup.test.ts index d1ce6aad1..adbf49d88 100644 --- a/packages/cli/test/setup.test.ts +++ b/packages/cli/test/setup.test.ts @@ -321,4 +321,109 @@ describe("runSetup", () => { expect((caught as CliError).fix).toContain("workbench setup"); expect((caught as CliError).fix).not.toContain("workbench seed"); }); + + test("writes OPERATOR_TENANT_ID for the org tenant it created", async () => { + const { log } = collector(); + const persisted: { key: string; value: string }[] = []; + const api = fakeAPI((method, path) => { + if (method === "POST" && path === "/api/auth/sign-in/email") + return signInMissing(); + if (method === "POST" && path === "/api/auth/sign-up/email") + return signUpResponse(); + if (method === "POST" && path === "/api/tenants") + return { status: 201, data: tenantRow() }; + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/roles`) + ) + return rolesResponse(["owner", "admin", "member"]); + return undefined; + }); + + await runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: noopPublishToolRegistry, + persistEnv: async (args) => { + persisted.push(args); + }, + log, + }); + + expect(persisted).toEqual([ + { key: "OPERATOR_TENANT_ID", value: TENANT_ID }, + ]); + }); + + test("a re-run still writes OPERATOR_TENANT_ID for the existing org tenant", async () => { + const { log } = collector(); + const persisted: { key: string; value: string }[] = []; + const api = fakeAPI((method, path) => { + if (method === "POST" && path === "/api/auth/sign-in/email") + return signUpResponse(); + if (method === "POST" && path === "/api/tenants") + return { status: 409, data: { error: "slug taken" } }; + if (method === "GET" && path === "/api/me/principals") + return principalsResponse(); + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/roles`) + ) + return rolesResponse(["owner", "admin", "member"]); + return undefined; + }); + + await runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: async () => [], + persistEnv: async (args) => { + persisted.push(args); + }, + log, + }); + + expect(persisted).toEqual([ + { key: "OPERATOR_TENANT_ID", value: TENANT_ID }, + ]); + }); + + test("a persistEnv failure is a setup CliError", async () => { + const { log } = collector(); + const api = fakeAPI((method, path) => { + if (method === "POST" && path === "/api/auth/sign-in/email") + return signInMissing(); + if (method === "POST" && path === "/api/auth/sign-up/email") + return signUpResponse(); + if (method === "POST" && path === "/api/tenants") + return { status: 201, data: tenantRow() }; + if ( + method === "GET" && + path.startsWith(`/api/tenants/${TENANT_ID}/roles`) + ) + return rolesResponse(["owner", "admin", "member"]); + return undefined; + }); + + let caught: unknown; + try { + await runSetup({ + config: CONFIG, + api, + runDbSetup: okDbSetup, + publishToolRegistry: noopPublishToolRegistry, + persistEnv: async () => { + throw new Error(".env is not writable"); + }, + log, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CliError); + expect((caught as CliError).message).toContain("OPERATOR_TENANT_ID"); + expect((caught as CliError).fix).toContain(".env"); + }); }); From b5eabf17a01dff90de70f8abb648f4dcad2c481f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 27 Aug 2026 16:25:34 -0700 Subject: [PATCH 6/7] Update docs: setup writes OPERATOR_TENANT_ID --- .env.example | 9 +++++---- apps/hub/src/config.ts | 2 +- docs/TENANCY.md | 16 ++++++++-------- docs/local-rip.md | 12 +++++++----- packages/cli/README.md | 9 +++++---- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 589b5280c..82b41b84c 100644 --- a/.env.example +++ b/.env.example @@ -57,10 +57,11 @@ HUB_STATIC_DIR=../web/dist # feature it configures off; the hub never treats a partially-set group # as configured — it fails loudly at boot instead. -# The tenant every self-served personal bench is parented under. Leave -# unset: the operator tenant it would parent under does not exist as -# real infrastructure yet, so self-served benches are unparented -# top-level tenants until it does. +# The tenant every self-served personal bench is parented under. +# `workbench setup` writes this to the org tenant it created. Leave it +# blank only for isolated tests that need an unparented personal bench +# — that is not the default self-serve story. Restart the hub after +# setup so it reads the new value. # OPERATOR_TENANT_ID= # Per-IP rate limit on email sign-up. Defaults to 5 sign-ups per 60 diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 061f66d53..dd2c0e48f 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -81,7 +81,7 @@ const HubEnv = type({ "a directory of built user-interface files the hub serves, e.g. apps/hub/public", ), "OPERATOR_TENANT_ID?": type("string > 0").describe( - "the tenant id every self-served personal bench is parented under; optional because the operator tenant it would parent under does not exist as infrastructure yet, and this field lets that land later without a rename", + "the tenant id every self-served personal bench is parented under; workbench setup writes this for the org tenant it creates. Leave unset only for isolated tests that need an unparented personal bench", ), "SIGNUP_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe( "the per-IP sign-up rate-limit window, in seconds, e.g. 60", diff --git a/docs/TENANCY.md b/docs/TENANCY.md index e12f5b0f1..f77a37a04 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -11,14 +11,14 @@ requires an upstream Interchange change. **Do not patch `vendor/intx`.** ## What already works (consume, do not reimplement) -| Capability | Where | -| ------------------------------- | ------------------------------------------------------------------------------------------------------ | -| Tenant `parentId` hierarchy | `@intx/db` tenant table; POST `/api/tenants` accepts `parentId` | -| Live ancestor-chain inheritance | `getAncestorChain` in `@intx/db` — catalog, credentials, providers walk ancestors at read time | -| Descendant walk | `getDescendantTenants` in `@intx/db` | -| Roles | Interchange native `owner` / `admin` / `member` — mirror 1:1 in UI; never invent a parallel role table | -| Personal bench parenting | `packages/onboarding` parents under `OPERATOR_TENANT_ID` when set | -| Memberships | Native principal + membership routes | +| Capability | Where | +| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Tenant `parentId` hierarchy | `@intx/db` tenant table; POST `/api/tenants` accepts `parentId` | +| Live ancestor-chain inheritance | `getAncestorChain` in `@intx/db` — catalog, credentials, providers walk ancestors at read time | +| Descendant walk | `getDescendantTenants` in `@intx/db` | +| Roles | Interchange native `owner` / `admin` / `member` — mirror 1:1 in UI; never invent a parallel role table | +| Personal bench parenting | `packages/onboarding` parents under `OPERATOR_TENANT_ID`; `workbench setup` writes that id for the org tenant it creates | +| Memberships | Native principal + membership routes | Inheritance is **live**. Creating a sub-workbench must **not** copy catalog rows, credentials, or providers from the parent — resolution diff --git a/docs/local-rip.md b/docs/local-rip.md index cf6a21b65..dccf78fd9 100644 --- a/docs/local-rip.md +++ b/docs/local-rip.md @@ -103,11 +103,13 @@ tarball (see `apps/hub/src/index.ts`'s `CORBITS_TOOLS_REGISTRY` comment). `workbench setup` publishes that asset onto the root tenant via `@corbits/tool-registry-publish` (bundles `@corbits/memory-tools` into a self-contained tarball and pushes it through the hub's native asset REST -routes). Descendants inherit it; `seedTenant` does not pack. This -walkthrough's personal bench is the root (`OPERATOR_TENANT_ID` unset), -so the same publish happens once onto that bench — then **echo**, -**workbench-digest**, and **assistant** all come up live. -`scripts/e2e/local-rip.test.ts` asserts exactly that. +routes). Descendants inherit it; `seedTenant` does not pack. Isolated +tests leave `OPERATOR_TENANT_ID` blank so the walkthrough's personal +bench is itself the root — then the same publish happens once onto that +bench, and **echo**, **workbench-digest**, and **assistant** all come up +live. `scripts/e2e/local-rip.test.ts` asserts exactly that. The default +self-serve story is the other way: setup writes `OPERATOR_TENANT_ID` for +the org tenant, and first-login personal benches parent under it. ## 5. Check the Connections surface diff --git a/packages/cli/README.md b/packages/cli/README.md index d78c3da77..259871acb 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -25,10 +25,11 @@ hosted provisioning, and every one of them is safe to re-run. a single arktype schema, and every missing/malformed variable is reported at once with the exact fix. - `setup.ts` — `workbench setup`: initializes the database, provisions - the bench through the hub's native tenant-creation route, publishes - the platform `corbits-tools` registry onto that root tenant - (descendants inherit it), and reports the role defaults the platform - created. + the bench through the hub's native tenant-creation route, writes + `OPERATOR_TENANT_ID` for that org tenant so first-login benches parent + under it, publishes the platform `corbits-tools` registry onto that + root tenant (descendants inherit it), and reports the role defaults + the platform created. - `seed.ts` — `workbench seed`: authenticates as the administrator, resolves the configured bench by slug, and deploys the default workflow set. It does not pack tarballs. From 8761d7eed1d4f87452f80ac9ff9c023a3e9d66fc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 28 Aug 2026 21:15:12 -0700 Subject: [PATCH 7/7] Publish root tool registry during onboarding recovery --- packages/onboarding/src/provision.ts | 78 +++++++++++++++++++--- packages/onboarding/test/provision.test.ts | 75 ++++++++++++++++++++- 2 files changed, 141 insertions(+), 12 deletions(-) diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts index 1856ba2c6..9ddb75c17 100644 --- a/packages/onboarding/src/provision.ts +++ b/packages/onboarding/src/provision.ts @@ -19,8 +19,10 @@ import { parseAs, reconcileSeedGrants, seedTenant, + publishCorbitsToolsRegistry, type ApiCall, type ModelSource, + type ToolRegistryPublisher, type WorkflowPusher, isLiveDeploymentStatus, } from "@workbench/hub-client"; @@ -105,6 +107,7 @@ export type ProvisionArgs = { displayName?: string; operatorTenantId?: string; seedModel?: ModelSource; + publishToolRegistry?: ToolRegistryPublisher; pushWorkflow: WorkflowPusher; log: (line: string) => void; /** The closed-by-default access-policy gate. Absent means this hub @@ -225,6 +228,33 @@ export async function isFullySeeded( return pending.length === 0; } +async function publishRootToolRegistry(args: { + api: ApiCall; + cookies: string[]; + hubUrl: string; + tenantId: string; + publishToolRegistry?: ToolRegistryPublisher; + log: (line: string) => void; +}): Promise { + const publishToolRegistry = + args.publishToolRegistry ?? publishCorbitsToolsRegistry; + try { + await publishToolRegistry({ + api: args.api, + cookies: args.cookies, + hubUrl: args.hubUrl, + tenantId: args.tenantId, + log: args.log, + }); + } catch (cause) { + throw new ProvisionError( + "tool_registry_publish_failed", + `personal root bench ${args.tenantId} could not publish corbits-tools before seeding: ${cause instanceof Error ? cause.message : String(cause)}`, + "transient", + ); + } +} + /** * The honest partial-seed report `ensureSeeded` reads after catching a * sidecar-unavailable deploy failure (CL-6264): which default workflows @@ -290,6 +320,30 @@ export async function provisionPersonalTenantIfNeeded( }); } + const tenantResponse = await args.api( + "GET", + `/api/tenants/${own.tenantId}`, + undefined, + args.cookies, + ); + const ownTenant = parseAs( + TenantResponse, + tenantResponse.data, + "tenant response", + ); + if (ownTenant.parentId === undefined || ownTenant.parentId === null) { + await publishRootToolRegistry({ + api: args.api, + cookies: args.cookies, + hubUrl: args.hubUrl, + tenantId: own.tenantId, + ...(args.publishToolRegistry !== undefined + ? { publishToolRegistry: args.publishToolRegistry } + : {}), + log: args.log, + }); + } + const fullySeeded = await isFullySeeded( args.api, args.cookies, @@ -313,17 +367,6 @@ export async function provisionPersonalTenantIfNeeded( return { kind: "existing-member", seeded: false, tenantId: own.tenantId }; } - const tenantResponse = await args.api( - "GET", - `/api/tenants/${own.tenantId}`, - undefined, - args.cookies, - ); - const ownTenant = parseAs( - TenantResponse, - tenantResponse.data, - "tenant response", - ); const existingMemberSeedArgs = { api: args.api, cookies: args.cookies, @@ -438,6 +481,19 @@ export async function provisionPersonalTenantIfNeeded( ); } + if (tenant.parentId === undefined || tenant.parentId === null) { + await publishRootToolRegistry({ + api: args.api, + cookies: args.cookies, + hubUrl: args.hubUrl, + tenantId: tenant.id, + ...(args.publishToolRegistry !== undefined + ? { publishToolRegistry: args.publishToolRegistry } + : {}), + log: args.log, + }); + } + if (!args.seedModel) { const seedSkipReason = "no hub-owned seed model credential is configured (ANTHROPIC_API_KEY); the bench was provisioned without the default workflow set"; diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts index 2a16067a9..651f483af 100644 --- a/packages/onboarding/test/provision.test.ts +++ b/packages/onboarding/test/provision.test.ts @@ -28,6 +28,7 @@ const noopPush: WorkflowPusher = async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40), }); +const noopPublishToolRegistry = async () => undefined; function collector() { const lines: string[] = []; @@ -256,6 +257,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userId: "user_1", userEmail: "alice@example.com", userEmailVerified: true, + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log: collector().log, }); @@ -315,6 +317,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, displayName: "Alice's Lab", + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log: collector().log, }); @@ -350,6 +353,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, displayName: "Alice's Lab", + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log: collector().log, }), @@ -359,6 +363,7 @@ describe("provisionPersonalTenantIfNeeded", () => { test("zero principals with no seed model: provisions the bench and reports the seed skip loudly", async () => { let principalsCalls = 0; const { lines, log } = collector(); + const publishedTenants: string[] = []; const api: ApiCall = async (method, path, body) => { if (method === "GET" && path === "/api/me/principals") { principalsCalls += 1; @@ -420,6 +425,9 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, displayName: "Alice's Lab", + publishToolRegistry: async ({ tenantId }) => { + publishedTenants.push(tenantId); + }, pushWorkflow: noopPush, log, }); @@ -427,6 +435,7 @@ describe("provisionPersonalTenantIfNeeded", () => { expect(result.kind).toBe("provisioned"); if (result.kind !== "provisioned") throw new Error("unreachable"); expect(result.seeded).toBe(false); + expect(publishedTenants).toEqual([TENANT_ID]); expect(result.seedSkipReason).toContain("ANTHROPIC_API_KEY"); expect(lines.some((line) => line.includes("ANTHROPIC_API_KEY"))).toBe(true); }); @@ -457,6 +466,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userId: "user_1", userEmail: "alice@example.com", userEmailVerified: true, + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log, }); @@ -480,6 +490,7 @@ describe("provisionPersonalTenantIfNeeded", () => { displayName: "Alice's Lab", operatorTenantId: "ten_operator", seedModel: MODEL, + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log, }); @@ -493,9 +504,10 @@ describe("provisionPersonalTenantIfNeeded", () => { expect(tarballPuts).toEqual([]); }); - test("an unparented personal bench still does not pack corbits-tools as a fallback", async () => { + test("an unparented personal root bench publishes corbits-tools before seeding", async () => { const { api, tarballPuts } = firstLoginSeedHub({}); const { log } = collector(); + const publishedTenants: string[] = []; const result = await provisionPersonalTenantIfNeeded({ api, cookies: ["session=abc"], @@ -505,6 +517,9 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, displayName: "Alice's Lab", seedModel: MODEL, + publishToolRegistry: async ({ tenantId }) => { + publishedTenants.push(tenantId); + }, pushWorkflow: noopPush, log, }); @@ -515,6 +530,7 @@ describe("provisionPersonalTenantIfNeeded", () => { tenantSlug: TENANT_SLUG, seeded: true, }); + expect(publishedTenants).toEqual([TENANT_ID]); expect(tarballPuts).toEqual([]); }); @@ -716,6 +732,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmailVerified: true, displayName: "Alice's Lab", seedModel: MODEL, + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log: collector().log, }); @@ -731,6 +748,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, seedModel: MODEL, + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log, }); @@ -766,6 +784,7 @@ describe("provisionPersonalTenantIfNeeded", () => { ), ); const grantsPosted: { resource: string; action: string }[] = []; + const publishedTenants: string[] = []; const api: ApiCall = async (method, path, body) => { if (method === "GET" && path === "/api/me/principals") { return { @@ -852,6 +871,20 @@ describe("provisionPersonalTenantIfNeeded", () => { cookies: [], }; } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { + return { + status: 200, + data: { + id: TENANT_ID, + name: "alice's workbench", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } throw new Error(`unexpected call: ${method} ${path}`); }; @@ -863,6 +896,9 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, // No seedModel needed: nothing left to seed on the workflow side. + publishToolRegistry: async ({ tenantId }) => { + publishedTenants.push(tenantId); + }, pushWorkflow: noopPush, log: collector().log, }); @@ -872,6 +908,7 @@ describe("provisionPersonalTenantIfNeeded", () => { seeded: true, tenantId: "ten_new", }); + expect(publishedTenants).toEqual([TENANT_ID]); // Exactly the one grant this tenant was missing — no more, no less. expect(grantsPosted).toEqual([missingGrant]); }); @@ -943,6 +980,20 @@ describe("provisionPersonalTenantIfNeeded", () => { cookies: [], }; } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { + return { + status: 200, + data: { + id: TENANT_ID, + name: "alice's workbench", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } throw new Error(`unexpected call: ${method} ${path}`); }; @@ -953,6 +1004,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userId: "user_1", userEmail: "alice@example.com", userEmailVerified: true, + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log: collector().log, }); @@ -970,6 +1022,7 @@ describe("provisionPersonalTenantIfNeeded", () => { // not depend on a seed credential that may never exist. Seeding itself is // skipped (nothing to seed with); the user is not stranded in a loop. let assetListCalls = 0; + const publishedTenants: string[] = []; const api: ApiCall = async (method, path) => { if (method === "GET" && path === "/api/me/principals") { return { @@ -1021,6 +1074,20 @@ describe("provisionPersonalTenantIfNeeded", () => { ) { return { status: 200, data: [], cookies: [] }; } + if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { + return { + status: 200, + data: { + id: TENANT_ID, + name: "alice's workbench", + slug: TENANT_SLUG, + domain: `${TENANT_SLUG}.localhost`, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + cookies: [], + }; + } throw new Error(`unexpected call: ${method} ${path}`); }; @@ -1032,6 +1099,9 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, // No seedModel — hub without ANTHROPIC_API_KEY. + publishToolRegistry: async ({ tenantId }) => { + publishedTenants.push(tenantId); + }, pushWorkflow: noopPush, log: collector().log, }); @@ -1044,6 +1114,7 @@ describe("provisionPersonalTenantIfNeeded", () => { seeded: false, tenantId: "ten_new", }); + expect(publishedTenants).toEqual([TENANT_ID]); // Completeness was checked (tenant-local assets listed) even without a // seed model — membership recovery does not short-circuit before that. expect(assetListCalls).toBe(1); @@ -1234,6 +1305,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, seedModel: MODEL, + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log: collector().log, }); @@ -1469,6 +1541,7 @@ describe("provisionPersonalTenantIfNeeded", () => { userEmail: "alice@example.com", userEmailVerified: true, seedModel: MODEL, + publishToolRegistry: noopPublishToolRegistry, pushWorkflow: noopPush, log: collector().log, });