diff --git a/AGENTS.md b/AGENTS.md index f032ed37fc..f1408a3639 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,6 @@ Env flags (unset behavior): | `HUB_DATA_DIR` | hub boot fails — required runtime config | | `CI` | set by GitHub Actions; not a caller flag | | `E2E_PROVIDER` / `E2E_PROVIDER_API_KEY` / `OLLAMA_BASE_URL` | live-inference e2e stays on the noop/stub path | -| `EVAL_PROVIDER` / `EVAL_PROVIDER_API_KEY` | `bun run eval` runs against the stub | | `CHROME_PATH` | browser walkthrough falls back to platform defaults, fails if none exist | ## Tests diff --git a/apps/web/src/myra-deploy.ts b/apps/web/src/myra-deploy.ts index 3b2f84f742..7d54e61648 100644 --- a/apps/web/src/myra-deploy.ts +++ b/apps/web/src/myra-deploy.ts @@ -38,7 +38,7 @@ async function readErrorBody(response: Response): Promise { } /** Idempotently ensures the `package-registry` asset Myra's tarball is - * published into, mirroring `packages/onboarding`'s `ensureWorkflowAsset` + * published into, mirroring `the deleted onboarding package's `ensureWorkflowAsset` * 409-then-list pattern. */ export async function ensureMyraSourceAsset( tenantId: string, diff --git a/apps/web/src/myra-source.ts b/apps/web/src/myra-source.ts index fd5fec3daf..9a05230e33 100644 --- a/apps/web/src/myra-source.ts +++ b/apps/web/src/myra-source.ts @@ -11,7 +11,7 @@ // // The one variant that installs cleanly today, entirely over stock // routes and with no hub or seed change, is the same shape -// `packages/onboarding`'s `seedTenant` and the e2e harness already push: +// `the deleted onboarding package's `seedTenant` and the e2e harness already push: // the built definition rendered as the two-file source package // `@corbits/workflows`' `renderWorkflowSourceTree` emits, published as a // `tarball` inside a stock `package-registry` asset via `PUT diff --git a/apps/web/src/myra-workbench.ts b/apps/web/src/myra-workbench.ts index 85c2df6ed3..122792b5ed 100644 --- a/apps/web/src/myra-workbench.ts +++ b/apps/web/src/myra-workbench.ts @@ -10,11 +10,10 @@ import { listAgentDefinitions, type AgentDefinition } from "./agents-api"; export const MYRA_WORKBENCH_TITLE = "Myra"; -/** The seeded workflow asset backing Myra - * (`packages/onboarding/src/tenant-seed.ts` deploys it as `assistant`, - * stamped with catalog displayName "Myra"). A - * chat's `definitionId` names this deployed definition's row id, never the - * asset name itself. */ +/** The deployed workflow asset backing Myra (`myra-source.ts` / + * `myra-deploy.ts` deploy it as `assistant`, stamped with catalog + * displayName "Myra"). A chat's `definitionId` names this deployed + * definition's row id, never the asset name itself. */ const MYRA_ASSET_NAME = WORKFLOW_CATALOG.find( (entry) => entry.displayName === MYRA_WORKBENCH_TITLE, )?.assetName; diff --git a/apps/web/src/onboarding/provider-connect-step.tsx b/apps/web/src/onboarding/provider-connect-step.tsx index 6d1278c91d..a7ff979527 100644 --- a/apps/web/src/onboarding/provider-connect-step.tsx +++ b/apps/web/src/onboarding/provider-connect-step.tsx @@ -72,7 +72,7 @@ export type ExistingOffering = { * ancestor, or minted by an earlier run of this step) needs no UI at * all: every visible offering becomes `sourceOfferingIds`, and the * lowest-priority one is the default — the same rule - * `resolveRealSourceOfferingIds` in `packages/onboarding` applies. */ + * `resolveRealSourceOfferingIds` in the deleted onboarding package applies. */ export async function resolveExistingOffering(tenantId: string): Promise { const models = await getResolvedCatalog(tenantId); const offerings = models diff --git a/bun.lock b/bun.lock index ae48bc7aeb..9f4c4ae95e 100644 --- a/bun.lock +++ b/bun.lock @@ -649,7 +649,6 @@ "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", - "@workbench/onboarding": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "postgres": "catalog:", @@ -798,45 +797,6 @@ "@intx/types": "0.3.0", }, }, - "packages/onboarding": { - "name": "@workbench/onboarding", - "version": "0.0.1", - "dependencies": { - "@corbits/assistant-workflow": "workspace:*", - "@corbits/attio-task-agent-workflow": "workspace:*", - "@corbits/code-review-workflow": "workspace:*", - "@corbits/collateral-generation-workflow": "workspace:*", - "@corbits/collections": "workspace:*", - "@corbits/connections": "workspace:*", - "@corbits/diligence-brief-workflow": "workspace:*", - "@corbits/echo-workflow": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/exa-topic-watch-workflow": "workspace:*", - "@corbits/granola-call-workflow": "workspace:*", - "@corbits/hub-api-client": "workspace:*", - "@corbits/last-30-days-research-workflow": "workspace:*", - "@corbits/morning-brief-workflow": "workspace:*", - "@corbits/pain-point-collateral-workflow": "workspace:*", - "@corbits/process-granola-call-workflow": "workspace:*", - "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", - "@corbits/tool-registry-publish": "workspace:*", - "@corbits/workbench-digest-workflow": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/agent": "workspace:*", - "@intx/crypto": "0.3.0", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "workspace:*", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/plugins-ui": { "name": "@corbits/plugins-ui", "version": "0.0.1", @@ -2546,8 +2506,6 @@ "@workbench/hub": ["@workbench/hub@workspace:apps/hub"], - "@workbench/onboarding": ["@workbench/onboarding@workspace:packages/onboarding"], - "@workbench/sidecar": ["@workbench/sidecar@workspace:apps/sidecar"], "@workbench/web": ["@workbench/web@workspace:apps/web"], @@ -3278,11 +3236,7 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - - "@corbits/diligence-brief-workflow/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - - "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#e85b81e", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-e85b81e", "sha512-ATPP/pll1sYG1DLKV1HvtboK8q1tFkqayQqRXdUmRUIsFJx5wqzd8YA1XELjXzLu1P2f2uCMe2CjsJHGv6E90Q=="], + "@corbits/assistant-workflow/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], @@ -3300,10 +3254,6 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#5309a15", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-5309a15", "sha512-zGHkq++juclUrOd0O8/Z5ziTldmJZ6gqyH/c9cs5S10UPU4gqn8CHwq174SiJXY0ujJ3cNpJXnrr+0fcqlfQ1w=="], - - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#e85b81e", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-e85b81e", "sha512-ATPP/pll1sYG1DLKV1HvtboK8q1tFkqayQqRXdUmRUIsFJx5wqzd8YA1XELjXzLu1P2f2uCMe2CjsJHGv6E90Q=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#e74da20", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-e74da20", "sha512-/uA9xTcF7XwMXgnR8OZamierzkLG4g4mhw3u9Fl3Uj6ait8pH3wcoQ6V32on1nMdaqtPRdYVFnxof5O+4ZacGA=="], "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], @@ -3350,8 +3300,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3396,8 +3344,6 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/docs/local-rip.md b/docs/local-rip.md index 0e113a1c42..6ac97353a3 100644 --- a/docs/local-rip.md +++ b/docs/local-rip.md @@ -3,10 +3,10 @@ A "local rip" is the whole platform proof, run by hand on your own machine, with your own real key: sign up, get a personal bench, connect a real inference provider, and watch a working workbench come up in front of -you — nothing mocked, nothing skipped. `scripts/e2e/local-rip.test.ts` -proves the same path with a stubbed provider probe and a scratch database -(see its own header comment); this doc is the honest, no-shortcuts version -for a person, not CI. +you — nothing mocked, nothing skipped. `scripts/e2e/browser/walkthrough.ts` proves the same path end to end +against a real browser with a stubbed provider probe and a scratch +database; this doc is the honest, no-shortcuts version for a person, not +CI. This covers the whole path: sign-up through a fully connected bench, with a real inference provider wired up end to end. @@ -59,7 +59,7 @@ The app root renders the sign-in screen when you're not authenticated an account with any email/password. Self-serve sign-up is ungated in stock composition — there is no signup mode left to open, so sign-up works out of the box here, against `bun run dev` and any deployed hub -alike. `scripts/e2e/local-rip.test.ts`'s "signup stays ungated once the +alike. `scripts/e2e/browser/walkthrough.ts`'s "signup stays ungated once the hub is occupied" hop proves exactly that response. ## 3. Name your bench @@ -116,7 +116,7 @@ through the hub's native asset REST routes). Descendants inherit it; `seedTenant` does not pack. Isolated tests run with no explicit tenant config so the walkthrough's personal bench is itself the root — then the same publish happens once onto that -bench, and **assistant** comes up live by default; `scripts/e2e/local-rip.test.ts` +bench, and **assistant** comes up live by default; `scripts/e2e/browser/walkthrough.ts` also deploys **echo** and **workbench-digest** from `CATALOG_WORKFLOWS` onto that same bench (the on-demand path, exercised directly rather than assumed) to prove they still come up live too. The default diff --git a/package.json b/package.json index 6605c615ca..134938f147 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,7 @@ "setup": "bun scripts/db-setup.ts", "setup:memory": "bun run scripts/setup-memory.ts", "reset": "bun scripts/reset.ts", - "build:sidecar-image": "docker build -f apps/sidecar/Dockerfile -t corbits-sidecar:dev .", - "eval": "bun run scripts/evals-run.ts" + "build:sidecar-image": "docker build -f apps/sidecar/Dockerfile -t corbits-sidecar:dev ." }, "devDependencies": { "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#5309a15d4aa504f132c54b4da08bb672b4903873", diff --git a/packages/chat/src/platform-port.ts b/packages/chat/src/platform-port.ts index f1d43c8da8..f1956b6319 100644 --- a/packages/chat/src/platform-port.ts +++ b/packages/chat/src/platform-port.ts @@ -9,7 +9,7 @@ // than duplicating it inside this package (which would violate "apps // stay generic; packages own the domain" the other way around), this // package depends on this narrow port, injected by the hub exactly as -// `@workbench/onboarding` injects `pushWorkflow` instead of +// the deleted `@workbench/onboarding` used to inject `pushWorkflow` instead of // reimplementing workflow push. // // Split into its three real seams — launching, mail, and the live diff --git a/packages/connections/src/connected-hook.test.ts b/packages/connections/src/connected-hook.test.ts index 5bf1768c7f..7eb3163d1f 100644 --- a/packages/connections/src/connected-hook.test.ts +++ b/packages/connections/src/connected-hook.test.ts @@ -1,22 +1,6 @@ import { describe, expect, spyOn, test } from "bun:test"; import * as errorSink from "@corbits/error-sink"; -import { - fireConnectedHook, - fireInferenceCredentialSeedableHook, - type InferenceCredentialSeedableInfo, - type ServiceConnectedInfo, -} from "./connected-hook"; - -function seedableInfo(): InferenceCredentialSeedableInfo { - return { - userId: "user_1", - tenantId: "tenant_1", - tenantDomain: "tenant-1.example", - principalId: "principal_1", - provider: "ollama", - apiKey: "secret", - }; -} +import { fireConnectedHook, type ServiceConnectedInfo } from "./connected-hook"; function connectedInfo(): ServiceConnectedInfo { return { @@ -55,48 +39,3 @@ describe("fireConnectedHook", () => { report.mockRestore(); }); }); - -describe("fireInferenceCredentialSeedableHook", () => { - test("does nothing when no hook is wired", async () => { - await expect( - fireInferenceCredentialSeedableHook(undefined, () => {}, seedableInfo()), - ).resolves.toBeUndefined(); - }); - - test("calls the hook with the connect's tenant, provider, and key", async () => { - const calls: InferenceCredentialSeedableInfo[] = []; - await fireInferenceCredentialSeedableHook( - (info) => { - calls.push(info); - }, - () => {}, - seedableInfo(), - ); - expect(calls).toEqual([seedableInfo()]); - }); - - test("logs and reports a hook failure rather than breaking the connect", async () => { - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - const logged: string[] = []; - await expect( - fireInferenceCredentialSeedableHook( - () => { - throw new Error("drain unavailable"); - }, - (line) => logged.push(line), - seedableInfo(), - ), - ).resolves.toBeUndefined(); - expect(logged).toHaveLength(1); - expect(logged[0]).toContain("tenant_1"); - expect(logged[0]).toContain("drain unavailable"); - expect(report).toHaveBeenCalledTimes(1); - expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); - expect(report.mock.calls[0]?.[1]).toMatchObject({ - operation: "fire_inference_credential_seedable_hook", - tenantId: "tenant_1", - extra: { provider: "ollama" }, - }); - report.mockRestore(); - }); -}); diff --git a/packages/connections/src/connected-hook.ts b/packages/connections/src/connected-hook.ts index 858ba080dd..c8e8348cdc 100644 --- a/packages/connections/src/connected-hook.ts +++ b/packages/connections/src/connected-hook.ts @@ -34,55 +34,3 @@ export async function fireConnectedHook( }); } } - -// A second, narrower seam: fires only when an inference connector's -// `/complete` just left the tenant with `hasUsableModel` true — a model -// with a resolvable offering, whether or not this tenant's bench has -// ever deployed its default workflows. A composition wires this to the -// same durable pending-seed drain the onboarding credential step already -// feeds (`@workbench/onboarding`'s `pendingSeedStore` + `benchProvisioner`), -// so a tenant that connects its own provider through Settings converges -// on Myra and the default workflow set exactly like one that connects -// through onboarding — never stuck waiting on an operator-configured -// hub key. `apiKey` here is the same secret `/complete` just stored -// (the URL placeholder for a `credentialInputKind: "url"` connector like -// Ollama, a real key otherwise); never logged, never returned to the -// caller. -export type InferenceCredentialSeedableInfo = { - readonly userId: string; - readonly tenantId: string; - readonly tenantDomain: string; - readonly principalId: string; - readonly provider: string; - readonly apiKey: string; - /** The real instance origin a `credentialInputKind: "url"` connector - * (Ollama) was just pointed at — absent for every other provider. - * Carried through to the drain so its deploy targets this tenant's - * actual endpoint rather than a curated default. */ - readonly baseURLOverride?: string; -}; - -export type InferenceCredentialSeedableHook = ( - info: InferenceCredentialSeedableInfo, -) => Promise | void; - -export async function fireInferenceCredentialSeedableHook( - hook: InferenceCredentialSeedableHook | undefined, - log: (line: string) => void, - info: InferenceCredentialSeedableInfo, -): Promise { - if (hook === undefined) return; - try { - await hook(info); - } catch (cause) { - const message = cause instanceof Error ? cause.message : String(cause); - log( - `onInferenceCredentialUsable hook failed for ${info.provider} on tenant ${info.tenantId}: ${message}`, - ); - reportError(cause, { - operation: "fire_inference_credential_seedable_hook", - tenantId: info.tenantId, - extra: { provider: info.provider }, - }); - } -} diff --git a/packages/connections/src/persist-credential.ts b/packages/connections/src/persist-credential.ts index ebc2d19a4a..4b27726289 100644 --- a/packages/connections/src/persist-credential.ts +++ b/packages/connections/src/persist-credential.ts @@ -3,7 +3,7 @@ // ensureCredential → seedCatalog-if-inference. Before CL-6394 this // sequence existed as three parallel copies (`routes.ts`'s // `/:connectorId/complete`, `oauth-tenant-connect.ts`, and -// `@workbench/onboarding`'s `testAndPersistCredential`) and their +// `@corbits/connections`'s `testAndPersistCredential`) and their // divergence is exactly what let a GitHub callback fall into an // inference-only `seedCatalog` — a non-inference connector must never // reach `CATALOG_SEEDS`, and here that rule lives in one place. diff --git a/packages/connections/src/routes.test.ts b/packages/connections/src/routes.test.ts index ada5fa49e0..33cfdb9e57 100644 --- a/packages/connections/src/routes.test.ts +++ b/packages/connections/src/routes.test.ts @@ -10,7 +10,6 @@ import * as errorSink from "@corbits/error-sink"; import { Hono } from "hono"; import type { MiddlewareHandler } from "hono"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import type { ModelInfo } from "@intx/types"; import type { ConnectorDescriptor } from "./descriptor"; import type { ApiCall } from "@corbits/hub-api-client"; import { createProviderHealthStore } from "./provider-health"; @@ -155,10 +154,6 @@ function buildApp( providerHealth?: Parameters[0]["providerHealth"]; listConnectedProviders?: Parameters[0]["listConnectedProviders"]; onConnected?: Parameters[0]["onConnected"]; - onInferenceCredentialUsable?: Parameters< - typeof createConnectionRoutes - >[0]["onInferenceCredentialUsable"]; - getResolvedCatalogFn?: Parameters[0]["getResolvedCatalogFn"]; } = {}, ) { const routeArgs: Parameters[0] = { @@ -179,10 +174,6 @@ function buildApp( if (overrides.listConnectedProviders !== undefined) routeArgs.listConnectedProviders = overrides.listConnectedProviders; if (overrides.onConnected !== undefined) routeArgs.onConnected = overrides.onConnected; - if (overrides.onInferenceCredentialUsable !== undefined) - routeArgs.onInferenceCredentialUsable = overrides.onInferenceCredentialUsable; - if (overrides.getResolvedCatalogFn !== undefined) - routeArgs.getResolvedCatalogFn = overrides.getResolvedCatalogFn; const routes = createConnectionRoutes(routeArgs); return mountAs(routes); } @@ -1078,158 +1069,3 @@ describe("onConnected hook", () => { expect(events).toHaveLength(0); }); }); - -function modelWithOfferings(offeringCount: number): ModelInfo { - return { - id: "model_1", - canonicalName: "qwen3", - displayName: "Qwen3", - offerings: Array.from({ length: offeringCount }, (_, index) => ({ - offeringId: `off_${index}`, - providerId: "prov_1", - providerName: "ollama", - plugin: "ollama", - priority: index, - capabilities: [], - })), - } as unknown as ModelInfo; -} - -describe("onInferenceCredentialUsable hook", () => { - const OLLAMA_REGISTRY: Readonly> = { - ...FAKE_REGISTRY, - ollama: { - id: "ollama", - displayName: "Ollama", - authKind: "api-key", - credentialPlugin: "http", - docsUrl: "https://example.test/docs", - feedsTools: [], - credentialInputKind: "url", - credentialPlaceholder: "http://localhost:11434", - probe: async () => ({ ok: true }), - }, - }; - - test("a connected provider that resolves a usable model fires the hook with that provider's own key and endpoint", async () => { - const events: unknown[] = []; - const routes = createConnectionRoutes({ - hubUrl: "http://hub.test", - requireGrant: allowAll, - log: () => {}, - registry: OLLAMA_REGISTRY, - ensureProviderFn: async () => "prv_1", - ensureCredentialFn: async () => "crd_1", - seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), - getResolvedCatalogFn: async () => [modelWithOfferings(1)], - onInferenceCredentialUsable: async (info) => { - events.push(info); - }, - }); - const app = mountAs(routes); - const response = await app.request("/ollama/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - apiKey: "https://home-mac-studio.tail87f5aa.ts.net", - }), - }); - - expect(response.status).toBe(200); - expect(events).toEqual([ - { - userId: USER.id, - tenantId: TENANT.id, - tenantDomain: TENANT.domain, - principalId: PRINCIPAL.id, - provider: "ollama", - apiKey: "ollama", - baseURLOverride: "https://home-mac-studio.tail87f5aa.ts.net", - }, - ]); - }); - - test("a connected provider that resolves no usable model never fires the hook — a seeded row is not the same as a usable one", async () => { - const events: unknown[] = []; - const routes = createConnectionRoutes({ - hubUrl: "http://hub.test", - requireGrant: allowAll, - log: () => {}, - registry: OLLAMA_REGISTRY, - ensureProviderFn: async () => "prv_1", - ensureCredentialFn: async () => "crd_1", - seedCatalogFn: async () => ({ hasCompletionCapableModel: false }), - getResolvedCatalogFn: async () => [modelWithOfferings(0)], - onInferenceCredentialUsable: async (info) => { - events.push(info); - }, - }); - const app = mountAs(routes); - const response = await app.request("/ollama/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ apiKey: "http://localhost:11434" }), - }); - - expect(response.status).toBe(200); - expect(events).toHaveLength(0); - }); - - test("a non-inference connector never fires the hook", async () => { - const events: unknown[] = []; - const app = buildApp({ - ensureProviderFn: async () => "prv_1", - ensureCredentialFn: async () => "crd_1", - onInferenceCredentialUsable: async (info) => { - events.push(info); - }, - }); - const response = await app.request("/accepting-connector/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ apiKey: "good-key" }), - }); - - expect(response.status).toBe(200); - expect(events).toHaveLength(0); - }); - - test("a resolved-catalog check failure is reported and never breaks the connect", async () => { - const report = spyOn(errorSink, "reportError").mockReturnValue("ref_test"); - const events: unknown[] = []; - const routes = createConnectionRoutes({ - hubUrl: "http://hub.test", - requireGrant: allowAll, - log: () => {}, - registry: OLLAMA_REGISTRY, - ensureProviderFn: async () => "prv_1", - ensureCredentialFn: async () => "crd_1", - seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), - getResolvedCatalogFn: async () => { - throw new Error("hub unreachable"); - }, - onInferenceCredentialUsable: async (info) => { - events.push(info); - }, - }); - const app = mountAs(routes); - const response = await app.request("/ollama/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - apiKey: "https://home-mac-studio.tail87f5aa.ts.net", - }), - }); - - expect(response.status).toBe(200); - expect(events).toHaveLength(0); - expect(report).toHaveBeenCalledTimes(1); - expect(report.mock.calls[0]?.[0]).toBeInstanceOf(Error); - expect(report.mock.calls[0]?.[1]).toMatchObject({ - operation: "check_resolved_catalog_after_connect", - tenantId: TENANT.id, - extra: { connectorId: "ollama" }, - }); - report.mockRestore(); - }); -}); diff --git a/packages/connections/src/routes.ts b/packages/connections/src/routes.ts index 80b768777b..c0bc93ec75 100644 --- a/packages/connections/src/routes.ts +++ b/packages/connections/src/routes.ts @@ -4,7 +4,7 @@ // the client calls first) and, on success, plants the credential through // the same `ensureProvider` / `ensureCredential` seam `seedCatalog` uses — // never reimplementing credential storage. A rejected probe 422s with no -// storage, mirroring `@workbench/onboarding`'s `completeCredentialSetup`. +// storage, mirroring `@corbits/connections`'s `completeCredentialSetup`. // Mounted inside the platform's native tenant middleware (`TenantEnv`'s // `tenant`/`principal` resolved before any handler here runs), the same // way `@corbits/webhook-triggers`' management routes are. @@ -14,9 +14,8 @@ // display-only in this ticket), is a 404. import { Hono, type Context } from "hono"; import { type } from "arktype"; -import { ModelInfo, ModelProviderResponse, ProviderResponse, paginatedSchema } from "@intx/types"; +import { ModelProviderResponse, ProviderResponse, paginatedSchema } from "@intx/types"; import type { RequireGrant, TenantEnv } from "@intx/hub-api"; -import { hasUsableModel } from "@corbits/inference-settings"; import { makeErrorEnvelope, reportError } from "@corbits/error-sink"; import { OLLAMA_PLACEHOLDER_SECRET } from "./credential-test"; import { @@ -29,13 +28,8 @@ import { } from "./seed-catalog"; import { cookiesFromHeader, createHubAPI, parseAs, type ApiCall } from "@corbits/hub-api-client"; import type { ConnectorDescriptor } from "./descriptor"; -import { - fireConnectedHook, - fireInferenceCredentialSeedableHook, - type InferenceCredentialSeedableHook, - type ServiceConnectedHook, -} from "./connected-hook"; -import { isInferenceProvider, persistConnectorCredential } from "./persist-credential"; +import { fireConnectedHook, type ServiceConnectedHook } from "./connected-hook"; +import { persistConnectorCredential } from "./persist-credential"; import type { ProviderHealthStore } from "./provider-health"; export type DisconnectConnectorArgs = { @@ -177,9 +171,9 @@ export type CreateConnectionRoutesDeps = { /** The connector set this build ships — this module's own * `CONNECTOR_REGISTRY` for the hub; a test passes its own stub. */ registry: Readonly>; - /** Test-only override, matching `complete-credential.ts`'s `seedCatalogFn` - * override pattern — lets `routes.test.ts` stub credential storage - * without reaching for module mocking. */ + /** Test-only override, matching every other override in this file — + * lets `routes.test.ts` stub credential storage without reaching for + * module mocking. */ ensureProviderFn?: ( api: ApiCall, cookies: string[], @@ -192,20 +186,11 @@ export type CreateConnectionRoutesDeps = { args: EnsureCredentialArgs, log: (line: string) => void, ) => ReturnType; - /** Test-only override, matching `complete-credential.ts`'s - * `seedCatalogFn` override pattern — lets `routes.test.ts` prove an - * inference-provider connect seeds the catalog (and a non-inference - * connector never does) without reaching for module mocking. */ - seedCatalogFn?: (args: SeedCatalogArgs) => ReturnType; /** Test-only override, matching every other override in this file — - * lets `routes.test.ts` stub the post-connect resolved-catalog read - * `onInferenceCredentialUsable`'s `hasUsableModel` gate runs against, - * without reaching for module mocking. */ - getResolvedCatalogFn?: ( - api: ApiCall, - cookies: string[], - tenantId: string, - ) => Promise; + * lets `routes.test.ts` prove an inference-provider connect seeds the + * catalog (and a non-inference connector never does) without reaching + * for module mocking. */ + seedCatalogFn?: (args: SeedCatalogArgs) => ReturnType; /** Test-only override, matching every other override in this file — * lets `routes.test.ts` stub disconnect's catalog/provider cleanup * without reaching for module mocking. */ @@ -264,17 +249,6 @@ export type CreateConnectionRoutesDeps = { * connect cards, resume waiting agents). Failures are logged and * never surface into the response. */ onConnected?: ServiceConnectedHook; - /** Fires once an inference connector's credential is durably stored - * AND leaves the tenant with `hasUsableModel` true — never on a - * non-inference connector, and never merely because a credential row - * exists (seeding plants that row regardless of whether it actually - * resolves an offering). The composition wires this to the same - * durable pending-seed drain onboarding's own credential step feeds, - * so a provider connected through Settings deploys the tenant's - * default workflows exactly like one connected through onboarding — - * see `./connected-hook.ts`. Absent means this hub build never - * re-seeds off a Settings connect (every existing test double). */ - onInferenceCredentialUsable?: InferenceCredentialSeedableHook; }; export function createConnectionRoutes(deps: CreateConnectionRoutesDeps): Hono { @@ -282,17 +256,6 @@ export function createConnectionRoutes(deps: CreateConnectionRoutesDeps): Hono { - const response = await resolveApi( - "GET", - `/api/tenants/${tenantId}/models`, - undefined, - cookies, - ); - return parseAs(ModelInfo.array(), response.data, "resolved catalog response"); - }); // Lets a settings-ui OAuth card tell "not configured" (an operator // hasn't registered this connector's OAuth app yet) apart from "not @@ -453,48 +416,6 @@ export function createConnectionRoutes(deps: CreateConnectionRoutesDeps): Hono Connections) is allowed to block on a real check. + // submission through a connect UI: `connections`' + // `POST /:connectorId/complete` (`routes.ts`) sets it only after + // `descriptor.probe` passes, since that surface (Settings > Connections) + // is allowed to block on a real check. const shouldRotate = args.type === "oauth_token" ? existing.status !== "active" || args.verified === true @@ -518,17 +515,16 @@ export type SeedCatalogArgs = { credentialName?: string; /** * Free-form data attached to the seeded credential's `metadata` - * field — the extension point a token's expiry timestamp lives in - * (see `complete-credential.ts`), never interpreted by this function. + * field — the extension point a token's expiry timestamp lives in, + * never interpreted by this function. */ credentialMetadata?: Record; /** * Passed straight through to `ensureCredential`'s own `verified` — set * only by a caller that already proved `apiKey` against the provider's - * own probe before calling `seedCatalog` (onboarding's - * `testAndPersistCredential`). A plain `workbench seed` never sets - * this, since its key comes straight from env with no probe of its - * own. + * own probe before calling `seedCatalog`. A plain `workbench seed` + * never sets this, since its key comes straight from env with no + * probe of its own. */ credentialVerified?: boolean; /** diff --git a/packages/evals/package.json b/packages/evals/package.json index 3e84a310a2..f5f09dc22a 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -28,7 +28,6 @@ "@intx/types": "workspace:*", "@intx/workflow": "workspace:*", "@modelcontextprotocol/sdk": "catalog:", - "@workbench/onboarding": "workspace:*", "arktype": "catalog:", "drizzle-orm": "catalog:", "postgres": "catalog:" diff --git a/packages/evals/src/index.ts b/packages/evals/src/index.ts index 69c66df163..1c9582d3ec 100644 --- a/packages/evals/src/index.ts +++ b/packages/evals/src/index.ts @@ -64,14 +64,6 @@ export { export type { EvalRunRecord, EvalRunStore } from "./store/store.ts"; export { createPostgresEvalRunStore } from "./store/pg-store.ts"; export { applyEvalsMigrations } from "./store/migrations.ts"; -export { bootMyraTarget, createEvalAssetService } from "./targets/real-target.ts"; -export type { - EvalApiResult, - EvalHubHandle, - EvalSpawnedApp, - MyraTargetInfra, - MyraTargetMcpFake, -} from "./targets/real-target.ts"; export { newToolCallsSince, readAllToolCalls } from "./targets/trace.ts"; export type { SqlClientLike } from "./targets/trace.ts"; export { captureWorldSnapshot } from "./targets/world-snapshot.ts"; diff --git a/packages/evals/src/targets/real-target.test.ts b/packages/evals/src/targets/real-target.test.ts deleted file mode 100644 index 4f380d78d6..0000000000 --- a/packages/evals/src/targets/real-target.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { expect, test } from "bun:test"; - -import { findNewAgentReply } from "./real-target.ts"; - -function textMessage(id: string, address: string, text: string) { - return { id, sender: { address }, parts: [{ kind: "text", text }] }; -} - -test("finds a fresh agent-authored text message not yet seen", () => { - const items = [textMessage("m1", "user:1", "hello"), textMessage("m2", "agent:myra", "hi there")]; - const found = findNewAgentReply(items, "agent:myra", new Set()); - expect(found?.id).toBe("m2"); -}); - -test("ignores a message already recorded as seen", () => { - const items = [textMessage("m1", "agent:myra", "hi there")]; - const found = findNewAgentReply(items, "agent:myra", new Set(["m1"])); - expect(found).toBeUndefined(); -}); - -test("ignores a message from someone other than the agent", () => { - const items = [textMessage("m1", "user:1", "hello again")]; - const found = findNewAgentReply(items, "agent:myra", new Set()); - expect(found).toBeUndefined(); -}); - -test("ignores a part-less or text-less agent message (e.g. tool-only turn in flight)", () => { - const items = [ - { - id: "m1", - sender: { address: "agent:myra" }, - parts: [{ kind: "tool_call" }], - }, - ]; - const found = findNewAgentReply(items, "agent:myra", new Set()); - expect(found).toBeUndefined(); -}); - -test("returns the first unseen agent message when several are new", () => { - const items = [ - textMessage("m1", "agent:myra", "first"), - textMessage("m2", "agent:myra", "second"), - ]; - const found = findNewAgentReply(items, "agent:myra", new Set()); - expect(found?.id).toBe("m1"); -}); diff --git a/packages/evals/src/targets/real-target.ts b/packages/evals/src/targets/real-target.ts deleted file mode 100644 index a8ca0141d5..0000000000 --- a/packages/evals/src/targets/real-target.ts +++ /dev/null @@ -1,674 +0,0 @@ -// The live Myra `Target` (CL-6143): boots a real hub + sidecar against -// a real Postgres, signs up a throwaway user, connects an inference -// credential, deploys the "assistant" workflow, mints a chat with it, -// and plays scripted human turns through the real HTTP API — the same -// boot sequence `scripts/e2e/greeting-delivery.test.ts` proves end to -// end, generalized here into something `runEval`/`runMatrix` can drive -// once per matrix config instead of once per test file. -// -// Two modes, chosen by whether `EVAL_PROVIDER_API_KEY` is set: -// - live: a real Anthropic key, so replies are genuine model -// output and tool calls are genuine tool calls. -// - plumbing: a stub key (never sent anywhere real — the platform's -// own inference director folds the provider's 401 into a -// completed turn carrying a credential-error report, the -// same fixture `greeting-delivery.test.ts` and -// `local-rip.test.ts` already rely on), so every turn -// still gets delivered and recorded with zero real -// inference spend. `bun run eval` uses this mode to keep -// CI green with no key configured. -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -import { OLLAMA_PLACEHOLDER_SECRET } from "@corbits/connections/credential-test"; -import { createGitWorkflowPusher } from "./git-workflow-push"; -import { createHubAPI, type ApiCall } from "@corbits/hub-api-client"; -import { getLogger } from "@intx/log"; -import { generateKeyPair } from "@intx/crypto"; -import { createAgentRepoStore, createAssetService, type AssetService } from "@intx/hub-sessions"; -import type { DB } from "@intx/db"; -import { completeCredentialSetup } from "@workbench/onboarding"; -import { - signPayload, - WEBHOOK_SIGNATURE_HEADER, - WEBHOOK_TIMESTAMP_HEADER, -} from "@corbits/webhook-triggers"; - -import type { FakeReceipt, RunConfig, Target, Turn, WorldSnapshot } from "../types.ts"; -import type { McpFakeRecording } from "../fakes/recording.ts"; -import { startMcpFake } from "../fakes/mcp-fake-server.ts"; -import { startGithubRestFake } from "../fakes/github-rest-fake.ts"; -import { arrayField, stringField } from "./json-fields.ts"; -import { newToolCallsSince, readAllToolCalls, type SqlClientLike } from "./trace.ts"; - -/** One recorded MCP fake (packages/evals/src/fakes) to stand up and - * connect into the tenant before the target is handed back — connected - * through the exact same `POST /mcp-servers` route real users use - * (see mcp-fake-server.ts's module comment), never a second connect - * mechanism. */ -export interface MyraTargetMcpFake { - readonly server: string; - readonly recording: McpFakeRecording; -} - -export interface EvalSpawnedApp { - output(): string; - exited(): boolean; - stop(): Promise; -} - -export interface EvalHubHandle extends EvalSpawnedApp { - readonly baseUrl: string; -} - -export interface EvalApiResult { - status: number; - data: unknown; - cookies: string[]; -} - -/** - * The deployment plumbing `bootMyraTarget` runs on, injected by the - * caller (the repo's own e2e harness in `scripts/e2e/harness.ts` and - * `scripts/db-setup.ts`) so this package never reaches outside its own - * pack for repo-level scripts. - */ -export interface MyraTargetInfra { - e2eDatabaseUrl(): string | undefined; - resetSchema(databaseUrl: string): Promise; - setupDatabase(databaseUrl: string): Promise<{ action: string }>; - provisionSidecar(databaseUrl: string, sidecarId: string, token: string): Promise; - startHub(options: { - databaseUrl: string; - port: number; - sessionSecret: string; - dataDir: string; - /** Extra hub config env vars — how the boot points the hub's - * `GITHUB_API_BASE_URL` (CL-6403's seam) at the scratch GitHub REST - * fake this target starts. */ - extraEnv?: Record; - }): Promise; - startSidecar(options: { - hubPort: number; - sidecarId: string; - token: string; - dataDir: string; - }): EvalSpawnedApp; - api( - base: string, - method: string, - route: string, - body?: unknown, - cookies?: string[], - ): Promise; - expectStatus(what: string, result: EvalApiResult, expected: number): void; - connectE2eDb(databaseUrl: string): Promise }>; - freePort(): number; - /** Optional world-snapshot capability (targets/world-snapshot.ts): - * when the caller supplies this, the returned `Target` gains - * `snapshotWorld`. `hubDataDir` is the booted hub's own data dir, so - * the caller can stand up a read-equivalent `AssetService` over the - * same on-disk agent repos (see `createEvalAssetService` below); - * `fakeReceipts` hands back every call the - * target's connected MCP fakes received, so the snapshot's - * `fakeReceipts` come from the fakes this target actually started. */ - captureWorldSnapshot?: (args: { - tenantId: string; - hubDataDir: string; - fakeReceipts: () => readonly FakeReceipt[]; - }) => Promise; -} - -/** Never sent anywhere for real in plumbing mode — see the module - * comment. Only used when `EVAL_PROVIDER_API_KEY` is unset. */ -const log = getLogger(["evals", "real-target"]); - -/** - * A read-equivalent `AssetService` over a booted hub's own on-disk agent - * repos, for a world-scorer that reads real state (CL-6404). Built the - * same way hub boot builds one -- a fresh signing key only ever signs - * writes, which a snapshot reader never performs. - */ -export async function createEvalAssetService(args: { - db: DB["db"]; - dataDir: string; -}): Promise { - const signingKey = await generateKeyPair(); - const { repoStore } = createAgentRepoStore({ - dataDir: args.dataDir, - signingKey, - }); - return createAssetService({ db: args.db, repoStore }); -} - -const STUB_API_KEY = "corbits-evals-stub-key-not-real"; - -// The scratch GitHub REST fixture (CL-6405): what the fake origin -// answers for the connector PAT probe, the connect card's -// authenticated login, and its repo listing. The repo matches the -// factory case's `TARGET_REPO`, so start-reviewing's per-repo mint has -// exactly the repo the case's fired webhook names. -const GITHUB_REST_FAKE_PAT = "evals-fake-github-pat-not-real"; -const GITHUB_REST_FAKE_FIXTURE = { - login: "abklabs", - repos: [{ id: 101, full_name: "abklabs/workbench" }], -} as const; - -interface ChatMessage { - readonly id: string; - readonly sender: { readonly address: string }; - readonly parts: readonly { readonly kind: string; readonly text?: string }[]; -} - -/** Pure: the first message in `items` that (a) wasn't already in - * `seenIds`, (b) is authored by `agentAddress`, and (c) carries text — - * the "did Myra reply yet" check, factored out so it's unit-testable - * without booting anything. */ -export function findNewAgentReply( - items: readonly ChatMessage[], - agentAddress: string, - seenIds: ReadonlySet, -): ChatMessage | undefined { - return items.find( - (item) => - !seenIds.has(item.id) && - item.sender.address === agentAddress && - item.parts.some((part) => part.kind === "text" && (part.text ?? "") !== ""), - ); -} - -function replyTextOf(message: ChatMessage): string { - return message.parts - .filter((part) => part.kind === "text") - .map((part) => part.text ?? "") - .join(""); -} - -async function pollUntil( - what: string, - deadlineMs: number, - attempt: () => Promise, -): Promise { - const deadline = Date.now() + deadlineMs; - for (;;) { - const value = await attempt(); - if (value !== undefined) return value; - if (Date.now() > deadline) { - throw new Error(`${what}: timed out after ${String(deadlineMs)}ms`); - } - await Bun.sleep(1000); - } -} - -/** - * Boots one real Myra deployment and returns a `Target` that plays - * turns against it over the real HTTP API. The caller owns calling - * `close()` exactly once — every process, port, and DB connection this - * opens is released there, in reverse order, even if a later step in - * this function throws partway through boot. - */ -export async function bootMyraTarget( - config: RunConfig, - infra: MyraTargetInfra, - mcpFakes: readonly MyraTargetMcpFake[] = [], -): Promise { - const { - api, - connectE2eDb, - e2eDatabaseUrl, - expectStatus, - freePort, - provisionSidecar, - resetSchema, - setupDatabase, - startHub, - startSidecar, - } = infra; - if (config.systemPromptOverride !== undefined || config.toolPins !== undefined) { - // [Gap worth flagging: the live target has no wiring today to push a - // matrix entry's systemPromptOverride/toolPins into the deployed - // "assistant" workflow before minting a chat — `ensureSeeded` - // deploys the tenant's one stored copy of each default workflow, not - // a per-run variant. Failing loudly here beats silently ignoring the - // matrix entry's request.] - throw new Error( - `bootMyraTarget("${config.name}"): systemPromptOverride/toolPins are not ` + - "wired to the live target yet — see the comment above this throw", - ); - } - - const databaseUrl = e2eDatabaseUrl(); - if (databaseUrl === undefined) { - throw new Error( - "bootMyraTarget: DATABASE_URL is not set; the live Myra target needs a " + - "reachable Postgres (see .env.example)", - ); - } - - const cleanups: (() => Promise)[] = []; - async function closeAll(): Promise { - for (const cleanup of cleanups.splice(0).reverse()) { - await cleanup(); - } - } - - try { - await resetSchema(databaseUrl); - const report = await setupDatabase(databaseUrl); - if (report.action !== "migrated") { - throw new Error(`db setup: expected "migrated", got "${report.action}"`); - } - - const runToken = crypto.randomUUID().slice(0, 8); - const sidecarId = `evals-${config.name}-${runToken}`; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(databaseUrl, sidecarId, sidecarToken); - - // The fake GitHub REST origin starts before the hub so its URL can - // ride into the hub's own `GITHUB_API_BASE_URL` config (CL-6403): - // the `github` connector PAT probe, the connect card's repo - // listing, and the stored provider origin all resolve here instead - // of `https://api.github.com`. - const githubRest = startGithubRestFake(freePort(), { - login: GITHUB_REST_FAKE_FIXTURE.login, - repos: GITHUB_REST_FAKE_FIXTURE.repos, - }); - cleanups.push(() => { - githubRest.stop(); - return Promise.resolve(); - }); - - const hubDataDir = await mkdtemp(path.join(tmpdir(), "evals-hub-data-")); - const hub: EvalHubHandle = await startHub({ - databaseUrl, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: hubDataDir, - extraEnv: { GITHUB_API_BASE_URL: githubRest.url }, - }); - cleanups.push(() => hub.stop()); - cleanups.push(() => rm(hubDataDir, { recursive: true, force: true })); - - const sidecarDataDir = await mkdtemp(path.join(tmpdir(), "evals-sidecar-data-")); - const sidecar: EvalSpawnedApp = startSidecar({ - hubPort: Number(new URL(hub.baseUrl).port || "80"), - sidecarId, - token: sidecarToken, - dataDir: sidecarDataDir, - }); - cleanups.push(() => sidecar.stop()); - cleanups.push(() => rm(sidecarDataDir, { recursive: true, force: true })); - - const hubApi: ApiCall = createHubAPI(hub.baseUrl); - const pushWorkflow = createGitWorkflowPusher(); - - const email = `evals-${config.name}-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const signUpRes = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: `Evals ${config.name}`, - email, - password, - }); - expectStatus(`sign-up for ${config.name}`, signUpRes, 200); - if (signUpRes.cookies.length === 0) { - throw new Error(`sign-up for ${config.name} returned no session cookie`); - } - const userId = stringField( - (signUpRes.data as { user: unknown }).user, - "id", - `sign-up user field for ${config.name}`, - ); - const cookies = signUpRes.cookies; - - const provisionRes = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: `Evals ${config.name}'s Bench` }, - cookies, - ); - expectStatus(`provision personal bench for ${config.name}`, provisionRes, 200); - - const startedFakes = mcpFakes.map(({ recording }) => startMcpFake(recording, freePort())); - for (const fake of startedFakes) { - cleanups.push(() => { - fake.stop(); - return Promise.resolve(); - }); - } - - // EVAL_PROVIDER=ollama + OLLAMA_BASE_URL runs against a local Ollama - // (no key: the fixed placeholder secret); otherwise an Anthropic key. - const ollamaBaseUrl = process.env["OLLAMA_BASE_URL"]; - const useOllama = process.env["EVAL_PROVIDER"] === "ollama" && ollamaBaseUrl !== undefined; - const provider = useOllama ? ("ollama" as const) : ("anthropic" as const); - const apiKey = useOllama - ? OLLAMA_PLACEHOLDER_SECRET - : (process.env["EVAL_PROVIDER_API_KEY"] ?? STUB_API_KEY); - - const seeded = await pollUntil( - "connecting the inference credential and deploying default workflows", - 60_000, - async () => { - try { - const outcome = await completeCredentialSetup({ - api: hubApi, - cookies, - hubUrl: hub.baseUrl, - userId, - userEmail: email, - provider, - apiKey, - pushWorkflow, - log: () => undefined, - ...(useOllama && ollamaBaseUrl !== undefined ? { baseURLOverride: ollamaBaseUrl } : {}), - }); - if (outcome.kind !== "seeded") { - throw new Error(`expected "seeded", got: ${JSON.stringify(outcome)}`); - } - return outcome; - } catch (cause) { - if (sidecar.exited()) { - throw new Error(`sidecar exited while seeding; output:\n${sidecar.output()}`, { - cause, - }); - } - if (process.env["EVALS_DEBUG"] === "1") { - log.error`seeding attempt failed, retrying: ${cause instanceof Error ? cause.message : String(cause)}`; - } - return undefined; - } - }, - ); - - // Connect every started fake through the exact same route Plugins - // uses for a real MCP server — no eval-only connect mechanism. - for (const [index, fake] of startedFakes.entries()) { - const connectRes = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/mcp-servers`, - { name: mcpFakes[index]?.server, url: fake.url }, - cookies, - ); - expectStatus(`connect MCP fake "${mcpFakes[index]?.server ?? "?"}"`, connectRes, 200); - } - - // The Plugins-PAT half of a GitHub connect (CL-6403's seam, closed - // here for the scratch stack): the same `POST .../connections/github/complete` - // route Settings > Connections drives, whose probe now lands on the - // REST fake the hub's `GITHUB_API_BASE_URL` names. This is what - // makes `githubConnectedViaConnectionsLayer`'s connector-credential - // half real, and what start-reviewing's repo listing resolves. - const completeRes = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/connections/github/complete`, - { apiKey: GITHUB_REST_FAKE_PAT }, - cookies, - ); - expectStatus("connect the GitHub PAT against the REST fake", completeRes, 200); - - const assistantDefinitionId = await pollUntil( - '"assistant" becoming invitable', - 60_000, - async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${seeded.tenantId}/chat/invitable-definitions`, - undefined, - cookies, - ); - if (res.status !== 200) return undefined; - const items = arrayField(res.data, "items", "list invitable definitions") as { - id: string; - name: string; - }[]; - return items.find((item) => item.name === "assistant")?.id; - }, - ); - - const { chatId, agentAddress } = await pollUntil( - "POST /workbenches kind=chat definitionId=assistant", - 60_000, - async () => { - if (sidecar.exited()) { - throw new Error(`sidecar exited before chat creation; output:\n${sidecar.output()}`); - } - const res: EvalApiResult = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/chat/workbenches`, - { kind: "chat", definitionId: assistantDefinitionId }, - cookies, - ); - if (res.status === 500) return undefined; - expectStatus("create chat", res, 201); - const id = stringField(res.data, "id", "create chat"); - const participants = arrayField(res.data, "participants", "create chat") as { - address: string; - handle: string; - }[]; - const agent = participants.find((participant) => participant.handle === "myra"); - if (agent === undefined) { - throw new Error(`chat has no "myra" agent participant: ${JSON.stringify(participants)}`); - } - return { chatId: id, agentAddress: agent.address }; - }, - ); - - const sql = await connectE2eDb(databaseUrl); - cleanups.push(() => sql.end()); - const sqlClient: SqlClientLike = sql; - - const seenMessageIds = new Set(); - let toolCallsConsumed = 0; - - function bootFailureOutput(): string { - return ( - `hub output (tail):\n${hub.output().slice(-60_000)}\n` + - `sidecar output (tail):\n${sidecar.output().slice(-6_000)}` - ); - } - - // `POST /workbenches` fires `postCannedGreeting` fire-and-forget - // (see `packages/chat/src/routes.ts`) right after the chat mints — - // the unprompted canned greeting `greeting-delivery.test.ts` proves - // lands with zero user messages sent. That test waits for the - // greeting's own agent-authored message to land *before* sending - // its first human turn; this target must do the same, or the first - // scripted turn's human message and mail fan-out race the still - // in-flight greeting post's own record-mail to the channel host, - // which is what the "run 'run_' is terminal" rejection - // traces back to. The greeting's message id (and its tool calls) are - // folded in as already-seen so `sendTurn`'s own reply/tool-call - // bookkeeping for turn 1 starts clean. - const greeting = await pollUntil( - "Myra's unprompted greeting landing with zero user messages sent", - 300_000, - async () => { - if (sidecar.exited()) { - throw new Error(`sidecar exited waiting for the greeting; ${bootFailureOutput()}`); - } - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${seeded.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - cookies, - ); - expectStatus("list messages while waiting for the greeting", res, 200); - const items = arrayField( - res.data, - "items", - "list messages while waiting for the greeting", - ) as ChatMessage[]; - return findNewAgentReply(items, agentAddress, seenMessageIds); - }, - ).catch((cause) => { - throw new Error(`no unprompted greeting within 300s; ${bootFailureOutput()}`, { cause }); - }); - seenMessageIds.add(greeting.id); - // See the settle-window comment in `sendTurn` below — the greeting's - // own record-mail into the channel host needs the same room to land - // before the first scripted turn posts. - await Bun.sleep(3_000); - const greetingToolCalls = await readAllToolCalls(sqlClient, seeded.tenantId, chatId); - toolCallsConsumed = newToolCallsSince(greetingToolCalls, toolCallsConsumed).consumed; - - async function sendTurn(human: string): Promise { - const postRes = await api( - hub.baseUrl, - "POST", - `/api/tenants/${seeded.tenantId}/chat/workbenches/${chatId}/messages`, - { parts: [{ kind: "text", text: human }] }, - cookies, - ); - expectStatus(`post message "${human}"`, postRes, 201); - - const beforeRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${seeded.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - cookies, - ); - expectStatus("list messages before reply", beforeRes, 200); - for (const item of arrayField( - beforeRes.data, - "items", - "list messages before reply", - ) as ChatMessage[]) { - seenMessageIds.add(item.id); - } - - // A 27B local Ollama model, with tools, can take well over two - // minutes for one turn — 300s gives it room without masking a - // genuine hang (the sidecar-exited check above still fails fast - // on that). - let lastItems: ChatMessage[] = []; - const reply = await pollUntil(`Myra's reply to "${human}"`, 300_000, async () => { - if (sidecar.exited()) { - throw new Error(`sidecar exited waiting for a reply; ${bootFailureOutput()}`); - } - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${seeded.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - cookies, - ); - expectStatus("list messages", res, 200); - const items = arrayField(res.data, "items", "list messages") as ChatMessage[]; - lastItems = items; - return findNewAgentReply(items, agentAddress, seenMessageIds); - }).catch((cause) => { - throw new Error( - `no reply within 300s; last-seen messages: ${JSON.stringify(lastItems)}\n` + - bootFailureOutput(), - { cause }, - ); - }); - seenMessageIds.add(reply.id); - - // A landed reply still has its own record-mail settling into the - // channel host's shared timeline (`sendChannelMessage`'s "for the - // record" delivery) — posting the next turn's human message before - // that settles races the host's write with this turn's, which is - // what a `path_violation` pack rejection and a subsequent - // "workflow run ... is terminal" (see the module comment) traces - // back to. A short settle window here is the same fix - // `greeting-delivery.test.ts` reaches for via `E2E_TURN2_DELAY_MS`. - await Bun.sleep(3_000); - - const allToolCalls = await readAllToolCalls(sqlClient, seeded.tenantId, chatId); - const { newCalls, consumed } = newToolCallsSince(allToolCalls, toolCallsConsumed); - toolCallsConsumed = consumed; - - return { human, replyText: replyTextOf(reply), toolCalls: newCalls }; - } - - function fakeReceipts(): readonly FakeReceipt[] { - return [...startedFakes.flatMap((fake) => fake.receipts()), ...githubRest.receipts()]; - } - - // Fires a trigger through the REAL ingress route - // (`POST /api/webhooks/:triggerId`), HMAC-signed the way any - // external sender signs. The signing secret is read off the - // platform's own table (the trace.ts convention) — the eval hub - // boots with ALLOW_PLAINTEXT_SECRETS, so the scratch row carries it - // readable; the route itself still verifies the signature for real. - async function fireWebhook(triggerId: string, payload: unknown): Promise { - const rows = await sqlClient.unsafe( - "select secret from webhook_triggers.webhook_trigger where id = $1", - [triggerId], - ); - const secret = rows[0]?.["secret"]; - if (typeof secret !== "string" || secret === "") { - throw new Error( - `fireWebhook("${triggerId}"): no readable signing secret on the trigger row`, - ); - } - const rawBody = JSON.stringify(payload); - const timestamp = String(Math.floor(Date.now() / 1000)); - const response = await fetch(new URL(`/api/webhooks/${triggerId}`, hub.baseUrl), { - method: "POST", - headers: { - "content-type": "application/json", - [WEBHOOK_TIMESTAMP_HEADER]: timestamp, - [WEBHOOK_SIGNATURE_HEADER]: signPayload(secret, timestamp, rawBody), - }, - body: rawBody, - }); - // Read as text first: a failed launch surfaces as the hub's own - // non-JSON 500 body, which must land in the recorded turn rather - // than die as a JSON parse crash that hides the status. A non-202 - // is recorded honestly (the same convention as the runner's - // no-trigger miss) so the step's scorers grade red with the real - // failure instead of the whole run crashing. - const rawResponse = await response.text(); - if (response.status !== 202) { - return { - human: `(harness) fire webhook trigger ${triggerId}`, - replyText: - `webhook delivery failed: ingress returned ` + - `${String(response.status)}: ${rawResponse.slice(0, 2_000)}\n` + - `hub output (tail):\n${hub.output().slice(-4_000)}`, - toolCalls: [], - }; - } - const data: unknown = JSON.parse(rawResponse); - return { - human: `(harness) fire webhook trigger ${triggerId}`, - replyText: - `webhook delivery accepted: instance ` + - `${stringField(data, "instanceId", "webhook fire")} at ` + - `${stringField(data, "address", "webhook fire")}`, - toolCalls: [], - }; - } - - const captureWorldSnapshot = infra.captureWorldSnapshot; - return { - configName: config.name, - sendTurn, - fireWebhook, - close: closeAll, - ...(captureWorldSnapshot === undefined - ? {} - : { - snapshotWorld: (): Promise => - captureWorldSnapshot({ - tenantId: seeded.tenantId, - hubDataDir, - fakeReceipts, - }), - }), - }; - } catch (cause) { - await closeAll(); - throw cause; - } -} diff --git a/packages/hub-api-client/src/errors.ts b/packages/hub-api-client/src/errors.ts index 9afb0331ec..488dfc2755 100644 --- a/packages/hub-api-client/src/errors.ts +++ b/packages/hub-api-client/src/errors.ts @@ -22,11 +22,10 @@ export function isHubApiError(value: unknown): value is HubApiError { * a workflow-deployment create with 502 `sidecar_unavailable`: the * sidecar that hosts workflow execution is down, but every durable step * ahead of it (tenant, grants, credential, catalog, workflow assets) - * already succeeded. Onboarding's `ensureSeeded` (see - * `@workbench/onboarding`'s `complete-credential.ts`) parses this exact - * class to finish the request successfully with an honest "agents - * pending" report, instead of failing the whole flow the way any other - * `HubApiError` still does. + * already succeeded. A caller deploying several workflows in sequence + * can parse this exact class to finish successfully with an honest + * "agents pending" report, instead of failing the whole flow the way + * any other `HubApiError` still does. */ export class SidecarUnavailableError extends HubApiError { constructor(problem: string, fix: string, options?: ErrorOptions) { diff --git a/packages/inbox/src/migrations.ts b/packages/inbox/src/migrations.ts index d20c39fb75..7e750c7ee9 100644 --- a/packages/inbox/src/migrations.ts +++ b/packages/inbox/src/migrations.ts @@ -9,7 +9,7 @@ // CL-7208 added `inbox.snooze` for a snooze's `until` timestamp; // CL-8185 drops it forward — snooze is gone per owner ruling. // Bookkeeping uses its own ledger table, following the same pattern as -// `@workbench/onboarding`'s `migrations.ts`, so this package's migration +// the deleted `@workbench/onboarding`'s `migrations.ts`, so this package's migration // history stays extractable on its own. import { createMailboxDb, runMailboxMigrations } from "@corbits/mailbox"; diff --git a/packages/onboarding/LICENSE b/packages/onboarding/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/onboarding/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/onboarding/README.md b/packages/onboarding/README.md deleted file mode 100644 index 888c9c9472..0000000000 --- a/packages/onboarding/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# @workbench/onboarding - -First-login provisioning for the hub: a signed-in session with zero -principals gets a personal bench, and a guided credential step lets a new -user paste or connect their own inference-provider key before their first -workflow deploys. - -## How it composes with Interchange - -- `provision.ts`'s `provisionPersonalTenantIfNeeded` mints the personal - bench through the hub's native tenant-creation route and - `./src/tenant-seed.ts`'s `seedTenant`/`DEFAULT_WORKFLOWS` — never a - product-owned tenant table of its own. -- `complete-credential.ts` proves a pasted key with - `@corbits/connections`'s `testProviderCredential` before storing it - through the hub's native `POST /api/tenants/:id/credentials` - (`ensureCredential`/`ensureProvider`), then runs the same - `seedCatalog`/`seedTenant` path an operator-configured key runs. -- `huggingface-connect.ts` and `openrouter-connect.ts` are thin - re-exports: the actual OAuth/PKCE connect mechanics live in - `@corbits/connections` (CL-6028 generalized both flows into that - package's OAuth route factory) and are kept here only so existing - imports don't break. -- `@intx/crypto`'s `CredentialCipher` seals the plaintext key carried from - an OAuth callback to onboarding's own follow-up request (see - `pending-seed.ts`); `@workbench/access-policy` and `@corbits/hub-api-client` - supply the grant and tenant primitives provisioning reuses. - -## Key modules - -- `provision.ts` — the first-login decision and personal-bench - provisioning. -- `routes.ts` — `POST /provision`, mounted outside tenant-prefixed routes - since a brand-new user belongs to no tenant yet. -- `complete-credential.ts` — the guided credential step: ` -testAndPersistCredential` (fast, safe for an OAuth callback to await) - and `ensureSeeded` (slow, the workflow-deploy half, run separately so a - browser is never left waiting mid-redirect). -- `pending-seed.ts` — server-side custody of a just-connected credential's - plaintext key between the OAuth callback and the follow-up seed request. -- `schema.ts` / `migrations.ts` — the package's own `onboarding.pending_seed` - table, in its own Postgres schema, with its own migration ledger. - -## Running tests - -``` -cd packages/onboarding && bun test -``` - -`test/migrations.test.ts` and `test/pending-seed-store.drizzle.test.ts` -need a live Postgres: `DATABASE_URL=postgres://localhost:5432/workbench_e2e`. diff --git a/packages/onboarding/package.json b/packages/onboarding/package.json deleted file mode 100644 index 710cf8dbc8..0000000000 --- a/packages/onboarding/package.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "name": "@workbench/onboarding", - "version": "0.0.1", - "private": true, - "description": "First-login provisioning for the hub: the onboarding route and the decision behind it", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./desired-state": "./src/desired-state.ts", - "./migrations": "./src/migrations.ts", - "./tenant-seed": "./src/tenant-seed.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@corbits/assistant-workflow": "workspace:*", - "@corbits/attio-task-agent-workflow": "workspace:*", - "@corbits/code-review-workflow": "workspace:*", - "@corbits/collateral-generation-workflow": "workspace:*", - "@corbits/collections": "workspace:*", - "@corbits/connections": "workspace:*", - "@corbits/diligence-brief-workflow": "workspace:*", - "@corbits/echo-workflow": "workspace:*", - "@corbits/error-sink": "workspace:*", - "@corbits/exa-topic-watch-workflow": "workspace:*", - "@corbits/granola-call-workflow": "workspace:*", - "@corbits/hub-api-client": "workspace:*", - "@corbits/last-30-days-research-workflow": "workspace:*", - "@corbits/morning-brief-workflow": "workspace:*", - "@corbits/pain-point-collateral-workflow": "workspace:*", - "@corbits/process-granola-call-workflow": "workspace:*", - "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", - "@corbits/tool-registry-publish": "workspace:*", - "@corbits/workbench-digest-workflow": "workspace:*", - "@corbits/workflows": "workspace:*", - "@intx/agent": "workspace:*", - "@intx/crypto": "0.3.0", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "workspace:*", - "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/onboarding/src/bench-provisioning.ts b/packages/onboarding/src/bench-provisioning.ts deleted file mode 100644 index 14ccac84a3..0000000000 --- a/packages/onboarding/src/bench-provisioning.ts +++ /dev/null @@ -1,368 +0,0 @@ -// The one place a bench's default workflows are ever deployed -// (CL-6457). Connecting a provider used to do it inline: a pasted key -// sat on "Connecting…" for minutes while five workflows deployed at -// ~20s each, and the owner reasonably concluded the app had frozen. -// Connect now does the durable, fast half only — persist the credential, -// seed its catalog — and this converges the bench afterwards, off the -// request path entirely. -// -// It is a drain over `onboarding.pending_seed`, not a queue of jobs: the -// row IS the work item, and what it means is "this bench has a -// credential and does not yet have its agents". That framing is what -// makes the three properties fall out rather than have to be engineered: -// -// - Idempotent. Every pass reconciles the bench against the tenant -// desired-state document (`reconcileTenantDesiredState`) — ensure- -// then-create at every step — so a pass over a converged bench -// deploys nothing and simply clears the row. -// - Convergent. A pass that gets partway — pins reported `blocked` -// (sidecar unavailable) or `failed` — leaves the row in place, so -// the next pass picks up exactly the pins that are still missing. -// - Restart-safe. Nothing about a bench's outstanding work lives in -// this process. A hub that dies mid-deploy leaves the row behind, -// and the next boot's first tick finishes it. In-memory state here -// is only ever an optimization (a dedupe guard, a retry backoff), -// never a fact the system needs to be correct. -// -// Sessions are the one thing a background loop cannot inherit: there is -// no request to borrow cookies from. `sessionFor` is that seam — the -// composition root decides how a session is minted for a user, and this -// module stays out of the auth mechanism entirely. - -import { reportError } from "@corbits/error-sink"; -import type { ModelSource } from "./tenant-seed"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import { type ApiCall } from "@corbits/hub-api-client"; -import { - reconcileTenantDesiredState, - resolveTenantModelSource, - type ReconcileReport, -} from "./desired-state"; -import { - PENDING_SEED_SCAN_LIMIT, - type PendingSeed, - type PendingSeedListCursor, - type PendingSeedStore, -} from "./pending-seed"; - -export const PROVISIONING_POLL_INTERVAL_MS = 15_000; -const RETRY_BACKOFF_BASE_MS = 15_000; -const RETRY_BACKOFF_CEILING_MS = 10 * 60 * 1000; - -/** - * Mints the hub session the drain acts under for one user's own bench, - * or `undefined` when no session can be minted (an account since - * deleted, an auth backend briefly unavailable). Returning `undefined` - * holds the bench for a later pass — it never discards the row. - */ -export type SessionForUser = (args: { - userId: string; - tenantId: string; -}) => Promise; - -export type BenchProvisionerDeps = { - api: ApiCall; - hubUrl: string; - store: PendingSeedStore; - pushWorkflow: WorkflowPusher; - sessionFor: SessionForUser; - log: (line: string) => void; - logError?: (line: string) => void; - /** - * The convergence step, doc-driven (CL-7584): reads the tenant's real - * state against `TENANT_DESIRED_STATE` and installs only absent pins. - * Production resolves the deploy model from the tenant's catalog and - * delegates to `reconcileTenantDesiredState`; tests replace the whole - * thing. A `blocked` report keeps the row; `failed` keeps it too and - * counts as a failure for backoff. - */ - reconcileFn?: (args: { - api: ApiCall; - cookies: string[]; - hubUrl: string; - tenant: { tenantId: string; principalId: string; domain: string }; - pushWorkflow: WorkflowPusher; - log: (line: string) => void; - }) => Promise; - now?: () => number; -}; - -/** What one pass over one bench concluded. `converged` and `pending` are - * both healthy — the difference is only whether there is more to do. */ -export type BenchProvisionOutcome = "converged" | "pending" | "failed"; - -export type DrainReport = { - readonly converged: number; - readonly pending: number; - readonly failed: number; - /** Benches skipped this tick because a previous failure's backoff has - * not elapsed, or because a pass over them is still running. */ - readonly deferred: number; - /** True when this tick stopped because its scan page filled the limit - * with more rows still in the table. The next tick continues after this - * page's cursor rather than restarting at the oldest row. False when - * the scan reached the end of the table. */ - readonly truncated: boolean; -}; - -export type BenchProvisioner = { - /** One bench, start to finish. Exposed so a connect can kick its own - * bench immediately instead of waiting out a poll interval. */ - provisionBench(seed: PendingSeed): Promise; - drainOnce(args?: { ignoreBackoff?: boolean }): Promise; - /** Fire-and-forget drain for callers that must not wait on it — a - * route that just wrote a pending row, or hub boot. Never rejects. */ - wake(): void; - start(args?: { intervalMs?: number }): void; - stop(): void; -}; - -function benchKey(seed: { userId: string; tenantId: string }): string { - return `${seed.userId}:${seed.tenantId}`; -} - -export function createBenchProvisioner(deps: BenchProvisionerDeps): BenchProvisioner { - const now = deps.now ?? Date.now; - const logError = deps.logError ?? deps.log; - - /** - * The production reconcile: resolve the tenant's deploy model from its - * resolved catalog, then install only the desired-state pins that are - * absent. `undefined` model means no launchable offering — reconcile - * reports the workflow pins blocked rather than throwing. - */ - const runReconcile: NonNullable = - deps.reconcileFn ?? - (async (args) => { - const model: ModelSource | undefined = await resolveTenantModelSource( - args.api, - args.cookies, - args.tenant.tenantId, - ); - return reconcileTenantDesiredState({ ...args, model }); - }); - - const inFlight = new Map>(); - // Backoff bookkeeping for a failing bench, keyed the same way - // `inFlight` is. This is process-local, in-memory state — never a - // fact the system needs to be correct, only a hammering guard — but - // with no eviction it survives its own bench forever: a bench that - // fails permanently accumulates here even after its `pending_seed` - // row TTL-expires out from under it (CL-7233). `userId`/`tenantId` - // are carried alongside the counters (not re-derived from the map - // key) so `pruneOrphanedHolds` can ask the store directly whether a - // hold's row still exists, rather than assuming a compound string key - // splits back apart cleanly. - const holds = new Map< - string, - { - retryAfter: number; - failureCount: number; - userId: string; - tenantId: string; - } - >(); - let timer: ReturnType | undefined; - let scanAfter: PendingSeedListCursor | undefined; - - function holdOff(seed: { userId: string; tenantId: string }): void { - const key = benchKey(seed); - const failures = (holds.get(key)?.failureCount ?? 0) + 1; - const backoff = Math.min(RETRY_BACKOFF_BASE_MS * 2 ** (failures - 1), RETRY_BACKOFF_CEILING_MS); - holds.set(key, { - retryAfter: now() + backoff, - failureCount: failures, - userId: seed.userId, - tenantId: seed.tenantId, - }); - } - - function clearHold(key: string): void { - holds.delete(key); - } - - /** - * Reclaims a hold whose bench is permanently gone rather than merely - * quiet this tick (CL-7233). A hold already appearing in `due` is - * left alone unconditionally — its row still exists, backoff or not. - * For every other hold, `store.read` is the authoritative check - * (unlike `due`, which is `listDue`'s capped-and-unordered page and - * can omit a row that still exists): a row is deleted the moment it - * is read past its TTL, so `undefined` here means the row is truly - * gone, not just off this tick's page. - */ - async function pruneOrphanedHolds(due: readonly PendingSeed[]): Promise { - const dueKeys = new Set(due.map(benchKey)); - for (const [key, hold] of holds) { - if (dueKeys.has(key)) continue; - const row = await deps.store.read({ - userId: hold.userId, - tenantId: hold.tenantId, - }); - if (row === undefined) holds.delete(key); - } - } - - async function runOnce(seed: PendingSeed): Promise { - const cookies = await deps.sessionFor({ - userId: seed.userId, - tenantId: seed.tenantId, - }); - if (cookies === undefined) { - logError( - `bench provisioning for tenant ${seed.tenantId} has no session to act under; holding for a later pass`, - ); - return "failed"; - } - - // Doc-driven convergence (CL-7584): reconcile installs only the - // desired-state pins this bench is still missing. Row semantics are - // unchanged — ready clears the row, blocked keeps it as pending, - // failed keeps it and backs off. - let report: ReconcileReport; - try { - report = await runReconcile({ - api: deps.api, - cookies, - hubUrl: deps.hubUrl, - tenant: { - tenantId: seed.tenantId, - principalId: seed.principalId, - domain: seed.tenantDomain, - }, - pushWorkflow: deps.pushWorkflow, - log: deps.log, - }); - } catch (cause) { - reportError(cause, { - operation: "pending_seed_reconcile", - tenantId: seed.tenantId, - }); - logError( - `bench provisioning for tenant ${seed.tenantId} failed; its pending row stays for a retry: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - return "failed"; - } - - if (report.ready) { - await deps.store.clear({ userId: seed.userId, tenantId: seed.tenantId }); - deps.log(`bench ${seed.tenantId} finished provisioning (${report.pins.length} pins present)`); - return "converged"; - } - - if (report.pins.some((pin) => pin.status === "failed")) { - deps.log( - `bench provisioning for tenant ${seed.tenantId} failed; its pending row stays for a retry`, - ); - return "failed"; - } - - deps.log( - `bench ${seed.tenantId} is partly provisioned; its pending row stays for the next pass`, - ); - return "pending"; - } - - async function provisionBench(seed: PendingSeed): Promise { - const key = benchKey(seed); - const running = inFlight.get(key); - if (running !== undefined) return "deferred"; - - const operation = (async (): Promise => { - try { - const outcome = await runOnce(seed); - if (outcome === "converged") clearHold(key); - else holdOff(seed); - return outcome; - } catch (cause) { - // report-error-ignore: CL-7234 routes this drain catch through - // reportError the way provision.ts already does - const message = cause instanceof Error ? cause.message : String(cause); - logError( - `bench provisioning for tenant ${seed.tenantId} failed; its pending row stays for a retry: ${message}`, - ); - holdOff(seed); - return "failed"; - } - })(); - - inFlight.set(key, operation); - try { - return await operation; - } finally { - if (inFlight.get(key) === operation) inFlight.delete(key); - } - } - - async function drainOnce(args: { ignoreBackoff?: boolean } = {}): Promise { - const page = await deps.store.listDue({ - limit: PENDING_SEED_SCAN_LIMIT, - ...(scanAfter !== undefined ? { after: scanAfter } : {}), - }); - let converged = 0; - let pending = 0; - let failed = 0; - let deferred = 0; - - for (const seed of page.seeds) { - const key = benchKey(seed); - const heldUntil = holds.get(key)?.retryAfter; - if (args.ignoreBackoff !== true && heldUntil !== undefined && heldUntil > now()) { - deferred += 1; - continue; - } - if (args.ignoreBackoff === true) clearHold(key); - - const outcome = await provisionBench(seed); - if (outcome === "converged") converged += 1; - else if (outcome === "pending") pending += 1; - else if (outcome === "failed") failed += 1; - else deferred += 1; - } - - if (page.truncated && page.next !== undefined) { - scanAfter = page.next; - } else { - scanAfter = undefined; - } - - await pruneOrphanedHolds(page.seeds); - - return { - converged, - pending, - failed, - deferred, - truncated: page.truncated, - }; - } - - function wake(): void { - void drainOnce().catch((cause: unknown) => { - const message = cause instanceof Error ? cause.message : String(cause); - logError(`bench provisioning drain failed: ${message}`); - // Not scoped to any one bench — a whole-tick failure (listDue - // itself throwing, say) rather than one seed's own provisioning - // failure, which already carries tenant context via holdOff - // (CL-7234). - reportError(cause, { operation: "bench_provisioning_drain" }); - }); - } - - return { - provisionBench, - drainOnce, - wake, - start(args = {}) { - if (timer !== undefined) return; - timer = setInterval(wake, args.intervalMs ?? PROVISIONING_POLL_INTERVAL_MS); - timer.unref?.(); - wake(); - }, - stop() { - if (timer === undefined) return; - clearInterval(timer); - timer = undefined; - }, - }; -} diff --git a/packages/onboarding/src/complete-credential.ts b/packages/onboarding/src/complete-credential.ts deleted file mode 100644 index 5c236aca1b..0000000000 --- a/packages/onboarding/src/complete-credential.ts +++ /dev/null @@ -1,570 +0,0 @@ -// The guided credential step of first-run: a signed-in user who reached -// onboarding with no seed model configured picks a provider — any of -// `supportedCredentialProviders()` — and pastes their own key. CL-6123 -// dropped the blocking probe that used to sit in front of this: an -// onboarding submission is accepted and stored immediately, with no -// live call to the provider gating it. A key that turns out to be wrong -// is caught later, the first time it is actually dialed for real -// inference, and surfaces in-chat through the existing credential-error -// + "Fix this connection" flow (CL-6092) — that is the designed place to -// catch a bad key, not a synchronous check onboarding makes someone wait -// on. Storing plants the credential and its catalog through the hub's -// native `POST /api/tenants/:id/credentials` route (see `seedCatalog`'s -// `ensureCredential`) — this module never stores a secret itself — the -// same `seedCatalog` + `seedTenant` the first-login hook runs when a -// hub-owned key is configured, so a self-served key and an -// operator-configured one land the same bench. -// -// Two halves, on purpose. `testAndPersistCredential` — the fast half — -// plants the credential and its catalog; nothing in it ever deploys a -// workflow, so it is all any connect route runs before answering. -// `ensureSeeded` — the slow half — is the workflow-deploy step, minutes -// of deploy calls no browser has any business waiting on. -// -// CL-6457 finished that split: NO HTTP route runs the slow half any -// more. Connecting a provider persists the credential, seeds its -// catalog, and returns in seconds; the deploys belong to the background -// drain in `./bench-provisioning.ts`, which is also what makes them -// survive a hub restart. `completeCredentialSetup` at the bottom of this -// file still composes both halves back-to-back, but it is an -// eval-harness convenience — a bench that must be fully deployed before -// a scenario runs — and never the connect path. A route that calls it -// re-creates the 2+ minute "Connecting…" freeze this split exists to -// prevent. -// -// `ensureSeeded` runs with `confirmDeployments: false`: there is nothing -// to confirm by triggering a real, billed inference call against an -// account whose key was never probed — only insufficient credit, a bad -// key, or a busy sidecar for `confirmDeployments: true` to fail -// spuriously on. Deployment itself still runs (it is configuration, not -// inference), so the bench's default workflows are genuinely usable -// once this returns; whether the key actually works is proven the first -// time a workflow really dials it. -// -// The workflows deploy against the connected provider's own default -// model — read straight out of `CATALOG_SEEDS` (`catalog-seed-data.ts`), -// the one place a provider's curated model list is declared, rather than -// a second, hand-maintained model choice living here. This is also what -// recovers a bench the hub's own sign-in hook -// (`provisionPersonalTenantIfNeeded`) could only mark `bench_unseeded` -// (no hub-owned `ANTHROPIC_API_KEY` to seed with): the first working -// credential a user connects — through this module, whichever -// onboarding path reaches it — finishes seeding with that credential's -// own provider, so an OAuth-only or bring-your-own-key user is never -// stuck waiting on an operator-configured key. - -import { - ModelInfo, - ModelResponse, - PrincipalSummary, - TenantResponse, - paginatedSchema, -} from "@intx/types"; -import { type SupportedCredentialProvider } from "@corbits/connections/credential-test"; -import { preferCompletionCapable } from "@corbits/connections/model-capability"; -import { CATALOG_SEEDS } from "@corbits/connections/catalog-seed-data"; -import { - DEFAULT_WORKFLOWS, - seedTenant, - type ModelSource, - type SeedTenantArgs, -} from "./tenant-seed"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import { isSidecarUnavailableError, parseAs, type ApiCall } from "@corbits/hub-api-client"; -import { - persistConnectorCredential, - type PersistConnectorCredentialFns, -} from "@corbits/connections/persist-credential"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; -import { TENANT_DESIRED_STATE, seededWorkflowNames } from "./desired-state"; -import { personalTenantSlug } from "./provision"; - -/** The onboarding UI's copy for a partial seed: every durable step - * (credential, tenant, grants, assets) already succeeded, and the - * deferred workflows finish deploying on their own the next time this - * account's onboarding page reads `POST /complete-setup` — see - * `ensureSeeded`'s own doc comment below for the sidecar-unavailable - * class this covers. */ -export const AGENTS_PENDING_MESSAGE = "Your workbench is ready — agents will come online shortly."; - -export type PersonalTenant = { - readonly tenantId: string; - readonly tenantSlug: string; - readonly principalId: string; - readonly tenantDomain: string; -}; - -export type TestAndPersistCredentialResult = - /** - * Kept for API compatibility with dependents that accept it as a - * possible outcome (`@corbits/connections`' `OAuthStoreOutcome`, - * matched "exactly" per that module's own doc comment) — the default - * implementation below never produces it, since CL-6123 dropped the - * probe that used to be the only thing that could. - */ - | { readonly kind: "invalid-credential"; readonly message: string } - | { readonly kind: "no-personal-bench" } - | ({ readonly kind: "connected" } & PersonalTenant); - -export type EnsureSeededResult = - | { readonly kind: "seeded"; readonly workflows: string[] } - /** - * The deploy step hit the sidecar-unavailable class specifically - * (CL-6264): every durable step ahead of it (credential, tenant, - * grants, catalog, workflow assets) is intact, `deployed` names the - * default workflows that made it live before the sidecar dropped out, - * and `pending` names the rest. Never produced for any other deploy - * failure — those still throw, same as before. - */ - | { - readonly kind: "seeded-pending-agents"; - readonly deployed: string[]; - readonly pending: string[]; - readonly message: string; - }; - -export type CompleteCredentialResult = - | { readonly kind: "invalid-credential"; readonly message: string } - | { readonly kind: "no-personal-bench" } - | { - readonly kind: "seeded"; - readonly tenantId: string; - readonly tenantSlug: string; - readonly workflows: string[]; - } - | { - readonly kind: "seeded-pending-agents"; - readonly tenantId: string; - readonly tenantSlug: string; - /** Threaded through to `routes.ts`'s `/complete` handler, which - * writes them into the same `pendingSeedStore` row an OAuth - * connect writes (see `./pending-seed.ts`) so `POST - * /complete-setup`'s existing retry path — no new queue — finishes - * the deferred workflows on this account's next onboarding-page - * visit. */ - readonly principalId: string; - readonly tenantDomain: string; - readonly deployed: string[]; - readonly pending: string[]; - readonly message: string; - }; - -type CommonArgs = { - api: ApiCall; - cookies: string[]; - hubUrl: string; - pushWorkflow: WorkflowPusher; - log: (line: string) => void; -}; - -export type TestAndPersistCredentialArgs = CommonArgs & - PersistConnectorCredentialFns & { - userId: string; - userEmail: string; - provider: SupportedCredentialProvider; - apiKey: string; - /** - * Free-form data stored on the credential's `metadata` field — - * provider labels (an OAuth account id, an MCP client registration). - * An expiring OAuth token's expiry lives on the first-class - * `expiresAt` field below, which writes the credential row's own - * column that serving-time refresh keys on; a metadata fold may - * also carry it (that fold is what types the row `oauth_token`), - * but it must not be the expiry's only ride. Absent for a - * pasted key or a durable-key connect flow (OpenRouter). - */ - credentialMetadata?: Record; - /** ISO instant an OAuth exchange's access token expires — stored on - * the credential row's own `expiresAt` COLUMN, which serving-time - * refresh keys on. The metadata fold that types the row may also - * carry the expiry, but this field is the ride that lands the - * column. */ - expiresAt?: string; - /** The configurable-base-URL seam `ollama` uses (see `modelSourceFor`); - * ignored for every other provider. */ - baseURLOverride?: string; - }; - -export type EnsureSeededArgs = CommonArgs & { - tenant: PersonalTenant; - provider: SupportedCredentialProvider; - apiKey: string; - baseURLOverride?: string; - seedTenantFn?: (args: SeedTenantArgs) => ReturnType; -}; - -export type CompleteCredentialArgs = CommonArgs & - PersistConnectorCredentialFns & { - userId: string; - userEmail: string; - provider: SupportedCredentialProvider; - apiKey: string; - credentialMetadata?: Record; - /** ISO instant an OAuth exchange's access token expires — see - * `TestAndPersistCredentialArgs`. */ - expiresAt?: string; - baseURLOverride?: string; - seedTenantFn?: (args: SeedTenantArgs) => ReturnType; - }; - -/** - * Resolves the tenant a caller's bench work lands on. An exact slug match - * against `expectedSlug` (the computed personal-bench slug) always wins. - * - * By default the match is strict: a mismatch resolves to `undefined`. - * Callers that must wait until a specific slug is visible — rather than - * acting on the first principal that happens to be listed — depend on - * that default. - * - * The connect-flow callers (`testAndPersistCredential`, the OAuth - * duplicate-callback recovery `recentlyConnectedCredential`, and the - * `/complete-setup` route) pass `fallbackToFirstPrincipal` so a caller - * that already has a bench but under a different slug — a seeded admin - * whose only membership is the root bench (CL-7506) — resolves to the - * first principal in page order instead of hard-failing - * `no-personal-bench`. Only a response with zero principals yields - * `undefined`, with or without the flag. - */ -export async function findPersonalTenant( - api: ApiCall, - cookies: string[], - expectedSlug: string, - opts: { fallbackToFirstPrincipal?: boolean } = {}, -): Promise { - const response = await api("GET", "/api/me/principals", undefined, cookies); - const summary = parseAs(paginatedSchema(PrincipalSummary), response.data, "principals response"); - const own = - summary.data.find((p) => p.tenantSlug === expectedSlug) ?? - (opts.fallbackToFirstPrincipal ? summary.data[0] : undefined); - if (!own) return undefined; - - const tenantResponse = await api("GET", `/api/tenants/${own.tenantId}`, undefined, cookies); - const tenant = parseAs(TenantResponse, tenantResponse.data, "tenant response"); - return { - tenantId: own.tenantId, - tenantSlug: own.tenantSlug, - principalId: own.principalId, - tenantDomain: tenant.domain, - }; -} - -/** - * One provider's offerings out of a tenant's resolved catalog - * (`GET /api/tenants/:id/models`), flattened to the shape - * `resolveOllamaModelSource` picks a winner from. - */ -type CatalogOfferingCandidate = { - readonly canonicalName: string; - readonly plugin: string; - readonly priority: number; - readonly capabilities: readonly string[]; -}; - -/** - * Ollama's model resolution: what the instance ACTUALLY serves, never a - * curated name it may not have pulled (CL-6366 — pinning - * `CATALOG_SEEDS.ollama.models[0]` here killed every turn on any - * instance without that exact model). By the time this runs, - * `testAndPersistCredential` has already run `seedCatalog` against the - * same tenant, which seeded this provider's catalog straight from its - * live `/api/tags` — capability rows included (`fetchOllamaModelCatalog`, - * CL-6366) — so the resolved catalog read below reflects exactly what - * this instance offers, not the two-entry static seed. - * - * `preferCompletionCapable` narrows to completion-capable offerings - * first — the same rule `selectDefaultInferencePreferences` - * (`@corbits/chat`) and `defaultModelForProvider` - * (`@workbench/inference-settings`) apply, so an embedding pull can - * never win here either, by construction rather than by name-sorting - * heuristic. The curated name (`CATALOG_SEEDS.ollama.models[0]`) is - * honored only as a PREFERENCE among the surviving candidates — a tie - * broken toward the name this repo already knows serves tool calls and - * thinking — never as a value this can return when the instance doesn't - * actually offer it. - * - * Discovery (`GET /models`) includes inherited operator catalog seeds, so - * the curated name can "resolve" without living on this tenant's instance - * (CL-7185). When that name appears in discovery, this also reads - * tenant-owned `/catalog/models`. A non-empty owned list restricts - * candidates to those names before the curated preference / priority sort; - * an empty owned list keeps discovery (inherit-only). The extra fetch is - * skipped when discovery does not contain the curated name. - */ -async function resolveOllamaModelSource( - api: ApiCall, - cookies: string[], - tenantId: string, -): Promise { - const catalogSeed = CATALOG_SEEDS.ollama; - - const response = await api("GET", `/api/tenants/${tenantId}/models`, undefined, cookies); - const models = parseAs(ModelInfo.array(), response.data, "resolved catalog response"); - const candidates: CatalogOfferingCandidate[] = []; - for (const model of models) { - for (const offering of model.offerings) { - if (offering.providerName !== catalogSeed.provider.name) continue; - candidates.push({ - canonicalName: model.canonicalName, - plugin: offering.plugin, - priority: offering.priority, - capabilities: offering.capabilities, - }); - } - } - if (candidates.length === 0) { - throw new Error( - `tenant ${tenantId} has no seeded ollama catalog offerings — seedCatalog must run before resolveOllamaModelSource`, - ); - } - - const completionCapable = preferCompletionCapable( - candidates, - (candidate) => candidate.capabilities, - (candidate) => candidate.canonicalName, - ); - const curatedName = catalogSeed.models[0]?.canonicalName; - let pool = completionCapable; - if ( - curatedName !== undefined && - completionCapable.some((candidate) => candidate.canonicalName === curatedName) - ) { - const ownedResponse = await api( - "GET", - `/api/tenants/${tenantId}/catalog/models`, - undefined, - cookies, - ); - const owned = parseAs( - paginatedSchema(ModelResponse), - ownedResponse.data, - "tenant-owned catalog models response", - ).data; - if (owned.length > 0) { - const ownedNames = new Set(owned.map((model) => model.canonicalName)); - pool = completionCapable.filter((candidate) => ownedNames.has(candidate.canonicalName)); - } - } - const preferred = - curatedName !== undefined - ? pool.find((candidate) => candidate.canonicalName === curatedName) - : undefined; - const winner = - preferred ?? - [...pool].sort( - (left, right) => - left.priority - right.priority || left.canonicalName.localeCompare(right.canonicalName), - )[0]; - if (winner === undefined) { - throw new Error(`tenant ${tenantId}'s seeded ollama catalog resolved to no candidate model`); - } - - return { - provider: winner.plugin, - model: winner.canonicalName, - }; -} - -/** The `ModelSource` `ensureSeeded` names in every deployed definition's - * inference preferences — never what the deploy itself resolves - * inference against (the tenant's catalog offerings, CL-7461). Every - * provider but `ollama` has a fixed, always-available curated model list - * (`CATALOG_SEEDS`), so its curated default is a safe pin. `ollama` is - * the one provider whose actual model list is per-instance and can - * diverge from that curated name entirely (CL-6366) — its resolution - * defers to `resolveOllamaModelSource`, which reads back what - * `seedCatalog` actually found on the instance rather than repeating the - * static pin. */ -export async function modelSourceFor( - api: ApiCall, - cookies: string[], - tenantId: string, - provider: SupportedCredentialProvider, -): Promise { - if (provider === "ollama") { - return resolveOllamaModelSource(api, cookies, tenantId); - } - - const catalogSeed = CATALOG_SEEDS[provider]; - const defaultModel = catalogSeed.models[0]; - if (defaultModel === undefined) { - throw new Error(`catalog seed for provider ${provider} has no default model`); - } - return { - provider: catalogSeed.provider.plugin, - model: defaultModel.canonicalName, - }; -} - -/** - * The fast half: plants an onboarding user's key as a credential on - * their own personal bench, alongside that provider's curated model - * catalog — immediately, with no live call to the provider gating it - * (CL-6123). Never deploys a workflow — that is `ensureSeeded`'s job, - * deliberately kept out of this half so an OAuth callback route can run - * only this and redirect immediately. - */ -export async function testAndPersistCredential( - args: TestAndPersistCredentialArgs, -): Promise { - const expectedSlug = personalTenantSlug(args.userEmail, args.userId); - const tenant = await findPersonalTenant(args.api, args.cookies, expectedSlug, { - fallbackToFirstPrincipal: true, - }); - if (!tenant) return { kind: "no-personal-bench" }; - - const descriptor = CONNECTOR_REGISTRY[args.provider]; - if (descriptor === undefined) { - throw new Error(`no connector descriptor registered for provider ${args.provider}`); - } - - // The one shared persist-and-seed sequence (CL-6394): provider + - // credential rows named the way the Plugins gallery's resolver reads - // them back (`descriptor.id` / `descriptor.displayName`), then the - // curated model catalog. An explicit user submission through a connect - // UI — a pasted key or a completed OAuth exchange — always rotates a - // name-conflicting credential (a regenerated key, or a retry after a - // bad paste): see `ensureCredential`'s own `verified` doc comment in - // `@corbits/connections/seed-catalog` for the full rotation rule. - await persistConnectorCredential({ - api: args.api, - cookies: args.cookies, - tenantId: tenant.tenantId, - descriptor, - secret: args.apiKey, - log: args.log, - ...(args.expiresAt !== undefined ? { expiresAt: args.expiresAt } : {}), - ...(args.credentialMetadata !== undefined - ? { credentialMetadata: args.credentialMetadata } - : {}), - ...(args.baseURLOverride !== undefined ? { baseURLOverride: args.baseURLOverride } : {}), - ...(args.ensureProviderFn !== undefined ? { ensureProviderFn: args.ensureProviderFn } : {}), - ...(args.ensureCredentialFn !== undefined - ? { ensureCredentialFn: args.ensureCredentialFn } - : {}), - ...(args.seedCatalogFn !== undefined ? { seedCatalogFn: args.seedCatalogFn } : {}), - }); - - return { kind: "connected", ...tenant }; -} - -/** - * The slow half: deploys and — never confirms, see the module comment — - * every default workflow against the tenant's already-persisted - * credential. Safe to call more than once for the same tenant: every - * step it drives (`seedTenant`'s asset and deployment creation) is - * itself ensure-then-create, so two overlapping calls (a duplicate - * "finish setup" request, a retried one) never double-deploy. - * - * CL-6264: a deploy failure in the sidecar-unavailable class specifically - * (`isSidecarUnavailableError`, the 502 `ensureDeployment` raises when - * the hub cannot reach the sidecar) does not fail this call — every step - * ahead of the deploy loop already durably succeeded, and the sidecar - * coming back is an operational fact outside the caller's control, not - * a reason to tell someone their onboarding failed. The doc's - * `seededWorkflowNames` re-reads the tenant's actual asset/deployment - * state (rather than hand-tracking a loop index) to report exactly which - * default workflows made it live and which are still pending. Any other - * error out of `seedTenant` still throws, unchanged. - */ -export async function ensureSeeded(args: EnsureSeededArgs): Promise { - const runSeedTenant = args.seedTenantFn ?? seedTenant; - - const seedTenantArgs = { - api: args.api, - cookies: args.cookies, - hubUrl: args.hubUrl, - tenant: { - tenantId: args.tenant.tenantId, - principalId: args.tenant.principalId, - domain: args.tenant.tenantDomain, - }, - model: await modelSourceFor(args.api, args.cookies, args.tenant.tenantId, args.provider), - pushWorkflow: args.pushWorkflow, - log: args.log, - workflows: DEFAULT_WORKFLOWS, - confirmDeployments: false, - }; - try { - await runSeedTenant(seedTenantArgs); - } catch (cause) { - if (!isSidecarUnavailableError(cause)) throw cause; - args.log( - `sidecar unavailable while deploying default workflows for tenant ${args.tenant.tenantId}; completing onboarding with agents pending: ${cause.message}`, - ); - // The single doc-status reader (the same lookup - // `readTenantDesiredStateStatus` derives its workflow pins from) — - // no second seeded-check lives alongside it. - const { deployed, pending } = await seededWorkflowNames( - args.api, - args.cookies, - args.tenant.tenantId, - TENANT_DESIRED_STATE.workflows, - ); - return { - kind: "seeded-pending-agents", - deployed, - pending, - message: AGENTS_PENDING_MESSAGE, - }; - } - - return { - kind: "seeded", - workflows: DEFAULT_WORKFLOWS.map((workflow) => workflow.assetName), - }; -} - -/** - * Both halves back to back, blocking until the bench is genuinely - * deployed. For harnesses that need a fully provisioned bench before - * they can begin — `@workbench/evals`' real-target is the only caller — - * never for a route serving a person. Connect returns in seconds - * precisely because it does NOT do this (CL-6457); see the module - * comment above. - */ -export async function completeCredentialSetup( - args: CompleteCredentialArgs, -): Promise { - const persisted = await testAndPersistCredential(args); - if (persisted.kind !== "connected") return persisted; - - const baseEnsureSeededArgs = { - api: args.api, - cookies: args.cookies, - hubUrl: args.hubUrl, - pushWorkflow: args.pushWorkflow, - log: args.log, - tenant: persisted, - provider: args.provider, - apiKey: args.apiKey, - }; - const ensureSeededArgs = - args.baseURLOverride !== undefined - ? { ...baseEnsureSeededArgs, baseURLOverride: args.baseURLOverride } - : baseEnsureSeededArgs; - const seeded = await ensureSeeded( - args.seedTenantFn !== undefined - ? { ...ensureSeededArgs, seedTenantFn: args.seedTenantFn } - : ensureSeededArgs, - ); - - if (seeded.kind === "seeded-pending-agents") { - return { - kind: "seeded-pending-agents", - tenantId: persisted.tenantId, - tenantSlug: persisted.tenantSlug, - principalId: persisted.principalId, - tenantDomain: persisted.tenantDomain, - deployed: seeded.deployed, - pending: seeded.pending, - message: seeded.message, - }; - } - - return { - kind: "seeded", - tenantId: persisted.tenantId, - tenantSlug: persisted.tenantSlug, - workflows: seeded.workflows, - }; -} diff --git a/packages/onboarding/src/desired-state.ts b/packages/onboarding/src/desired-state.ts deleted file mode 100644 index 8838d6dba8..0000000000 --- a/packages/onboarding/src/desired-state.ts +++ /dev/null @@ -1,613 +0,0 @@ -// CL-7584: the per-tenant onboarding contract. The desired state — -// which workflows, tool packages, and skills every real tenant should -// have — is DATA here, a plain const composed BY REFERENCE over the -// existing single-source constants (`DEFAULT_WORKFLOWS`, -// `REQUIRED_SEED_TOOL_PACKAGES`, `DEFAULT_SKILLS`). It is not a hub -// table, not a migration, and never seeded from hub boot: adding a core -// workflow later is an edit to `./tenant-seed`'s constants, which -// this document mirrors, nothing more. -// -// `reconcileTenantDesiredState` is the one installer: it reads the -// tenant's real state against this document and installs ONLY the -// absent pins. Convergence has three triggers — a tenant-create -// observation, a background drain over a pending credential, and a -// revisit probe (`POST /api/onboarding/provision`) — all of which call -// this one function, so there is exactly one place installation happens -// and exactly one definition of "already done". - -import { type } from "arktype"; -import { - AssetWithOriginResponse, - ModelInfo, - PrincipalSummary, - TenantResponse, - paginatedSchema, -} from "@intx/types"; -import { DEFAULT_SKILLS } from "@corbits/connections/default-skills"; -import { - DEFAULT_WORKFLOWS, - isLiveDeploymentStatus, - seedTenant, - type DefaultWorkflow, - type ModelSource, - type SeedTenantArgs, -} from "./tenant-seed"; -import { - fetchRegistryTarballSource, - installRegistryTarball, - isCorbitsToolsRegistrySeeded, - REQUIRED_SEED_TOOL_PACKAGES, -} from "@corbits/tool-registry-publish"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import { reportError } from "@corbits/error-sink"; -import { isSidecarUnavailableError, parseAs, type ApiCall } from "@corbits/hub-api-client"; - -export type WorkflowPin = { - readonly assetName: string; - readonly displayName: string; - readonly version: string; - readonly definition: DefaultWorkflow["buildJson"]; -}; - -export type ToolPackagePin = { - readonly name: string; - readonly version: string; - readonly source: - | { readonly kind: "workspace-pack" } - | { - readonly kind: "tarball-url"; - readonly url: string; - readonly integrity: string; - }; -}; - -export type SkillPin = { - readonly name: string; - readonly description: string; - readonly body: string; -}; - -export type TenantDesiredState = { - readonly stateId: string; - readonly workflows: readonly WorkflowPin[]; - readonly toolPackages: readonly ToolPackagePin[]; - readonly skills: readonly SkillPin[]; -}; - -const WORKFLOW_PIN_VERSION = "1.0.0"; -const TOOL_PACKAGE_VERSION = "1.0.0"; - -/** - * What every real tenant should have. Myra first (CL-7074); growing the - * core set later is a data edit upstream, never code here. - */ -export const TENANT_DESIRED_STATE: TenantDesiredState = { - stateId: "tenant-desired-state-1", - workflows: DEFAULT_WORKFLOWS.map((workflow) => ({ - assetName: workflow.assetName, - displayName: workflow.displayName, - version: WORKFLOW_PIN_VERSION, - definition: workflow.buildJson, - })), - toolPackages: REQUIRED_SEED_TOOL_PACKAGES.map((name) => ({ - name, - version: TOOL_PACKAGE_VERSION, - source: { kind: "workspace-pack" as const }, - })), - skills: DEFAULT_SKILLS.map((skill) => ({ ...skill })), -}; - -export type PinState = "present" | "pending" | "blocked"; - -export type DesiredStateStatus = { - readonly tenantId: string; - readonly stateId: string; - readonly ready: boolean; - readonly workflows: Readonly>; - readonly tools: PinState; - readonly skills: Readonly>; -}; - -const WorkflowDeploymentStatus = type({ - definitionAssetId: "string", - status: "string", -}); - -/** Which of `pins`' asset names already carry an active deployment on - * this tenant. The same asset-then-deployment lookup `seedTenant` - * performs; parameterized over the doc's pins so the desired-state - * reader never re-derives it. Read-only. */ -export async function seededWorkflowNames( - api: ApiCall, - cookies: string[], - tenantId: string, - pins: readonly { assetName: string }[], -): Promise<{ deployed: string[]; pending: string[] }> { - const assetsResponse = await api( - "GET", - `/api/tenants/${tenantId}/assets?kind=workflow&inherited=false`, - undefined, - cookies, - ); - const assets = parseAs(AssetWithOriginResponse.array(), assetsResponse.data, "assets response"); - - const deploymentsResponse = await api( - "GET", - `/api/tenants/${tenantId}/workflows/deployments`, - undefined, - cookies, - ); - const deployments = parseAs( - WorkflowDeploymentStatus.array(), - deploymentsResponse.data, - "deployments response", - ); - - const deployed: string[] = []; - const pending: string[] = []; - for (const pin of pins) { - const asset = assets.find((a) => a.name === pin.assetName); - const isDeployed = - asset !== undefined && - deployments.some((d) => d.definitionAssetId === asset.id && isLiveDeploymentStatus(d.status)); - (isDeployed ? deployed : pending).push(pin.assetName); - } - return { deployed, pending }; -} - -async function readToolsState( - api: ApiCall, - cookies: string[], - tenantId: string, -): Promise { - try { - return (await isCorbitsToolsRegistrySeeded(api, cookies, tenantId)) ? "present" : "pending"; - } catch (cause) { - if (isSidecarUnavailableError(cause)) return "blocked"; - throw cause; - } -} - -async function readSkillState( - api: ApiCall, - cookies: string[], - tenantId: string, - skill: SkillPin, -): Promise { - try { - const response = await api( - "GET", - `/api/tenants/${tenantId}/assets?kind=skill&inherited=false`, - undefined, - cookies, - ); - if (response.status >= 500) return "blocked"; - const assets = parseAs(AssetWithOriginResponse.array(), response.data, "assets response"); - return assets.some((asset) => asset.name === skill.name) ? "present" : "pending"; - } catch (cause) { - if (isSidecarUnavailableError(cause)) return "blocked"; - throw cause; - } -} - -/** - * Reads the tenant's real state against the desired-state document, - * native reads only — never creates, deploys, or publishes anything. - */ -export async function readTenantDesiredStateStatus( - api: ApiCall, - cookies: string[], - tenantId: string, -): Promise { - const { deployed } = await seededWorkflowNames( - api, - cookies, - tenantId, - TENANT_DESIRED_STATE.workflows, - ); - const workflows: Record = {}; - for (const pin of TENANT_DESIRED_STATE.workflows) { - workflows[pin.assetName] = deployed.includes(pin.assetName) ? "present" : "pending"; - } - const tools = await readToolsState(api, cookies, tenantId); - const skills: Record = {}; - for (const skill of TENANT_DESIRED_STATE.skills) { - skills[skill.name] = await readSkillState(api, cookies, tenantId, skill); - } - const ready = - tools === "present" && - Object.values(workflows).every((s) => s === "present") && - Object.values(skills).every((s) => s === "present"); - // NOTE the two readiness gates (cf. `provisioningStatus` in - // `./routes.ts`): this `ready` covers tools AND skills AND workflows — - // the full desired state — while the person-facing `/provision` and - // `/complete-setup` answers gate only on the workflow set (Myra live - // is "can they start"). Tools and skills still ride in `steps` for the - // waiting surface; they report `blocked` rather than holding the door. - return { - tenantId, - stateId: TENANT_DESIRED_STATE.stateId, - ready, - workflows, - tools, - skills, - }; -} - -export type DesiredStateStep = { - readonly name: string; - readonly label: string; - readonly status: PinState; -}; - -/** Labeled, doc-ordered step list for a waiting surface (the - * onboarding page's finishing-setup view), derived from a status read - * plus the doc's own labels. */ -export function desiredStateSteps(status: DesiredStateStatus): readonly DesiredStateStep[] { - return [ - ...TENANT_DESIRED_STATE.workflows.map((pin) => ({ - name: pin.assetName, - label: pin.displayName, - status: status.workflows[pin.assetName] ?? "pending", - })), - ...TENANT_DESIRED_STATE.toolPackages.map((pin) => ({ - name: pin.name, - label: pin.name, - status: status.tools, - })), - ...TENANT_DESIRED_STATE.skills.map((pin) => ({ - name: pin.name, - label: pin.name, - status: status.skills[pin.name] ?? "pending", - })), - ]; -} - -// --------------------------------------------------------------------------- -// Reconcile -// --------------------------------------------------------------------------- - -export type ReconcilePinStatus = "present" | "installed" | "reinstalled" | "blocked" | "failed"; - -export type ReconcilePin = { - readonly name: string; - readonly kind: "tool-package" | "skill" | "workflow"; - readonly status: ReconcilePinStatus; -}; - -export type ReconcileReport = { - readonly tenantId: string; - readonly ready: boolean; - readonly pins: readonly ReconcilePin[]; -}; - -export type ReconcileArgs = { - api: ApiCall; - cookies: string[]; - hubUrl: string; - tenant: { - tenantId: string; - /** Required: deploying under an empty-string identity is the - * second-tenant-never-converges class (CL-7584) — a caller with no - * deployable principal resolves `resolveTenantDeployer` first, or - * reports the pins blocked without entering here. */ - principalId: string; - domain: string; - }; - model: ModelSource | undefined; - pushWorkflow: WorkflowPusher; - /** Test seam standing in for the deploy step, the same way - * `ensureSeeded` accepts one. */ - seedTenantFn?: (args: SeedTenantArgs) => ReturnType; - log: (line: string) => void; -}; - -/** - * The `ModelSource` a workflow deployment's rendered definition names: - * the tenant's top-priority resolved catalog offering (inherited - * included). `undefined` when the tenant has no offerings — nothing is - * launchable, and the caller reports the workflow pins blocked rather - * than throwing. - */ -export async function resolveTenantModelSource( - api: ApiCall, - cookies: string[], - tenantId: string, -): Promise { - const response = await api("GET", `/api/tenants/${tenantId}/models`, undefined, cookies); - const models = parseAs(ModelInfo.array(), response.data, "resolved catalog response"); - let best: { provider: string; model: string; priority: number } | undefined; - for (const model of models) { - for (const offering of model.offerings) { - if (best === undefined || offering.priority < best.priority) { - best = { - provider: offering.plugin, - model: model.canonicalName, - priority: offering.priority, - }; - } - } - } - return best === undefined ? undefined : { provider: best.provider, model: best.model }; -} - -export type TenantDeployer = { - readonly tenantId: string; - readonly principalId: string; - readonly tenantDomain: string; -}; - -/** - * The deploy identity for a tenant nobody has connected a credential - * to yet. The tenant-create kick (CL-7584) reconciles exactly such a - * tenant — a native `POST /api/tenants` that mints the creator's owner - * principal in the same transaction — so there is no pending-seed row - * to read `principalId`/`tenantDomain` from. This resolves both from - * the API instead: the session's own active user principal on the - * tenant, plus the tenant row's domain. `undefined` when the session - * holds no deployable principal there (suspended, invited, agent, or - * absent alike) — the caller reports the pins blocked rather than - * deploying under an empty-string identity. - */ -export async function resolveTenantDeployer( - api: ApiCall, - cookies: string[], - tenantId: string, -): Promise { - const response = await api("GET", "/api/me/principals", undefined, cookies); - const summary = parseAs(paginatedSchema(PrincipalSummary), response.data, "principals response"); - const own = summary.data.find( - (p) => p.tenantId === tenantId && p.kind === "user" && p.status === "active", - ); - if (!own) return undefined; - - const tenantResponse = await api("GET", `/api/tenants/${tenantId}`, undefined, cookies); - const tenant = parseAs(TenantResponse, tenantResponse.data, "tenant response"); - return { - tenantId, - principalId: own.principalId, - tenantDomain: tenant.domain, - }; -} - -/** - * Installs ONLY the absent pins, tools first, then skills + grants + - * workflows together through `seedTenant`. Safe to re-run: with every - * pin present this is READS ONLY — `seedTenant` is never entered. - * `workspace-pack` tool pins are never packed or published here (CL-8190: - * that requires `fs` + `bun build`, which no hub-triggered path may run — - * an operator publishes them out of band with `bun run publish-tools`); - * an absent workspace-pack pin reports `blocked` and this function stays - * read-only for tools. A `tarball-url` pin is still installed here — that - * install is a plain fetch + upload, and a tarball already published - * under its name@version is skipped (immutable). Sidecar-unavailable - * (502-class) pins report `blocked` without throwing — the same class - * `ensureSeeded` treats as pending; any other failure reports `failed` - * and is safe to re-run. Skill pins are re-read after the seed pass: - * still-absent (stock cutover — the skills mount is gone, so the - * 404-tolerant plant writes nothing) reports `blocked`, never - * `installed`. - */ -export async function reconcileTenantDesiredState(args: ReconcileArgs): Promise { - const { api, cookies, tenantId } = { - ...args, - tenantId: args.tenant.tenantId, - }; - const log = args.log; - const status = await readTenantDesiredStateStatus(api, cookies, tenantId); - const pins: ReconcilePin[] = []; - let sawFailure = false; - let sawBlocked = false; - - // Tools first: a workflow cannot launch without its tool-package pins - // resolvable, so a publish failure must not be hidden behind a later - // deploy success. - if (status.tools === "present") { - for (const pin of TENANT_DESIRED_STATE.toolPackages) { - pins.push({ name: pin.name, kind: "tool-package", status: "present" }); - } - } else { - try { - // `workspace-pack` pins are never packed or published from here - // (CL-8190): packing needs `fs` + `bun build`, which no - // hub-triggered reconcile path may run. An absent pin just reports - // `blocked` — the operator installs it out of band with - // `bun run publish-tools`. - const workspacePacks = TENANT_DESIRED_STATE.toolPackages.filter( - (pin) => pin.source.kind === "workspace-pack", - ); - for (const pin of workspacePacks) { - pins.push({ name: pin.name, kind: "tool-package", status: "blocked" }); - } - if (workspacePacks.length > 0) sawBlocked = true; - // The tarball-url half of the tool install: live and wired, but - // data-gated — no `tarball-url` pin exists in - // `TENANT_DESIRED_STATE` yet (no external artifacts exist), so this - // loop is a no-op until populating a pin makes it fire. Kept, tested - // at the `installRegistryTarball` unit level, so adding an artifact - // stays a data edit. - const tarballPins = TENANT_DESIRED_STATE.toolPackages.filter( - (pin) => pin.source.kind === "tarball-url", - ); - for (const pin of tarballPins) { - const source = pin.source; - if (source.kind !== "tarball-url") continue; - const outcome = await installRegistryTarball({ - api, - cookies, - hubUrl: args.hubUrl, - tenantId, - name: pin.name, - version: pin.version, - fetchSource: () => - fetchRegistryTarballSource({ - url: source.url, - integrity: source.integrity, - }), - log, - }); - pins.push({ - name: pin.name, - kind: "tool-package", - status: outcome === "installed" ? "installed" : "present", - }); - } - } catch (cause) { - if (isSidecarUnavailableError(cause)) { - sawBlocked = true; - for (const pin of TENANT_DESIRED_STATE.toolPackages) { - if (pins.some((p) => p.name === pin.name)) continue; - pins.push({ - name: pin.name, - kind: "tool-package", - status: "blocked", - }); - } - log( - `tool-package publish for tenant ${tenantId} is blocked (sidecar unavailable); reporting without failing`, - ); - } else { - sawFailure = true; - for (const pin of TENANT_DESIRED_STATE.toolPackages) { - if (pins.some((p) => p.name === pin.name)) continue; - pins.push({ name: pin.name, kind: "tool-package", status: "failed" }); - } - reportError(cause, { - operation: "desired_state_tool_install", - tenantId, - }); - log( - `tool-package publish for tenant ${tenantId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - } - } - - // Skills + grants + workflows together, via the one seeder. Entered - // only when at least one workflow OR skill pin is pending; with all - // present this whole function stays read-only. - const workflowPending = Object.values(status.workflows).some((s) => s !== "present"); - const skillPending = Object.values(status.skills).some((s) => s !== "present"); - - if (!workflowPending && !skillPending) { - for (const pin of TENANT_DESIRED_STATE.workflows) { - pins.push({ name: pin.assetName, kind: "workflow", status: "present" }); - } - for (const pin of TENANT_DESIRED_STATE.skills) { - pins.push({ name: pin.name, kind: "skill", status: "present" }); - } - } else { - const model = args.model; - const seedWorkflows = DEFAULT_WORKFLOWS.filter((workflow) => - TENANT_DESIRED_STATE.workflows.some((pin) => pin.assetName === workflow.assetName), - ); - try { - if (model === undefined) { - throw new Error(`tenant ${tenantId} has no catalog offerings to deploy against`); - } - await (args.seedTenantFn ?? seedTenant)({ - api, - cookies, - hubUrl: args.hubUrl, - tenant: { - tenantId, - principalId: args.tenant.principalId, - domain: args.tenant.domain, - }, - model, - pushWorkflow: args.pushWorkflow, - log, - workflows: seedWorkflows, - confirmDeployments: false, - }); - for (const pin of TENANT_DESIRED_STATE.workflows) { - pins.push({ - name: pin.assetName, - kind: "workflow", - status: "installed", - }); - } - for (const pin of TENANT_DESIRED_STATE.skills) { - // The seed pass just ran, but a 404-tolerant plant (stock - // cutover: the skills mount is gone, so nothing was written) must - // not be reported installed. Re-read each skill: still-absent - // pins "blocked" and holds the report unready; present pins - // "installed" as before. - const state = await readSkillState(api, cookies, tenantId, pin); - if (state === "present") { - pins.push({ name: pin.name, kind: "skill", status: "installed" }); - continue; - } - sawBlocked = true; - log( - `skill pin ${pin.name} still ${state} after the seed pass (skills surface removed by the stock cutover); reporting blocked without failing`, - ); - pins.push({ name: pin.name, kind: "skill", status: "blocked" }); - } - } catch (cause) { - if (isSidecarUnavailableError(cause) || model === undefined) { - sawBlocked = true; - for (const pin of TENANT_DESIRED_STATE.workflows) { - if (status.workflows[pin.assetName] === "present") { - pins.push({ - name: pin.assetName, - kind: "workflow", - status: "present", - }); - } else { - pins.push({ - name: pin.assetName, - kind: "workflow", - status: "blocked", - }); - } - } - for (const pin of TENANT_DESIRED_STATE.skills) { - if (pins.some((p) => p.name === pin.name)) continue; - pins.push({ name: pin.name, kind: "skill", status: "blocked" }); - } - log( - `workflow deployment for tenant ${tenantId} is blocked (${model === undefined ? "no catalog offerings" : "sidecar unavailable"}); reporting without failing`, - ); - } else { - sawFailure = true; - for (const pin of TENANT_DESIRED_STATE.workflows) { - if (status.workflows[pin.assetName] === "present") { - pins.push({ - name: pin.assetName, - kind: "workflow", - status: "present", - }); - } else { - pins.push({ - name: pin.assetName, - kind: "workflow", - status: "failed", - }); - } - } - for (const pin of TENANT_DESIRED_STATE.skills) { - if (pins.some((p) => p.name === pin.name)) continue; - pins.push({ - name: pin.name, - kind: "skill", - status: status.skills[pin.name] === "present" ? "present" : "failed", - }); - } - log( - `workflow deployment for tenant ${tenantId} failed: ${cause instanceof Error ? cause.message : String(cause)}`, - ); - reportError(cause, { - operation: "desired_state_workflow_deploy", - tenantId, - }); - } - } - } - - return { - tenantId, - ready: !sawFailure && !sawBlocked, - pins, - }; -} diff --git a/packages/onboarding/src/genesis.ts b/packages/onboarding/src/genesis.ts deleted file mode 100644 index 4a46878946..0000000000 --- a/packages/onboarding/src/genesis.ts +++ /dev/null @@ -1,169 +0,0 @@ -// First empty-hub signup mints the root tenant (native owner = superadmin). -// Later signups join that tenant as member. This path never seeds workflows, -// tools, or grants. - -import { paginatedSchema, PrincipalSummary, TenantResponse } from "@intx/types"; -import { parseAs, type ApiCall } from "@corbits/hub-api-client"; - -export type HubSignupTenancy = { - countUsers(): Promise; - countTenants(): Promise; - findRootTenant(): Promise<{ id: string; slug: string } | null>; - addActiveMember(args: { - tenantId: string; - userId: string; - roleName: "member"; - }): Promise<{ principalId: string }>; -}; - -export type GenesisOrJoinArgs = { - api: ApiCall; - cookies: string[]; - userId: string; - userEmail: string; - userEmailVerified: boolean; - defaultTenantSlug: string; - displayName?: string; - tenancy: HubSignupTenancy; - log: (line: string) => void; -}; - -export type GenesisOrJoinResult = - | { - readonly kind: "genesis"; - readonly tenantId: string; - readonly tenantSlug: string; - } - | { - readonly kind: "joined"; - readonly tenantId: string; - readonly tenantSlug: string; - readonly principalId: string; - } - | { readonly kind: "existing-member" } - | { readonly kind: "needs-onboarding" }; - -export type ProvisionErrorKind = "transient" | "permanent"; - -export class ProvisionError extends Error { - readonly code: string; - readonly errorKind: ProvisionErrorKind; - constructor(code: string, message: string, errorKind: ProvisionErrorKind) { - super(message); - this.name = "ProvisionError"; - this.code = code; - this.errorKind = errorKind; - } -} - -async function fetchPrincipals( - api: ApiCall, - cookies: string[], -): Promise<{ tenantId: string; tenantSlug: string; principalId: string }[]> { - const response = await api("GET", "/api/me/principals", undefined, cookies); - const summary = parseAs(paginatedSchema(PrincipalSummary), response.data, "principals response"); - return summary.data.map((p) => ({ - tenantId: p.tenantId, - tenantSlug: p.tenantSlug, - principalId: p.principalId, - })); -} - -async function joinRoot( - args: GenesisOrJoinArgs, - root: { id: string; slug: string }, -): Promise { - const { principalId } = await args.tenancy.addActiveMember({ - tenantId: root.id, - userId: args.userId, - roleName: "member", - }); - args.log( - `joined existing hub tenant ${root.slug} (${root.id}) as member principal ${principalId}`, - ); - return { - kind: "joined", - tenantId: root.id, - tenantSlug: root.slug, - principalId, - }; -} - -async function requireRoot(tenancy: HubSignupTenancy): Promise<{ - id: string; - slug: string; -}> { - const root = await tenancy.findRootTenant(); - if (root === null) { - throw new ProvisionError( - "root_tenant_missing", - "signup join path found no root tenant (parentId IS NULL)", - "permanent", - ); - } - return root; -} - -export async function genesisOrJoinHubSignup( - args: GenesisOrJoinArgs, -): Promise { - const before = await fetchPrincipals(args.api, args.cookies); - if (before.length > 0) return { kind: "existing-member" }; - - // Empty hub = zero tenants, regardless of user count: user rows - // without a tenant (an invite that never landed, a member removed by - // an operator) must not strand the hub — anyone principal-less may - // still genesis when no tenant exists. - const tenantCount = await args.tenancy.countTenants(); - if (tenantCount > 0) { - return joinRoot(args, await requireRoot(args.tenancy)); - } - - if (args.displayName === undefined || args.displayName.trim().length === 0) { - return { kind: "needs-onboarding" }; - } - - const created = await args.api( - "POST", - "/api/tenants", - { - name: args.displayName.trim(), - slug: args.defaultTenantSlug, - }, - args.cookies, - ); - if (created.status === 409) { - const root = await args.tenancy.findRootTenant(); - if (root !== null) return joinRoot(args, root); - const afterRace = await fetchPrincipals(args.api, args.cookies); - if (afterRace.length > 0) return { kind: "existing-member" }; - throw new ProvisionError( - "slug_conflict_no_principal", - `first-login provisioning hit a slug conflict creating a personal bench, but the caller still has no principal anywhere: ${JSON.stringify(created.data)}`, - "permanent", - ); - } - if (created.status !== 201) { - throw new ProvisionError( - "tenant_create_failed", - `first-login provisioning could not create a personal bench (status ${created.status}): ${JSON.stringify(created.data)}`, - created.status >= 500 ? "transient" : "permanent", - ); - } - const tenant = parseAs(TenantResponse, created.data, "tenant response"); - const after = await fetchPrincipals(args.api, args.cookies); - const membership = after.find((p) => p.tenantId === tenant.id); - if (membership === undefined) { - throw new ProvisionError( - "tenant_created_no_membership", - `personal bench ${tenant.id} was created but the caller has no principal in it`, - "transient", - ); - } - args.log(`genesis tenant ${tenant.slug} (${tenant.id}) minted for ${args.userEmail}`); - return { - kind: "genesis", - tenantId: tenant.id, - tenantSlug: tenant.slug, - }; -} diff --git a/packages/onboarding/src/huggingface-connect.test.ts b/packages/onboarding/src/huggingface-connect.test.ts deleted file mode 100644 index 9ab9fc2875..0000000000 --- a/packages/onboarding/src/huggingface-connect.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -// The pure mechanics under the Hugging Face connect routes: the -// exchange must post the standard authorization_code + PKCE form body -// to HF's token endpoint, parse its response at the trust boundary -// without ever putting token material in a failure message, and turn -// `expires_in` into the ISO instant `complete-credential.ts` stores as -// credential metadata. PKCE and single-use state mechanics themselves -// are covered once in `pkce.test.ts` (referenced from -// `openrouter-connect.test.ts`) and are not re-tested here. -import { describe, expect, test } from "bun:test"; -import { - exchangeCodeForToken, - HUGGINGFACE_TOKEN_URL, - type ExchangeFetch, -} from "./huggingface-connect"; - -describe("exchangeCodeForToken", () => { - test("posts the standard authorization_code + PKCE form to HF's token endpoint", async () => { - const requests: { - url: string; - body: string; - headers: Record; - }[] = []; - const fetchImpl: ExchangeFetch = async (url, init) => { - requests.push({ url, body: init.body, headers: init.headers }); - return new Response(JSON.stringify({ access_token: "hf_oauth_minted", expires_in: 28800 }), { - status: 200, - }); - }; - - const result = await exchangeCodeForToken({ - code: "auth_code_1", - codeVerifier: "verifier_1", - redirectUri: "https://bench.example.com/api/onboarding/oauth/huggingface/callback", - clientId: "client_1", - fetchImpl, - now: () => Date.parse("2026-08-13T12:00:00.000Z"), - }); - - expect(requests[0]?.url).toBe(HUGGINGFACE_TOKEN_URL); - expect(requests[0]?.headers["content-type"]).toBe("application/x-www-form-urlencoded"); - const body = new URLSearchParams(requests[0]?.body ?? ""); - expect(body.get("grant_type")).toBe("authorization_code"); - expect(body.get("code")).toBe("auth_code_1"); - expect(body.get("code_verifier")).toBe("verifier_1"); - expect(body.get("client_id")).toBe("client_1"); - expect(body.get("redirect_uri")).toBe( - "https://bench.example.com/api/onboarding/oauth/huggingface/callback", - ); - - expect(result).toEqual({ - ok: true, - accessToken: "hf_oauth_minted", - expiresAt: "2026-08-13T20:00:00.000Z", - }); - }); - - test("a response with no expires_in still succeeds, with no expiresAt", async () => { - const fetchImpl: ExchangeFetch = async () => - new Response(JSON.stringify({ access_token: "hf_oauth_minted" }), { - status: 200, - }); - - const result = await exchangeCodeForToken({ - code: "auth_code_1", - codeVerifier: "verifier_1", - redirectUri: "https://bench.example.com/cb", - clientId: "client_1", - fetchImpl, - }); - - expect(result).toEqual({ ok: true, accessToken: "hf_oauth_minted" }); - }); - - test("a rejected exchange reports the status, never token material", async () => { - const fetchImpl: ExchangeFetch = async () => - new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }); - - const result = await exchangeCodeForToken({ - code: "expired", - codeVerifier: "verifier_1", - redirectUri: "https://bench.example.com/cb", - clientId: "client_1", - fetchImpl, - }); - - expect(result).toEqual({ - ok: false, - message: "Hugging Face rejected the code exchange with status 400", - }); - }); - - test("a 200 without an access token is a failure, not a crash", async () => { - const fetchImpl: ExchangeFetch = async () => - new Response(JSON.stringify({ unexpected: true }), { status: 200 }); - - const result = await exchangeCodeForToken({ - code: "auth_code_1", - codeVerifier: "verifier_1", - redirectUri: "https://bench.example.com/cb", - clientId: "client_1", - fetchImpl, - }); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.message).toContain("did not carry an access token"); - }); - - test("a transport failure is reported honestly", async () => { - const fetchImpl: ExchangeFetch = async () => { - throw new Error("getaddrinfo ENOTFOUND"); - }; - - const result = await exchangeCodeForToken({ - code: "auth_code_1", - codeVerifier: "verifier_1", - redirectUri: "https://bench.example.com/cb", - clientId: "client_1", - fetchImpl, - }); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.message).toContain("ENOTFOUND"); - }); - - test("no failure message ever contains the minted access token", async () => { - const fetchImpl: ExchangeFetch = async () => - new Response(JSON.stringify({ access_token: "hf_oauth_super_secret" }), { - status: 500, - }); - - const result = await exchangeCodeForToken({ - code: "auth_code_1", - codeVerifier: "verifier_1", - redirectUri: "https://bench.example.com/cb", - clientId: "client_1", - fetchImpl, - }); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.message).not.toContain("hf_oauth_super_secret"); - }); -}); diff --git a/packages/onboarding/src/huggingface-connect.ts b/packages/onboarding/src/huggingface-connect.ts deleted file mode 100644 index 837e4f1673..0000000000 --- a/packages/onboarding/src/huggingface-connect.ts +++ /dev/null @@ -1,19 +0,0 @@ -// The Hugging Face PKCE connect mechanics moved to -// `@corbits/connections` (CL-6028's OAuth route factory generalized -// both this and OpenRouter's flow) — re-exported from here so nothing -// outside this package that still imports `./huggingface-connect` needs -// to change. -export { - createConnectStateStore, - generatePKCEPair, - type ConnectStateStore, - type PKCEPair, - HUGGINGFACE_CONNECT_STATE_TTL_MS as CONNECT_STATE_TTL_MS, - exchangeCodeForToken, - HUGGINGFACE_AUTHORIZE_URL, - HUGGINGFACE_SCOPE, - HUGGINGFACE_TOKEN_URL, - type ExchangeCodeForTokenArgs, - type HuggingFaceExchangeFetch as ExchangeFetch, - type HuggingFaceExchangeResult as ExchangeResult, -} from "@corbits/connections"; diff --git a/packages/onboarding/src/index.ts b/packages/onboarding/src/index.ts deleted file mode 100644 index 60e0168b33..0000000000 --- a/packages/onboarding/src/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -export { personalTenantSlug, provisionPersonalTenantIfNeeded } from "./provision"; -export type { ProvisionArgs, ProvisionResult } from "./provision"; -export { genesisOrJoinHubSignup, ProvisionError } from "./genesis"; -export type { - GenesisOrJoinArgs, - GenesisOrJoinResult, - HubSignupTenancy, - ProvisionErrorKind, -} from "./genesis"; -export { completeCredentialSetup, findPersonalTenant } from "./complete-credential"; -export type { - CompleteCredentialArgs, - CompleteCredentialResult, - PersonalTenant, -} from "./complete-credential"; -export { - createConnectStateStore, - exchangeCodeForKey, - generatePKCEPair, - s256Challenge, -} from "./openrouter-connect"; -export type { ConnectStateStore, ExchangeResult, PKCEPair } from "./openrouter-connect"; -export { createOnboardingRoutes } from "./routes"; -export type { CreateOnboardingRoutesDeps } from "./routes"; -export { - desiredStateSteps, - readTenantDesiredStateStatus, - reconcileTenantDesiredState, - resolveTenantDeployer, - resolveTenantModelSource, - TENANT_DESIRED_STATE, -} from "./desired-state"; -export type { - DesiredStateStatus, - DesiredStateStep, - PinState, - ReconcileArgs, - ReconcilePin, - ReconcilePinStatus, - ReconcileReport, - SkillPin, - TenantDeployer, - TenantDesiredState, - ToolPackagePin, - WorkflowPin, -} from "./desired-state"; -export { createBenchProvisioner, PROVISIONING_POLL_INTERVAL_MS } from "./bench-provisioning"; -export type { - BenchProvisioner, - BenchProvisionerDeps, - BenchProvisionOutcome, - DrainReport, - SessionForUser, -} from "./bench-provisioning"; -export { - createDrizzlePendingSeedStore, - createInMemoryPendingSeedStore, - PENDING_SEED_TTL_MS, -} from "./pending-seed"; -export type { - PendingSeed, - PendingSeedDb, - PendingSeedDuePage, - PendingSeedListCursor, - PendingSeedStore, -} from "./pending-seed"; -export { - envProviderBaseUrlsFrom, - envProviderKeysFrom, - plantEnvProviderCredentials, - PROVIDER_ENV_VARS, -} from "./plant-env-credentials"; -export type { - PlantEnvProviderCredentialsArgs, - PlantEnvProviderCredentialsOutcome, -} from "./plant-env-credentials"; diff --git a/packages/onboarding/src/migrations.ts b/packages/onboarding/src/migrations.ts deleted file mode 100644 index a93b1c1377..0000000000 --- a/packages/onboarding/src/migrations.ts +++ /dev/null @@ -1,105 +0,0 @@ -// Package-owned migrations for @workbench/onboarding. Bookkeeping uses -// its own ledger table so the package can be extracted without -// disentangling history from the platform drizzle journal. Every table -// this package owns — including its ledger — lives in its own -// `onboarding` Postgres schema, never `public`; see -// docs/package-migrations.md. -import postgres from "postgres"; - -export interface OnboardingMigration { - name: string; - sql: string; -} - -const SCHEMA = "onboarding"; - -export const onboardingMigrations: readonly OnboardingMigration[] = [ - { - name: "0001_pending_seed", - sql: ` - CREATE TABLE IF NOT EXISTS "onboarding"."pending_seed" ( - "user_id" text NOT NULL, - "tenant_id" text NOT NULL, - "provider" text NOT NULL, - "payload" text NOT NULL, - "expires_at" timestamptz NOT NULL, - PRIMARY KEY ("user_id", "tenant_id") - ); - `, - }, - { - // CL-8210: tenant_id becomes a real foreign key into Interchange's own - // tenant table, ON DELETE CASCADE. user_id stays a plain text column — - // it is the workbench web app's better-auth user id, not an - // Interchange principal id, so there is no Interchange row to - // reference (see packages/onboarding/src/schema.ts). - name: "0002_pending_seed_tenant_fk", - sql: ` - ALTER TABLE "onboarding"."pending_seed" - ADD CONSTRAINT "pending_seed_tenant_id_fkey" - FOREIGN KEY ("tenant_id") REFERENCES "public"."tenant" ("id") ON DELETE CASCADE; - `, - }, -]; - -const LEDGER_TABLE = "onboarding_migrations"; - -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -function quoteQualified(schema: string, name: string): string { - return `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`; -} - -export interface ApplyOnboardingMigrationsReport { - applied: string[]; - alreadyApplied: string[]; -} - -export async function applyOnboardingMigrations( - databaseUrl: string, -): Promise { - const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); - try { - await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(SCHEMA)}`); - - await sql.unsafe( - `CREATE TABLE IF NOT EXISTS ${quoteQualified(SCHEMA, LEDGER_TABLE)} (` + - `name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, - ); - - const applied: string[] = []; - const alreadyApplied: string[] = []; - - for (const migration of onboardingMigrations) { - const existing = await sql.unsafe( - `SELECT 1 FROM ${quoteQualified(SCHEMA, LEDGER_TABLE)} WHERE name = $1`, - [migration.name], - ); - if (existing.length > 0) { - alreadyApplied.push(migration.name); - continue; - } - try { - await sql.begin(async (tx) => { - await tx.unsafe(migration.sql); - await tx.unsafe( - `INSERT INTO ${quoteQualified(SCHEMA, LEDGER_TABLE)} (name) VALUES ($1)`, - [migration.name], - ); - }); - applied.push(migration.name); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new Error(`onboarding migration ${migration.name} failed: ${message}`, { - cause: err, - }); - } - } - - return { applied, alreadyApplied }; - } finally { - await sql.end({ timeout: 5 }); - } -} diff --git a/packages/onboarding/src/openrouter-connect.test.ts b/packages/onboarding/src/openrouter-connect.test.ts deleted file mode 100644 index 92d5c59dcf..0000000000 --- a/packages/onboarding/src/openrouter-connect.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -// The exchange's own contract: parse OpenRouter's response at the trust -// boundary without ever putting key material in a failure message. PKCE -// and single-use state mechanics are shared with every connect flow and -// are tested once in `pkce.test.ts`. -import { describe, expect, test } from "bun:test"; -import { exchangeCodeForKey, type ExchangeFetch } from "./openrouter-connect"; - -describe("exchangeCodeForKey", () => { - test("posts code, verifier, and S256 to the exchange endpoint", async () => { - const requests: { url: string; body: unknown }[] = []; - const fetchImpl: ExchangeFetch = async (url, init) => { - requests.push({ url, body: JSON.parse(init.body) }); - return new Response(JSON.stringify({ key: "sk-or-v1-minted" }), { - status: 200, - }); - }; - - const result = await exchangeCodeForKey({ - code: "auth_code_1", - codeVerifier: "verifier_1", - fetchImpl, - }); - - expect(result).toEqual({ ok: true, key: "sk-or-v1-minted" }); - expect(requests[0]?.url).toBe("https://openrouter.ai/api/v1/auth/keys"); - expect(requests[0]?.body).toEqual({ - code: "auth_code_1", - code_verifier: "verifier_1", - code_challenge_method: "S256", - }); - }); - - test("a rejected exchange reports the status, never key material", async () => { - const fetchImpl: ExchangeFetch = async () => - new Response(JSON.stringify({ error: "bad code" }), { status: 403 }); - - const result = await exchangeCodeForKey({ - code: "expired", - codeVerifier: "verifier_1", - fetchImpl, - }); - - expect(result).toEqual({ - ok: false, - message: "OpenRouter rejected the code exchange with status 403", - }); - }); - - test("a 200 without a key is a failure, not a crash", async () => { - const fetchImpl: ExchangeFetch = async () => - new Response(JSON.stringify({ unexpected: true }), { status: 200 }); - - const result = await exchangeCodeForKey({ - code: "auth_code_1", - codeVerifier: "verifier_1", - fetchImpl, - }); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.message).toContain("did not carry a key"); - }); - - test("a transport failure is reported honestly", async () => { - const fetchImpl: ExchangeFetch = async () => { - throw new Error("getaddrinfo ENOTFOUND"); - }; - - const result = await exchangeCodeForKey({ - code: "auth_code_1", - codeVerifier: "verifier_1", - fetchImpl, - }); - - expect(result.ok).toBe(false); - if (!result.ok) expect(result.message).toContain("ENOTFOUND"); - }); -}); diff --git a/packages/onboarding/src/openrouter-connect.ts b/packages/onboarding/src/openrouter-connect.ts deleted file mode 100644 index 3f0abd14e6..0000000000 --- a/packages/onboarding/src/openrouter-connect.ts +++ /dev/null @@ -1,18 +0,0 @@ -// The OpenRouter PKCE connect mechanics moved to `@corbits/connections` -// (CL-6028's OAuth route factory generalized both this and Hugging -// Face's flow) — re-exported from here so nothing outside this package -// that still imports `./openrouter-connect` needs to change. -export { - createConnectStateStore, - generatePKCEPair, - s256Challenge, - type ConnectStateStore, - type PKCEPair, - OPENROUTER_CONNECT_STATE_TTL_MS as CONNECT_STATE_TTL_MS, - exchangeCodeForKey, - OPENROUTER_AUTH_URL, - OPENROUTER_KEY_EXCHANGE_URL, - type ExchangeCodeForKeyArgs, - type OpenRouterExchangeFetch as ExchangeFetch, - type OpenRouterExchangeResult as ExchangeResult, -} from "@corbits/connections"; diff --git a/packages/onboarding/src/pending-seed.test.ts b/packages/onboarding/src/pending-seed.test.ts deleted file mode 100644 index 92f5359422..0000000000 --- a/packages/onboarding/src/pending-seed.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -// The in-memory store exercises the same encrypt/validate/TTL logic -// `createDrizzlePendingSeedStore` runs against Postgres (see -// `createPendingSeedStore` in `./pending-seed.ts`) — round-trip, -// per-provider AAD, wrong-session, expiry, and cleared-on-success all -// have to hold regardless of which `RowAccess` backs the store. -// `test/pending-seed-store.test.ts` DB-gates the same scenarios against -// the real Postgres-backed store. -import { describe, expect, test } from "bun:test"; -import { createEnvKeyCredentialCipher } from "@intx/crypto"; -import type { CredentialCipher } from "@intx/types"; -import { - createInMemoryPendingSeedStore, - PENDING_SEED_TTL_MS, - type PendingSeed, -} from "./pending-seed"; - -const TEST_KEY = Buffer.alloc(32, 11); -function testCipher(): CredentialCipher { - return createEnvKeyCredentialCipher(TEST_KEY); -} - -const SEED: PendingSeed = { - userId: "user_1", - tenantId: "ten_1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - provider: "openrouter", - apiKey: "sk-or-v1-minted", -}; - -describe("createInMemoryPendingSeedStore", () => { - test("round-trips for the exact user and tenant it was written for", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED); - - const read = await store.read({ userId: "user_1", tenantId: "ten_1" }); - - expect(read).toEqual(SEED); - }); - - test("stays readable more than once inside its TTL — unlike the PKCE state, this is not single-use", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED); - - const first = await store.read({ userId: "user_1", tenantId: "ten_1" }); - const second = await store.read({ userId: "user_1", tenantId: "ten_1" }); - - expect(first).toEqual(SEED); - expect(second).toEqual(SEED); - }); - - test("a row written for one user is invisible to another", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED); - - const read = await store.read({ userId: "user_2", tenantId: "ten_1" }); - - expect(read).toBeUndefined(); - }); - - test("a row written for one tenant is invisible against another", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED); - - const read = await store.read({ - userId: "user_1", - tenantId: "ten_other", - }); - - expect(read).toBeUndefined(); - }); - - test("an expired row reads as absent and is deleted, not merely ignored", async () => { - let clock = 0; - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED, { now: () => clock }); - - clock = PENDING_SEED_TTL_MS; - const read = await store.read({ - userId: "user_1", - tenantId: "ten_1", - now: () => clock, - }); - expect(read).toBeUndefined(); - - // Deleted, not just expired-and-skipped: a fresh read (even with - // the clock rewound) finds nothing, proving the row is gone. - const rereadEarlier = await store.read({ - userId: "user_1", - tenantId: "ten_1", - now: () => 0, - }); - expect(rereadEarlier).toBeUndefined(); - }); - - test("a ciphertext sealed under one key is worthless after a key rotation", async () => { - const cipher = testCipher(); - const aad = JSON.stringify(["onboarding-pending-seed", "openrouter"]); - const payload = await cipher.encrypt( - JSON.stringify({ - principalId: SEED.principalId, - tenantDomain: SEED.tenantDomain, - apiKey: SEED.apiKey, - }), - aad, - ); - - const rotated = createEnvKeyCredentialCipher(Buffer.alloc(32, 12)); - await expect(rotated.decrypt(payload, aad)).rejects.toThrow(); - }); - - test("a fresh connect upserts over whatever pending seed came before it — one active row per (userId, tenantId)", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED); - - const replacement: PendingSeed = { - ...SEED, - provider: "huggingface", - apiKey: "hf_replaced", - }; - await store.put(replacement); - - const read = await store.read({ userId: "user_1", tenantId: "ten_1" }); - expect(read).toEqual(replacement); - }); - - test("listDue answers every unexpired row, whoever owns it — the drain has no request to scope it", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED); - await store.put({ - ...SEED, - userId: "user_2", - tenantId: "ten_2", - apiKey: "sk-or-v1-second", - }); - - const due = await store.listDue({}); - - expect(due.truncated).toBe(false); - expect(due).not.toHaveProperty("next"); - expect(due.seeds).toHaveLength(2); - expect(due.seeds).toEqual( - expect.arrayContaining([ - SEED, - { - ...SEED, - userId: "user_2", - tenantId: "ten_2", - apiKey: "sk-or-v1-second", - }, - ]), - ); - }); - - test("listDue drops an expired row instead of handing the drain a dead key", async () => { - let clock = 0; - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED, { now: () => clock }); - await store.put( - { ...SEED, userId: "user_2", tenantId: "ten_2" }, - { now: () => clock, ttlMs: PENDING_SEED_TTL_MS * 2 }, - ); - - clock = PENDING_SEED_TTL_MS + 1; - const due = await store.listDue({ now: () => clock }); - - expect(due.truncated).toBe(false); - expect(due.seeds).toEqual([{ ...SEED, userId: "user_2", tenantId: "ten_2" }]); - // Swept, not merely skipped — the expired row is gone for good. - expect(await store.read({ userId: "user_1", tenantId: "ten_1", now: () => 0 })).toBeUndefined(); - }); - - test("listDue honors its limit so one drain tick can never scan the whole table", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - for (let index = 0; index < 5; index += 1) { - await store.put({ - ...SEED, - userId: `user_${index}`, - tenantId: `ten_${index}`, - }); - } - - const due = await store.listDue({ limit: 2 }); - - expect(due.seeds).toHaveLength(2); - expect(due.truncated).toBe(true); - expect(due.next).toEqual({ - expiresAt: expect.any(Date), - userId: expect.any(String), - tenantId: expect.any(String), - }); - }); - - test("listDue returns oldest-due first and continues from an (expiresAt, userId, tenantId) cursor", async () => { - const clock = 0; - const store = createInMemoryPendingSeedStore(testCipher()); - for (let index = 0; index < 5; index += 1) { - await store.put( - { - ...SEED, - userId: `user_${index}`, - tenantId: `ten_${index}`, - }, - { now: () => clock, ttlMs: 5_000 - index }, - ); - } - - const first = await store.listDue({ limit: 2, now: () => clock }); - expect(first.seeds.map((seed) => seed.userId)).toEqual(["user_4", "user_3"]); - expect(first.truncated).toBe(true); - - const second = await store.listDue({ - limit: 2, - now: () => clock, - ...(first.next !== undefined ? { after: first.next } : {}), - }); - expect(second.seeds.map((seed) => seed.userId)).toEqual(["user_2", "user_1"]); - expect(second.truncated).toBe(true); - - const third = await store.listDue({ - limit: 2, - now: () => clock, - ...(second.next !== undefined ? { after: second.next } : {}), - }); - expect(third.seeds.map((seed) => seed.userId)).toEqual(["user_0"]); - expect(third.truncated).toBe(false); - expect(third).not.toHaveProperty("next"); - }); - - test("cleared on successful seed — the row is gone after clear", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED); - - await store.clear({ userId: "user_1", tenantId: "ten_1" }); - - const read = await store.read({ userId: "user_1", tenantId: "ten_1" }); - expect(read).toBeUndefined(); - }); - - test("clearing a row that was never written is a harmless no-op", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await expect(store.clear({ userId: "user_1", tenantId: "ten_1" })).resolves.toBeUndefined(); - }); - - test("round-trips baseURLOverride for an ollama-shaped seed — the drain needs the real instance URL, not a curated default", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - const ollamaSeed: PendingSeed = { - ...SEED, - provider: "ollama", - baseURLOverride: "https://home-mac-studio.tail87f5aa.ts.net", - }; - await store.put(ollamaSeed); - - const read = await store.read({ userId: "user_1", tenantId: "ten_1" }); - - expect(read).toEqual(ollamaSeed); - }); - - test("omits baseURLOverride when the seed carries none, rather than round-tripping it as undefined", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - await store.put(SEED); - - const read = await store.read({ userId: "user_1", tenantId: "ten_1" }); - - expect(read).not.toHaveProperty("baseURLOverride"); - }); - - test("round-trips for a provider other than the first — every supported provider seals and opens correctly", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - const hfSeed: PendingSeed = { ...SEED, provider: "huggingface" }; - await store.put(hfSeed); - - const read = await store.read({ userId: "user_1", tenantId: "ten_1" }); - - expect(read).toEqual(hfSeed); - }); - - test("domain separation: a ciphertext sealed for one provider's AAD cannot decrypt under another's", async () => { - const cipher = testCipher(); - const openrouterAad = JSON.stringify(["onboarding-pending-seed", "openrouter"]); - const huggingfaceAad = JSON.stringify(["onboarding-pending-seed", "huggingface"]); - const payload = await cipher.encrypt( - JSON.stringify({ - principalId: SEED.principalId, - tenantDomain: SEED.tenantDomain, - apiKey: SEED.apiKey, - }), - openrouterAad, - ); - - await expect(cipher.decrypt(payload, huggingfaceAad)).rejects.toThrow(); - }); -}); diff --git a/packages/onboarding/src/pending-seed.ts b/packages/onboarding/src/pending-seed.ts deleted file mode 100644 index 2739c4a3cd..0000000000 --- a/packages/onboarding/src/pending-seed.ts +++ /dev/null @@ -1,437 +0,0 @@ -// Server-side custody for a just-connected credential's plaintext key, -// carried from the OAuth callback (which must never wait on a workflow -// deploy — see `complete-credential.ts`) to the onboarding page's own -// follow-up request, the one that actually runs `ensureSeeded`. The -// hub's credential rows are write-only by design (a secret is never -// handed back over `GET /api/tenants/:id/credentials`), so once the -// callback's own request ends, the plaintext key is gone from memory -// unless something carries it forward — this is that something. -// -// CL-6031 moved this off the browser entirely. It used to be an -// HttpOnly cookie sealed the same way the PKCE connect state is -// (`pkce.ts`); a cookie meant the browser briefly custodied a sealed -// copy of the plaintext key even though it could never read it. Now -// it is one row per (userId, tenantId) in this package's own -// `onboarding.pending_seed` table (see `./schema.ts`), and the browser -// carries nothing but its ordinary session cookie — which already -// scopes every read to the exact user and tenant the row was written -// for, the same guarantee the cookie's own userId/tenantId check used -// to provide by hand. -// -// Same AEAD discipline the cookie used: sealed via the shared -// `CredentialCipher`, AAD `["onboarding-pending-seed", provider]`, so a -// value minted moments before a hub restart survives it, and a row -// sealed for one provider cannot decrypt under another's. Unlike the -// PKCE connect state, this is not single-use — the workflow-deploy step -// it feeds (`seedTenant`/`ensureSeeded`) is itself idempotent -// (409-then-list on every create), so two overlapping "finish setup" -// calls both reading the same still-valid row is exactly as safe as -// one, and a legit retry after a transient failure can reuse it within -// the same short window instead of being stranded with no key to retry -// with. A single active row per (userId, tenantId) — writing a new one -// replaces whatever was there (upsert) — mirrors the cookie's -// single-active-token behavior. -// -// CL-6457 turned this row into something more than a handoff: it is now -// the durable work item for background bench provisioning (see -// `./bench-provisioning.ts`). Connecting a provider deploys nothing, so -// the row is what says "this bench has a credential and does not yet -// have its agents" — and because it lives in Postgres rather than in -// the process that wrote it, a hub that dies mid-deploy resumes the -// bench on its next boot instead of stranding it half-provisioned. That -// is also why the TTL is a day rather than ten minutes: it now has to -// outlive provisioning, not just a redirect. -// -// TTL is enforced at read time: a row past its `expiresAt` is deleted -// and treated as though it were never there, rather than by a periodic -// sweep job — `listDue` applies exactly the same rule as `read` on its -// way past, so the drain is never handed a dead key. A row that is never -// read again is dead weight, not a correctness or security problem — the -// ciphertext is worthless without the cipher key, and a forgotten row -// does not accumulate: the next connect upserts over it. -// -// Future work: once Interchange ships an `InferenceSource` -// credential-by-reference primitive (the [Intx ask] tracked on this -// ticket), the callback can hand `ensureSeeded` a reference to the -// credential it already just persisted instead of smuggling the -// plaintext key forward at all — this entire store, table, and AEAD -// dance goes away. - -import { and, asc, eq, gt, or } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { type } from "arktype"; -import type { CredentialCipher } from "@intx/types"; -import { - supportedCredentialProviders, - type SupportedCredentialProvider, -} from "@corbits/connections/credential-test"; -import { pendingSeed } from "./schema"; - -/** - * How long a row stays usable. This is a provisioning queue's durability - * window, not a handoff token's lifetime (CL-6457): the row is what lets - * a background drain finish — or, after a hub restart, resume — the - * workflow deploys that connect deliberately no longer waits on. It is - * deleted the moment provisioning converges, so this bound only governs - * a bench that never converged at all: long enough that an overnight - * sidecar outage still resolves itself, short enough that an abandoned - * one does not keep a duplicate of the key indefinitely. The key itself - * already lives on, encrypted, as the tenant's own credential row — this - * copy is a duplicate of material the hub holds either way, never a new - * class of secret. - */ -export const PENDING_SEED_TTL_MS = 24 * 60 * 60 * 1000; - -const PROVIDER_IDS = supportedCredentialProviders().map((p) => p.id) as [ - SupportedCredentialProvider, - ...SupportedCredentialProvider[], -]; - -const PendingSeedSecret = type({ - principalId: "string > 0", - tenantDomain: "string > 0", - apiKey: "string > 0", - "baseURLOverride?": "string > 0", -}); - -export type PendingSeed = { - readonly userId: string; - readonly tenantId: string; - readonly principalId: string; - readonly tenantDomain: string; - readonly provider: SupportedCredentialProvider; - readonly apiKey: string; - /** The instance origin a `credentialInputKind: "url"` connector - * (Ollama) was actually pointed at, connect time — never a curated - * default. Absent for every other provider. Threaded through so the - * drain's `ensureSeeded` call resolves `modelSourceFor` against the - * real instance rather than falling back to `CATALOG_SEEDS.ollama`'s - * fixed origin (CL-6366's same failure mode, one hop later). */ - readonly baseURLOverride?: string; -}; - -export type PendingSeedDb = Record> = - PostgresJsDatabase; - -/** Domain separation on top of the AEAD tag, mirroring `pkce.ts`'s - * `connectStateAad`: a row sealed for one provider's connect flow - * cannot decrypt under another's. */ -function pendingSeedAad(provider: string): string { - return JSON.stringify(["onboarding-pending-seed", provider]); -} - -export interface PendingSeedStore { - /** Upserts the single active row for (userId, tenantId) — a fresh - * connect always replaces whatever pending seed came before it. */ - put(seed: PendingSeed, args?: { ttlMs?: number; now?: () => number }): Promise; - /** - * Reads and validates the row for exactly the signed-in user and - * tenant this request already resolved — a row belonging to a - * different user or tenant is simply a different key and is never - * found. A row that fails to validate (expired, corrupt, sealed - * under a since-rotated key) is deleted as dead weight before this - * returns `undefined`, rather than left to linger out its TTL — - * this is the read-time TTL cleanup described in this module's - * header comment. A row that validates is left in place; the caller - * decides when to `clear` it (on successful seed). - */ - read(args: { - userId: string; - tenantId: string; - now?: () => number; - }): Promise; - /** - * One ordered page of unexpired rows, for the background drain that has - * no request — and therefore no (userId, tenantId) — to scope itself by. - * This is what makes provisioning survive a hub restart: the rows a - * crashed process was mid-way through are still here, and the next - * boot's first tick picks them straight back up. Expired rows are - * deleted on the way past, the same read-time sweep `read` performs, so - * a dead key is never handed to a drain. - * - * Rows are ordered oldest-due first (`expiresAt`, then `userId`, - * `tenantId`). `limit` bounds one page so a tick cannot scan the whole - * table; pass `after` the previous page's `next` cursor to continue. - * `truncated` is true when more rows remain behind this page. - */ - listDue(args: { - now?: () => number; - limit?: number; - after?: PendingSeedListCursor; - }): Promise; - /** Deletes the row for (userId, tenantId), if any. Called once the - * pending seed has done its job (seeded successfully) or once the - * bench already reads as fully seeded some other way. */ - clear(args: { userId: string; tenantId: string }): Promise; -} - -export const PENDING_SEED_SCAN_LIMIT = 50; - -/** Keyset cursor for `listDue` — the last row of a truncated page. */ -export type PendingSeedListCursor = { - readonly expiresAt: Date; - readonly userId: string; - readonly tenantId: string; -}; - -export type PendingSeedDuePage = { - readonly seeds: PendingSeed[]; - readonly truncated: boolean; - readonly next?: PendingSeedListCursor; -}; - -interface StoredRow { - provider: string; - payload: string; - expiresAt: Date; -} - -interface IdentifiedRow extends StoredRow { - userId: string; - tenantId: string; -} - -interface RowAccess { - get(userId: string, tenantId: string): Promise; - put(userId: string, tenantId: string, row: StoredRow): Promise; - delete(userId: string, tenantId: string): Promise; - list(args: { limit: number; after?: PendingSeedListCursor }): Promise; -} - -function comparePendingSeedCursor(a: PendingSeedListCursor, b: PendingSeedListCursor): number { - const byExpires = a.expiresAt.getTime() - b.expiresAt.getTime(); - if (byExpires !== 0) return byExpires; - if (a.userId < b.userId) return -1; - if (a.userId > b.userId) return 1; - if (a.tenantId < b.tenantId) return -1; - if (a.tenantId > b.tenantId) return 1; - return 0; -} - -function createPendingSeedStore(access: RowAccess, cipher: CredentialCipher): PendingSeedStore { - /** - * The single validity rule both readers apply: a row that is expired, - * sealed for an unsupported provider, or undecryptable under the - * current key is dead weight — deleted here and reported as absent, - * rather than left to linger or handed onward as a usable seed. - */ - async function decodeRow(row: IdentifiedRow, nowMs: number): Promise { - const drop = async (): Promise => { - await access.delete(row.userId, row.tenantId); - return undefined; - }; - - if (row.expiresAt.getTime() <= nowMs) return drop(); - if (!PROVIDER_IDS.includes(row.provider as SupportedCredentialProvider)) { - return drop(); - } - const provider = row.provider as SupportedCredentialProvider; - - try { - const plaintext = await cipher.decrypt(row.payload, pendingSeedAad(provider)); - const parsed = PendingSeedSecret(JSON.parse(plaintext)); - if (parsed instanceof type.errors) return drop(); - return { - userId: row.userId, - tenantId: row.tenantId, - provider, - principalId: parsed.principalId, - tenantDomain: parsed.tenantDomain, - apiKey: parsed.apiKey, - ...(parsed.baseURLOverride !== undefined - ? { baseURLOverride: parsed.baseURLOverride } - : {}), - }; - } catch { - return drop(); - } - } - - return { - async put(seed, args = {}) { - const now = args.now ?? Date.now; - const ttlMs = args.ttlMs ?? PENDING_SEED_TTL_MS; - const payload = await cipher.encrypt( - JSON.stringify({ - principalId: seed.principalId, - tenantDomain: seed.tenantDomain, - apiKey: seed.apiKey, - ...(seed.baseURLOverride !== undefined ? { baseURLOverride: seed.baseURLOverride } : {}), - }), - pendingSeedAad(seed.provider), - ); - await access.put(seed.userId, seed.tenantId, { - provider: seed.provider, - payload, - expiresAt: new Date(now() + ttlMs), - }); - }, - - async read(args) { - const now = args.now ?? Date.now; - const row = await access.get(args.userId, args.tenantId); - if (row === undefined) return undefined; - return decodeRow({ ...row, userId: args.userId, tenantId: args.tenantId }, now()); - }, - - async listDue(args) { - const now = args.now ?? Date.now; - const nowMs = now(); - const limit = args.limit ?? PENDING_SEED_SCAN_LIMIT; - const fetched = await access.list({ - limit: limit + 1, - ...(args.after !== undefined ? { after: args.after } : {}), - }); - const truncated = fetched.length > limit; - const rows = truncated ? fetched.slice(0, limit) : fetched; - const due: PendingSeed[] = []; - for (const row of rows) { - const seed = await decodeRow(row, nowMs); - if (seed !== undefined) due.push(seed); - } - const last = rows[rows.length - 1]; - return { - seeds: due, - truncated, - ...(truncated && last !== undefined - ? { - next: { - expiresAt: last.expiresAt, - userId: last.userId, - tenantId: last.tenantId, - }, - } - : {}), - }; - }, - - async clear(args) { - await access.delete(args.userId, args.tenantId); - }, - }; -} - -export function createDrizzlePendingSeedStore>( - db: PendingSeedDb, - cipher: CredentialCipher, -): PendingSeedStore { - return createPendingSeedStore( - { - async get(userId, tenantId) { - const [row] = await db - .select() - .from(pendingSeed) - .where(and(eq(pendingSeed.userId, userId), eq(pendingSeed.tenantId, tenantId))); - return row === undefined - ? undefined - : { - provider: row.provider, - payload: row.payload, - expiresAt: row.expiresAt, - }; - }, - async put(userId, tenantId, row) { - await db - .insert(pendingSeed) - .values({ - userId, - tenantId, - provider: row.provider, - payload: row.payload, - expiresAt: row.expiresAt, - }) - .onConflictDoUpdate({ - target: [pendingSeed.userId, pendingSeed.tenantId], - set: { - provider: row.provider, - payload: row.payload, - expiresAt: row.expiresAt, - }, - }); - }, - async delete(userId, tenantId) { - await db - .delete(pendingSeed) - .where(and(eq(pendingSeed.userId, userId), eq(pendingSeed.tenantId, tenantId))); - }, - async list({ limit, after }) { - const rows = - after === undefined - ? await db - .select() - .from(pendingSeed) - .orderBy( - asc(pendingSeed.expiresAt), - asc(pendingSeed.userId), - asc(pendingSeed.tenantId), - ) - .limit(limit) - : await db - .select() - .from(pendingSeed) - .where( - or( - gt(pendingSeed.expiresAt, after.expiresAt), - and( - eq(pendingSeed.expiresAt, after.expiresAt), - gt(pendingSeed.userId, after.userId), - ), - and( - eq(pendingSeed.expiresAt, after.expiresAt), - eq(pendingSeed.userId, after.userId), - gt(pendingSeed.tenantId, after.tenantId), - ), - ), - ) - .orderBy( - asc(pendingSeed.expiresAt), - asc(pendingSeed.userId), - asc(pendingSeed.tenantId), - ) - .limit(limit); - return rows.map((row) => ({ - userId: row.userId, - tenantId: row.tenantId, - provider: row.provider, - payload: row.payload, - expiresAt: row.expiresAt, - })); - }, - }, - cipher, - ); -} - -/** The fake this package's own tests drive routes through — no - * Postgres required, same encrypt/validate/TTL semantics as the real - * store. */ -export function createInMemoryPendingSeedStore(cipher: CredentialCipher): PendingSeedStore { - const rows = new Map(); - const keyOf = (userId: string, tenantId: string) => `${userId}:${tenantId}`; - return createPendingSeedStore( - { - async get(userId, tenantId) { - return rows.get(keyOf(userId, tenantId)); - }, - async put(userId, tenantId, row) { - rows.set(keyOf(userId, tenantId), row); - }, - async delete(userId, tenantId) { - rows.delete(keyOf(userId, tenantId)); - }, - async list({ limit, after }) { - const identified = [...rows.entries()].map(([key, row]) => { - const [userId = "", tenantId = ""] = key.split(":"); - return { ...row, userId, tenantId }; - }); - identified.sort(comparePendingSeedCursor); - const filtered = - after === undefined - ? identified - : identified.filter((row) => comparePendingSeedCursor(row, after) > 0); - return filtered.slice(0, limit); - }, - }, - cipher, - ); -} diff --git a/packages/onboarding/src/plant-env-credentials.ts b/packages/onboarding/src/plant-env-credentials.ts deleted file mode 100644 index 5b7033121f..0000000000 --- a/packages/onboarding/src/plant-env-credentials.ts +++ /dev/null @@ -1,382 +0,0 @@ -// Env-key auto-plant (CL-6101): the hub-boot counterpart to `workbench -// seed`'s ANTHROPIC_API_KEY handling and to this package's own -// `completeCredentialSetup` (the guided credential step). Where those -// two plant a credential for a person who is either running the CLI by -// hand or pasting a key into the wizard, this module plants one for -// every curated provider whose conventional env var appears in a given -// env map. Hub boot does not call it (CL-7579: boot reads no provider -// env vars); it is kept as a library for a future granted setup step. -// -// Never reimplements credential planting: the live probe is -// `testProviderCredential` and the actual plant is `seedCatalog` (same -// `@corbits/connections` functions `completeCredentialSetup` calls), -// reused here exactly as onboarding's own guided step uses them. This -// module's only job is the env-map-to-provider translation, the -// idempotency check that skips overwriting a provider already carrying -// a working credential (never rotating a renamed key), backfills that -// provider's curated catalog additively, and folds a single provider's -// failure into a log line instead of an exception — one bad or -// rate-limited key must never stop every other provider from planting. - -import { CredentialResponse, paginatedSchema, ProviderResponse } from "@intx/types"; -import { reportError } from "@corbits/error-sink"; -import { - OLLAMA_PLACEHOLDER_SECRET, - testProviderCredential, - type SupportedCredentialProvider, - type TestProviderCredentialArgs, -} from "@corbits/connections/credential-test"; -import { - inferenceCredentialName, - seedCatalog, - type SeedCatalogArgs, -} from "@corbits/connections/seed-catalog"; -import { parseAs, type ApiCall } from "@corbits/hub-api-client"; - -/** - * The conventional environment variable name(s) each curated provider's - * key is read from. Listed in the order `CATALOG_SEEDS` declares - * providers, so a log or docs listing built by iterating this map reads - * in the same order the catalog itself does. `google-genai` accepts - * either of the two names the ecosystem actually uses for a Gemini key - * — `GEMINI_API_KEY` first (Google's own current SDKs read this one), - * falling back to `GOOGLE_API_KEY` — rather than picking one and - * silently ignoring whichever an operator happens to already have set. - */ -export const PROVIDER_ENV_VARS: Readonly> = { - anthropic: ["ANTHROPIC_API_KEY"], - openai: ["OPENAI_API_KEY"], - "google-genai": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], - xai: ["XAI_API_KEY"], - // The OAuth-only providers (CL-7510) have no env-var API-key path at - // all — a token only exists after its loopback OAuth login, so there is - // no env var an operator could plant and no name here could match. - "xai-oauth": [], - codex: [], - openrouter: ["OPENROUTER_API_KEY"], - "opencode-zen": ["OPENCODE_ZEN_API_KEY"], - groq: ["GROQ_API_KEY"], - deepseek: ["DEEPSEEK_API_KEY"], - mistral: ["MISTRAL_API_KEY"], - huggingface: ["HUGGINGFACE_API_KEY"], - // Not a secret — see `OLLAMA_PLACEHOLDER_SECRET`. Listed here so the - // env-var-per-provider convention this map documents stays complete; - // `envProviderKeysFrom`/`envProviderBaseUrlsFrom` below give this one - // provider's entry different treatment: presence plants the fixed - // placeholder secret, and the value itself is read separately as the - // instance's base URL. - ollama: ["OLLAMA_BASE_URL"], -}; - -/** - * Reads `PROVIDER_ENV_VARS` out of a raw environment map, first - * matching name wins per provider. Pure and side-effect-free so a - * caller (the hub's own `readHubConfig`) can arktype-parse the - * individual variables first and still land on this same mapping. - * `ollama` is the one provider whose env var is not itself a secret: - * `OLLAMA_BASE_URL`'s mere presence plants the fixed - * `OLLAMA_PLACEHOLDER_SECRET`, never the URL itself — the URL is read - * separately by `envProviderBaseUrlsFrom`. - */ -export function envProviderKeysFrom( - env: Record, -): Partial> { - const keys: Partial> = {}; - for (const [provider, names] of Object.entries(PROVIDER_ENV_VARS) as [ - SupportedCredentialProvider, - readonly string[], - ][]) { - for (const name of names) { - const value = env[name]; - if (value !== undefined && value.length > 0) { - keys[provider] = provider === "ollama" ? OLLAMA_PLACEHOLDER_SECRET : value; - break; - } - } - } - return keys; -} - -/** - * The base-URL counterpart to `envProviderKeysFrom`: every curated - * provider has a fixed origin except `ollama`, whose `OLLAMA_BASE_URL` - * env var names the actual instance (local or tailscale-tunneled) to - * plant a credential against. Empty for every other provider — this map - * only ever carries the one entry today. - */ -export function envProviderBaseUrlsFrom( - env: Record, -): Partial> { - const baseUrls: Partial> = {}; - const value = env["OLLAMA_BASE_URL"]; - if (value !== undefined && value.length > 0) { - baseUrls.ollama = value; - } - return baseUrls; -} - -export type PlantEnvProviderCredentialsOutcome = { - readonly provider: SupportedCredentialProvider; - readonly status: "planted" | "skipped" | "failed" | "blocked"; - /** A probe-error summary on `failed`; never the key itself. */ - readonly message?: string; -}; - -/** - * Strips token-shaped substrings (`sk-…`, `hf_…`, and similar - * provider-key prefixes) out of a probe error message before it is - * logged. Providers sometimes echo a truncated form of the rejected key - * back in their error body (OpenAI does this); this must never reach a - * log line. - */ -function sanitizeProviderMessage(message: string): string { - return message.replace(/\b(sk|pk|xai|gsk|hf|or)[-_][A-Za-z0-9_-]{6,}\b/g, "[redacted]"); -} - -export type PlantEnvProviderCredentialsArgs = { - api: ApiCall; - cookies: string[]; - tenantId: string; - /** provider -> API key, e.g. from `envProviderKeysFrom`. */ - envProviderKeys: Partial>; - /** provider -> configured base URL, e.g. from `envProviderBaseUrlsFrom`. - * Only `ollama` ever carries an entry today; every other provider's - * probe and seed run against its own fixed origin regardless of what - * (if anything) this map holds for it. */ - envProviderBaseUrls?: Partial>; - /** One line per provider: name, outcome, and (on failure) a probe - * error summary — never the key. */ - log: (line: string) => void; - testCredential?: (args: TestProviderCredentialArgs) => ReturnType; - seedCatalogFn?: (args: SeedCatalogArgs) => ReturnType; -}; - -/** - * Looks up the provider row a curated provider's connections (env-plant - * and a Settings connect alike) both key their credential to — - * `persistConnectorCredential` and `seedCatalog`'s own `plantCredential` - * both `ensureProvider` this exact `{ name: provider }` pair, so a - * provider row's existence here means some path already connected this - * provider. Read-only: unlike `ensureProvider`, this never creates the - * row, so a provider nobody has connected yet correctly reads back as - * "no active credential" without planting a stub row ahead of a probe - * that might still fail. Paginated the same way `findActiveCredential` - * is — a tenant with enough providers to span a page must not lose a - * match that lands on page two. - */ -async function findProviderId( - api: ApiCall, - cookies: string[], - tenantId: string, - provider: SupportedCredentialProvider, -): Promise { - let cursor: string | undefined; - do { - const path = - cursor === undefined - ? `/api/tenants/${tenantId}/providers?inherited=false` - : `/api/tenants/${tenantId}/providers?inherited=false&cursor=${encodeURIComponent(cursor)}`; - const listed = await api("GET", path, undefined, cookies); - const page = parseAs(paginatedSchema(ProviderResponse), listed.data, "providers response"); - const match = page.data.find((p) => p.name === provider); - if (match !== undefined) return match.id; - cursor = page.nextCursor ?? undefined; - } while (cursor !== undefined); - return undefined; -} - -/** - * An active credential is recognized by the provider row it belongs to - * (`providerId`), not by the credential's own name — a Settings-connected - * credential is named after the connector's `displayName` ("Anthropic"), - * while this module's own plant names its row - * `inferenceCredentialName(provider)` ("anthropic-default"). Both - * resolve to the same provider row (`findProviderId`), so matching on - * `providerId` recognizes either one instead of only the env-plant's own - * naming convention. Restricted to the credential types `seedCatalog` - * itself ever writes for an inference source (`api_key`, `oauth_token`) - * so a non-inference row that happens to share the provider (never - * planted by either path today, but not a case this match should ever - * be fooled by) can't count as the plant. - */ -async function findActiveCredential( - api: ApiCall, - cookies: string[], - tenantId: string, - providerId: string, -): Promise<{ id: string; name: string } | undefined> { - let cursor: string | undefined; - do { - const path = - cursor === undefined - ? `/api/tenants/${tenantId}/credentials` - : `/api/tenants/${tenantId}/credentials?cursor=${encodeURIComponent(cursor)}`; - const listed = await api("GET", path, undefined, cookies); - const page = parseAs(paginatedSchema(CredentialResponse), listed.data, "credentials response"); - const match = page.data.find( - (c) => - c.providerId === providerId && - c.status === "active" && - (c.type === "api_key" || c.type === "oauth_token"), - ); - if (match !== undefined) return { id: match.id, name: match.name }; - cursor = page.nextCursor ?? undefined; - } while (cursor !== undefined); - return undefined; -} - -/** - * Plants a credential (and its curated catalog) for every provider - * present in `envProviderKeys`, at the given tenant, idempotently: - * - * - A provider already carrying an active credential of that name is - * not probed and its key is not overwritten — an already-rotated or - * hand-renamed key stays put. `seedCatalog` still runs against that - * existing credential (`existingCredentialId`, no `apiKey`) so a - * hub restart backfills newly curated models additively. `seedCatalog` - * is ensure-then-create: missing rows are planted, existing ones - * 409-skip, nothing is deleted. - * - A provider with no existing credential is proven with a real, - * free call (`testProviderCredential`) before anything is persisted; - * a failed probe is reported and skipped, never thrown, so one bad - * or rate-limited key never stops the rest of the providers (or the - * hub itself) from starting. - * - A proven key is planted through `seedCatalog` — the same function - * `workbench seed` and onboarding's own guided step use — so a - * workbench or workflow can launch against it immediately. `seedCatalog` - * is re-verified afterward: if a same-named but non-active credential - * already existed, `seedCatalog` 409-skips it rather than storing the - * proven key, and this is reported honestly as `"blocked"` — never - * logged as planted. - * - * Calling this twice with the same env plants the credential once: the - * second call's per-provider check skips probe and key write, then - * backfills the curated catalog against the already-active row. - */ -export async function plantEnvProviderCredentials( - args: PlantEnvProviderCredentialsArgs, -): Promise { - const testCredential = args.testCredential ?? testProviderCredential; - const runSeedCatalog = args.seedCatalogFn ?? seedCatalog; - const outcomes: PlantEnvProviderCredentialsOutcome[] = []; - const suppressedLog = () => { - // seedCatalog's own step-by-step log is suppressed here: this - // module reports exactly one summary line per provider below, - // never the per-row created/skipped detail seedCatalog logs for - // its other callers (`workbench seed`, the guided step). - }; - - function catalogSeedArgs( - provider: SupportedCredentialProvider, - extra: { readonly apiKey: string } | { readonly existingCredentialId: string }, - ): SeedCatalogArgs { - const baseURL = args.envProviderBaseUrls?.[provider]; - return { - api: args.api, - cookies: args.cookies, - tenantId: args.tenantId, - provider, - log: suppressedLog, - ...extra, - ...(baseURL !== undefined ? { baseURLOverride: baseURL } : {}), - }; - } - - for (const [provider, apiKey] of Object.entries(args.envProviderKeys) as [ - SupportedCredentialProvider, - string, - ][]) { - const providerId = await findProviderId(args.api, args.cookies, args.tenantId, provider); - const alreadyActive = - providerId !== undefined - ? await findActiveCredential(args.api, args.cookies, args.tenantId, providerId) - : undefined; - if (alreadyActive) { - const name = alreadyActive.name; - try { - await runSeedCatalog( - catalogSeedArgs(provider, { - existingCredentialId: alreadyActive.id, - }), - ); - } catch (cause) { - reportError(cause, { - operation: "env_credential_plant_backfill_catalog", - tenantId: args.tenantId, - extra: { provider }, - }); - // Never the raw cause detail (this module handles a live API - // key, CL-7234) — sanitizeProviderMessage is the same redaction - // the probe-failure path below already applies. - const message = sanitizeProviderMessage( - cause instanceof Error ? cause.message : String(cause), - ); - args.log(`env credential plant: ${provider} failed to backfill catalog: ${message}`); - outcomes.push({ provider, status: "failed", message }); - continue; - } - args.log( - `env credential plant: ${provider} already has an active credential named ${name} (skipped) — the env key was not planted; rotate the existing ${name} credential in Plugins if you meant to replace it. Curated catalog models were backfilled additively.`, - ); - outcomes.push({ provider, status: "skipped" }); - continue; - } - - const baseURL = args.envProviderBaseUrls?.[provider]; - const probe = await testCredential( - baseURL !== undefined ? { provider, apiKey, baseURL } : { provider, apiKey }, - ); - if (!probe.ok) { - const sanitized = sanitizeProviderMessage(probe.message); - args.log(`env credential plant: ${provider} probe failed: ${sanitized}`); - outcomes.push({ provider, status: "failed", message: sanitized }); - continue; - } - - try { - await runSeedCatalog(catalogSeedArgs(provider, { apiKey })); - } catch (cause) { - reportError(cause, { - operation: "env_credential_plant", - tenantId: args.tenantId, - extra: { provider }, - }); - // Never the raw cause detail — this catch runs right after a live - // API key was used to seed the catalog (CL-7234). - const message = sanitizeProviderMessage( - cause instanceof Error ? cause.message : String(cause), - ); - args.log(`env credential plant: ${provider} failed to plant: ${message}`); - outcomes.push({ provider, status: "failed", message }); - continue; - } - - // `seedCatalog`'s `ensureCredential` treats a same-named existing - // credential as a 409 conflict and, for `api_key` rows, never - // rotates it — so a revoked/expired credential left over under the - // same name silently blocks the proven env key from ever being - // stored. `findActiveCredential` already ruled out an *active* - // credential before the probe; re-checking now is the only way to - // tell "planted" apart from "409-skipped against a dead row". The - // provider row is guaranteed to exist by now — `seedCatalog` just - // `ensureProvider`d it while planting. - const nowProviderId = await findProviderId(args.api, args.cookies, args.tenantId, provider); - const nowActive = - nowProviderId !== undefined - ? await findActiveCredential(args.api, args.cookies, args.tenantId, nowProviderId) - : undefined; - if (!nowActive) { - const name = inferenceCredentialName(provider); - args.log( - `env credential plant: ${provider} was NOT planted — a credential named ${name} already exists but is not active; your proven env key was not stored. Remove or rotate the existing ${name} credential in Plugins, then restart the hub.`, - ); - outcomes.push({ provider, status: "blocked" }); - continue; - } - - args.log(`env credential plant: ${provider} planted (catalog ready)`); - outcomes.push({ provider, status: "planted" }); - } - - return outcomes; -} diff --git a/packages/onboarding/src/provision.ts b/packages/onboarding/src/provision.ts deleted file mode 100644 index 4cdc0808b9..0000000000 --- a/packages/onboarding/src/provision.ts +++ /dev/null @@ -1,106 +0,0 @@ -// The first-login decision. The first signup on an empty hub (zero -// tenants, at most the caller's own user row) mints the root tenant -// through the native tenant-creation route — its creator becomes that -// tenant's owner. Every later signup joins the existing root tenant as -// a plain member. This path never seeds workflows, tools, or grants: -// seeding is the credential-completion step's job, driven by the -// user's own credential, never by signup. - -import { type ApiCall } from "@corbits/hub-api-client"; -import { genesisOrJoinHubSignup, type HubSignupTenancy } from "./genesis"; - -export { ProvisionError } from "./genesis"; -export type { ProvisionErrorKind } from "./genesis"; - -export type ProvisionResult = - | { - /** An account that already belongs somewhere — or has just joined - * the existing root as a plain member. Either way no wizard: the - * tenant facts ride along only when the caller just joined, for - * the member-onboarding UX to surface later (CL-7584). */ - readonly kind: "existing-member"; - readonly tenantId?: string; - readonly tenantSlug?: string; - } - | { readonly kind: "needs-onboarding" } - | { - /** The caller just minted the root tenant as its owner (genesis). - * `seeded` is always `false`: signup provisions membership only; - * the credential step owns seeding. */ - readonly kind: "provisioned"; - readonly tenantId: string; - readonly tenantSlug: string; - readonly seeded: false; - }; - -export type ProvisionArgs = { - api: ApiCall; - cookies: string[]; - userId: string; - userEmail: string; - userEmailVerified: boolean; - /** Slug for the genesis tenant — the first tenant on an empty hub. - * Later signups join the existing root and never read this. */ - defaultTenantSlug: string; - /** Display name for the genesis tenant. Required to mint: when - * omitted (shell membership probe), returns `needs-onboarding` and - * creates nothing. */ - displayName?: string; - tenancy: HubSignupTenancy; - log: (line: string) => void; -}; - -/** A lowercase-kebab personal-bench slug, unique per user without a - * coordinating registry: the local part of the email plus a short - * fragment of the user's own id, which the platform already treats as - * unique. Signup no longer mints personal benches, but the credential - * step still uses this to recognize a bench it provisioned itself. */ -export function personalTenantSlug(email: string, userId: string): string { - const local = email.split("@")[0] ?? email; - const kebab = local - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - const suffix = userId - .replace(/[^a-zA-Z0-9]/g, "") - .slice(-8) - .toLowerCase(); - return `${kebab || "bench"}-${suffix || "personal"}`; -} - -/** - * Runs the first-login hook by delegating the whole decision to - * `genesisOrJoinHubSignup` (see ./genesis.ts). Safe to call on every - * sign-in — an existing member is a single read and nothing else. - */ -export async function provisionPersonalTenantIfNeeded( - args: ProvisionArgs, -): Promise { - const result = await genesisOrJoinHubSignup({ - api: args.api, - cookies: args.cookies, - userId: args.userId, - userEmail: args.userEmail, - userEmailVerified: args.userEmailVerified, - defaultTenantSlug: args.defaultTenantSlug, - tenancy: args.tenancy, - log: args.log, - ...(args.displayName !== undefined ? { displayName: args.displayName } : {}), - }); - if (result.kind === "genesis") { - return { - kind: "provisioned", - tenantId: result.tenantId, - tenantSlug: result.tenantSlug, - seeded: false, - }; - } - if (result.kind === "joined") { - return { - kind: "existing-member", - tenantId: result.tenantId, - tenantSlug: result.tenantSlug, - }; - } - return result; -} diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts deleted file mode 100644 index 5dfb3e8d06..0000000000 --- a/packages/onboarding/src/routes.ts +++ /dev/null @@ -1,1051 +0,0 @@ -// `POST /provision`, mounted outside the hub's tenant-prefixed routes -// because a brand-new user belongs to no tenant yet: authenticated, -// idempotent, and answering either the provisioning result or the hub's -// `{ error: { code, userMessage, refId } }` envelope. What it decides and why lives -// in ./provision.ts. - -import type { AppEnv } from "@intx/hub-api"; -import { createExpiringMap } from "@corbits/collections"; -import { createNoopCredentialCipher } from "@intx/crypto"; -import { CredentialResponse, PrincipalSummary, paginatedSchema } from "@intx/types"; -import type { CredentialCipher } from "@intx/types"; -import { - supportedCredentialProviders, - type SupportedCredentialProvider, -} from "@corbits/connections/credential-test"; -import { inferenceCredentialName } from "@corbits/connections/seed-catalog"; -import { SETUP_AGENT_ASSET_NAME } from "./tenant-seed"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import { cookiesFromHeader, createHubAPI, parseAs, type ApiCall } from "@corbits/hub-api-client"; -import { Hono } from "hono"; -import { type } from "arktype"; -import { generateRefId, makeErrorEnvelope, reportError } from "@corbits/error-sink"; - -import { personalTenantSlug, provisionPersonalTenantIfNeeded, ProvisionError } from "./provision"; -import { - desiredStateSteps, - readTenantDesiredStateStatus, - TENANT_DESIRED_STATE, -} from "./desired-state"; - -import type { HubSignupTenancy } from "./genesis"; - -import { - ensureSeeded, - findPersonalTenant, - testAndPersistCredential, - type PersonalTenant, - type TestAndPersistCredentialResult, -} from "./complete-credential"; -import type { BenchProvisioner } from "./bench-provisioning"; -import { - createOAuthConnectRoutes, - DEFAULT_RETURN_PATH_ALLOWLIST, - type ConnectorDescriptor, - type OAuthExchangeResult, -} from "@corbits/connections"; -import { CONNECTOR_REGISTRY } from "@corbits/connections/registry"; -import type { PendingSeedStore } from "./pending-seed"; -import { exchangeCodeForKey } from "./openrouter-connect"; -import { exchangeCodeForToken as exchangeHuggingFaceCodeForToken } from "./huggingface-connect"; -import type { ProviderHealthStore } from "@corbits/connections/provider-health"; - -const ProvisioningStatusQuery = type({ tenantId: "string > 0" }); - -function assertNonEmpty(arr: T[]): asserts arr is [T, ...T[]] { - if (arr.length === 0) { - throw new Error("expected a non-empty array"); - } -} - -/** - * 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 (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 - * forwards `cause.message` to the client, regardless of error type. - */ -function reportOnboardingError( - logError: (line: string) => void, - args: { - /** The @corbits/error-sink operation name for this call site — one - * per route action, snake_case, never the failure code itself - * (CL-7234): a `ProvisionError`'s own `code` is an unbounded - * per-failure taxonomy, and using it as `operation` would fragment - * "provisioning is broken" across N sink operations instead of one. */ - operation: string; - userAction: string; - code: string; - userMessage: string; - cause: unknown; - tenantId?: string; - extra?: Record; - }, -): ReturnType { - const refId = generateRefId(); - const detail = args.cause instanceof Error ? args.cause.message : String(args.cause); - logError(`[${refId}] ${args.userAction} failed (${args.code}): ${detail}`); - reportError(args.cause, { - operation: args.operation, - refId, - ...(args.tenantId !== undefined ? { tenantId: args.tenantId } : {}), - ...(args.extra !== undefined ? { extra: args.extra } : {}), - }); - return makeErrorEnvelope({ - code: args.code, - userMessage: args.userMessage, - refId, - }); -} - -const providerIds = supportedCredentialProviders().map((p) => p.id); -assertNonEmpty(providerIds); - -const SubmitCredential = type({ - provider: type.enumerated(...providerIds), - apiKey: "string > 0", - // Ollama's card collects a URL instead of a key (see the onboarding - // page's own `ProviderCardButton`/credential form); `apiKey` still - // carries the fixed `OLLAMA_PLACEHOLDER_SECRET` for that provider, and - // this optional field carries the actual instance URL. Absent for - // every other provider. - "baseURL?": "string > 0", -}); - -const ProvisionBody = type({ - "name?": "string > 0", -}); - -export type CreateOnboardingRoutesDeps = { - hubUrl: string; - /** Slug for the genesis tenant the first signup on an empty hub - * mints; later signups join the existing root and never read it. */ - defaultTenantSlug: string; - /** Hub tenancy reads/writes for the genesis-or-join decision — see - * ./genesis.ts's `HubSignupTenancy`. Production wiring is - * `createHubSignupTenancy` in apps/hub/src/signup-tenancy.ts. */ - tenancy: HubSignupTenancy; - pushWorkflow: WorkflowPusher; - log: (line: string) => void; - /** Error-level sibling of `log`: every server-side failure path in - * these routes reports here so the hub's global logger records it at - * error severity, not as an info line that vanishes under filtering. */ - logError?: (line: string) => void; - openrouterConnect?: { - exchange?: typeof exchangeCodeForKey; - /** The fast half only — persists the code-exchanged key as a - * credential, no probe gating it (CL-6123). Never deploys a - * workflow; see `complete-credential.ts`'s module comment for why - * the callback route must never run more than this before - * redirecting. */ - connectCredential?: typeof testAndPersistCredential; - }; - /** The public OAuth app id from huggingface.co/settings/applications - * (see docs/onboarding-huggingface-connect.md). Absent disables the - * connect card's routes without disabling anything else — HF stays - * available as a paste-a-token provider either way. */ - huggingfaceClientId?: string; - huggingfaceConnect?: { - exchange?: typeof exchangeHuggingFaceCodeForToken; - connectCredential?: typeof testAndPersistCredential; - }; - /** - * The background drain these routes hand provisioning work to - * (CL-6457). No route here ever deploys a workflow itself; the most a - * route does is write the pending-seed row and nudge this. Absent - * only means no nudge — the drain's own poll still picks the row up on - * its next tick, which is why this is a latency optimization rather - * than a correctness dependency. - */ - benchProvisioner?: Pick; - /** - * CL-7584 revisit kick: fire-and-forget desired-state reconcile for the - * caller's tenant, fired unconditionally — never gated on a pending - * check (the reconciler itself is reads-only when converged). The hub - * wires this to the same reconciler the tenant-create observer and the - * drain use; the route never awaits it. Absent means no kick — - * convergence then relies on the drain's own poll, which is why this is - * a latency optimization rather than a correctness dependency. - */ - desiredStateKick?: (args: { tenantId: string; cookies: string[] }) => void; - /** Test seam standing in for the deploy step, so a route test can - * prove the response never waits on one. */ - ensureSeededFn?: typeof ensureSeeded; - /** Test seam for `POST /complete`'s fast half — credential persist and - * catalog seed, the only work that call still does inline. */ - testAndPersistCredentialFn?: typeof testAndPersistCredential; - /** - * The same provider-health signal `@corbits/connections`' own routes - * write to (CL-6092): a successful `POST /complete` clears any stale - * needs-attention record for the connected provider, so the shell - * banner's onboarding-routed "Fix it" (the zero-working-providers case) - * doesn't survive the very fix it sent someone to make. Absent means no - * health store is wired in — the clear is a no-op, matching every other - * optional dep here. - */ - providerHealth?: ProviderHealthStore; - /** Server-side custody for a just-connected credential's plaintext - * key between the OAuth callback and this package's own - * `/complete-setup` follow-up — see `./pending-seed.ts`'s module - * comment for why this replaced an HttpOnly cookie (CL-6031). Built - * from `createDrizzlePendingSeedStore(db, credentialCipher)` in - * production; tests inject `createInMemoryPendingSeedStore`. */ - pendingSeedStore: PendingSeedStore; - /** Seals the OAuth connect state (PKCE verifier included) parked - * between `/start` and `/callback`, so a hub restart in between - * doesn't strand it — see `@corbits/connections`' `pkce.ts`. The same `CredentialCipher` - * every other secret-at-rest seam in the hub shares - * (`CREDENTIAL_ENCRYPTION_KEY`, `apps/hub`'s `credentialCipherFrom`). - * Defaults to the identity no-op cipher: fine for dev/test, never for - * a real deployment. */ - credentialCipher?: CredentialCipher; -}; - -/** - * What every provisioning-aware route answers with (CL-6457): where this - * bench's agents actually are, read from the bench's own asset and - * deployment state rather than from anything a caller remembers. `ready` - * means every default workflow is live; `provisioning` means the drain - * still has work to do. - * - * `setupAgentReady` is the only field a waiting surface should ever - * branch on (CL-6462): it says whether the one agent a person talks to - * is live, which is the real "can they start?" question. `deployed` and - * `pending` stay for operators and logs — a count of seed workflows is - * an implementation detail no person should be made to watch. - */ -type ProvisioningStatusBody = { - readonly kind: "ready" | "provisioning"; - readonly tenantId: string; - readonly tenantSlug: string; - readonly setupAgentReady: boolean; - readonly deployed: string[]; - readonly pending: string[]; - /** CL-7584: the desired-state step list, doc-labeled, for the - * onboarding page's waiting surfaces. */ - readonly steps: readonly { - readonly name: string; - readonly label: string; - readonly status: "present" | "pending" | "blocked"; - }[]; -}; - -/** - * The idempotent-duplicate-callback recovery: when a callback's own - * single-use state comes back already consumed, that is not on its own - * proof the connection failed — a browser that fires the same callback - * twice (a double navigation, a retried request) burns the state on its - * first, successful arrival and only ever sees `state_expired` on the - * second. Before reporting that as a failure, check whether this exact - * session's user already has an active credential for this provider, - * created recently enough that it can only be the twin of this same - * round trip — never a coincidence from some unrelated, older connect. - * A genuinely expired or wrong-session state still finds nothing here - * and errors honestly. This is best-effort recovery, never load-bearing - * for correctness: any failure reading the hub (it being briefly - * unreachable, a malformed response) is treated the same as "found - * nothing" — the caller falls back to its ordinary `state_expired` - * ending rather than surfacing a second, unrelated failure mode. - */ -async function recentlyConnectedCredential( - api: ApiCall, - cookies: string[], - args: { - userId: string; - userEmail: string; - provider: SupportedCredentialProvider; - withinMs: number; - log: (line: string) => void; - now?: () => number; - }, -): Promise { - const now = args.now ?? Date.now; - try { - const expectedSlug = personalTenantSlug(args.userEmail, args.userId); - // The recovery check serves the same connect flow as - // `testAndPersistCredential`, so it resolves through the same - // fallback — a seeded admin (root bench only) whose browser - // double-fires the callback must recover as connected, not see a - // strict miss turn into `state_expired` (CL-7506). - const tenant = await findPersonalTenant(api, cookies, expectedSlug, { - fallbackToFirstPrincipal: true, - }); - if (!tenant) return undefined; - - const listed = await api( - "GET", - `/api/tenants/${tenant.tenantId}/credentials`, - undefined, - cookies, - ); - const credentials = parseAs( - paginatedSchema(CredentialResponse), - listed.data, - "credentials response", - ).data; - const name = inferenceCredentialName(args.provider); - const cutoff = now() - args.withinMs; - const match = credentials.find( - (credential) => - credential.name === name && - credential.status === "active" && - Date.parse(credential.createdAt) >= cutoff, - ); - return match ? tenant : undefined; - } catch (cause) { - // Never the raw cause detail — this path lists credentials after a - // connect and can see secret-shaped hub/parse errors (CL-7255). - const refId = reportError(cause, { - operation: "onboarding_duplicate_callback_recovery", - extra: { userId: args.userId }, - }); - args.log(`duplicate-callback recovery check failed for user ${args.userId} [${refId}]`); - return undefined; - } -} - -export function createOnboardingRoutes(deps: CreateOnboardingRoutesDeps): Hono { - const app = new Hono(); - const api = createHubAPI(deps.hubUrl); - const credentialCipher = deps.credentialCipher ?? createNoopCredentialCipher(); - - // A simple in-process per-user provision rate limiter. Provisioning is - // idempotent and safe to retry, but a client stuck in a tight retry loop - // (or a runaway script) can pile concurrent tenant creates onto the hub. - // One in-flight or recent provision per user is enough; the window is - // short because successful provisioning resolves immediately. - // - // This router is built once at hub boot and lives for the process — - // every distinct user who has ever attempted a named create would - // otherwise sit in this map forever (CL-7233). A TTL equal to the rate - // limit window itself is exactly the right eviction policy here: an - // entry has no reason to exist past the window it gates, so - // `createExpiringMap`'s own `get()` already encodes the rate-limit - // check — a defined result means "still inside the window". - const PROVISION_RATE_LIMIT_MS = 10_000; - const lastProvisionByUser = createExpiringMap({ - ttlMs: PROVISION_RATE_LIMIT_MS, - }); - - /** - * Reads where the bench's agents actually stand. Two hub reads, no - * writes, no deploys — cheap enough that every provisioning-aware - * route can answer from it directly. - */ - async function provisioningStatus( - cookies: string[], - tenant: Pick, - ): Promise { - const status = await readTenantDesiredStateStatus(api, cookies, tenant.tenantId); - const steps = desiredStateSteps(status); - const deployed = TENANT_DESIRED_STATE.workflows - .filter((pin) => status.workflows[pin.assetName] === "present") - .map((pin) => pin.assetName); - const pending = TENANT_DESIRED_STATE.workflows - .filter((pin) => status.workflows[pin.assetName] !== "present") - .map((pin) => pin.assetName); - return { - // The person-facing readiness gate stays on the workflow set: Myra - // live is "can they start". Tool packages and skills ride in - // `steps` for the waiting surface without holding the door shut. - // (Cf. `DesiredStateStatus.ready` in `./desired-state.ts`, which - // covers the full tools+skills+workflows state — the reconcile - // loop's gate, not this response's.) - kind: pending.length === 0 ? "ready" : "provisioning", - tenantId: tenant.tenantId, - tenantSlug: tenant.tenantSlug, - setupAgentReady: status.workflows[SETUP_AGENT_ASSET_NAME] === "present", - deployed, - pending, - steps, - }; - } - - app.post("/provision", async (c) => { - const user = c.get("user"); - if (!user) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Sign in to continue.", - }), - 401, - ); - } - - // Optional body: the naming wizard sends `{ name }`; the shell's - // membership probe may POST with no body and only wants the read path. - // Parse before rate-limiting so the read probe never burns a create slot. - // Empty body → probe. Present body that is not valid JSON or fails the - // schema → 400 (never silently treated as a probe). - const bodyText = await c.req.text(); - let body: { name?: string } | undefined; - if (bodyText.trim() === "") { - body = undefined; - } else { - let rawBody: unknown; - try { - rawBody = JSON.parse(bodyText) as unknown; - } catch { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "That request wasn't valid. Try again.", - }), - 400, - ); - } - const parsed = ProvisionBody(rawBody); - if (parsed instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "bad_request", - userMessage: "That request wasn't valid. Try again.", - }), - 400, - ); - } - body = parsed; - } - const isCreateAttempt = body?.name !== undefined; - - // Rate-limit only named creates. The two-step first-login flow is - // probe (no name) → naming submit (with name); gating both would 429 - // anyone who types a name within the window of their membership probe. - if (isCreateAttempt) { - const now = Date.now(); - const isRateLimited = lastProvisionByUser.get(user.id) !== undefined; - if (isRateLimited) { - return c.json( - { - error: { - ...makeErrorEnvelope({ - code: "rate_limited", - userMessage: "Too many attempts. Wait a moment, then try again.", - }).error, - kind: "transient" as const, - }, - }, - 429, - ); - } - lastProvisionByUser.set(user.id, now); - } - - const cookies = cookiesFromHeader(c.req.header("cookie")); - try { - const provisionArgs: Parameters[0] = { - api, - cookies, - userId: user.id, - userEmail: user.email, - userEmailVerified: user.emailVerified, - defaultTenantSlug: deps.defaultTenantSlug, - tenancy: deps.tenancy, - log: deps.log, - }; - if (body?.name !== undefined) provisionArgs.displayName = body.name; - - const result = await provisionPersonalTenantIfNeeded(provisionArgs); - - // CL-7584 revisit kick: fire unconditionally — never await the - // multi-GET status read on this hot path. `reconcileTenantDesiredState` - // is reads-only when converged (it never enters `seedTenant` with - // every pin present), so a pending-check here would only ever delay - // this response; the reconcile itself re-reads the pins behind the - // fire-and-forget boundary. Repeat probes re-kick safely - // (idempotent) and `pending_seed` stays the only durable row. - if ( - (result.kind === "provisioned" || result.kind === "existing-member") && - deps.desiredStateKick !== undefined - ) { - const kick = deps.desiredStateKick; - const fire = (tenantId: string) => { - try { - kick({ tenantId, cookies }); - } catch (cause) { - // report-error-ignore: the kick is best-effort — convergence - // falls back to the pending_seed drain, so a failed kick only - // ever costs one delayed pass. - deps.log( - `desired-state kick for user ${user.id} failed (convergence falls back to the drain): ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - }; - // A just-joined or just-minted tenant carries its id and fires - // synchronously, before this response is even serialized. - const directTenantId = result.tenantId; - if (directTenantId !== undefined) { - fire(directTenantId); - } else { - // A plain existing member carries no id — resolve it the same - // way the connect flow does (first active principal) behind the - // fire-and-forget boundary, so the response never waits on it - // either. - void (async () => { - try { - const found = await findPersonalTenant( - api, - cookies, - personalTenantSlug(user.email, user.id), - { fallbackToFirstPrincipal: true }, - ); - if (found?.tenantId !== undefined) fire(found.tenantId); - } catch (cause) { - // report-error-ignore: the kick lookup is best-effort — - // convergence falls back to the pending_seed drain, so a - // failed lookup only ever costs one delayed pass. - deps.log( - `desired-state kick lookup for user ${user.id} failed (convergence falls back to the drain): ${cause instanceof Error ? cause.message : String(cause)}`, - ); - } - })(); - } - } - - return c.json(result, 200); - } catch (cause) { - // report-error-ignore: both branches below route through - // reportOnboardingError, this package's reportError wrapper. - if (cause instanceof ProvisionError) { - const status = - cause.code === "signup_not_allowed" ? 403 : cause.errorKind === "transient" ? 503 : 500; - const userMessage = - cause.code === "signup_not_allowed" - ? "Sign-ups aren't open for this account yet. Contact your workspace admin for access." - : "Setting up your workbench hit a snag — we're on it. Try again in a moment."; - const envelope = reportOnboardingError(deps.logError ?? deps.log, { - operation: "onboarding_provision", - userAction: `first-login provisioning for user ${user.id}`, - code: cause.code, - userMessage, - cause, - extra: { userId: user.id, code: cause.code }, - }); - return c.json( - { - error: { ...envelope.error, kind: cause.errorKind }, - }, - status, - ); - } - // An unrecognized error is treated as transient — the hub may have - // been momentarily unavailable, and retrying is safe because - // provisioning is idempotent. - const envelope = reportOnboardingError(deps.logError ?? deps.log, { - operation: "onboarding_provision", - userAction: `first-login provisioning for user ${user.id}`, - code: "provisioning_failed", - userMessage: "Setting up your workbench hit a snag — we're on it. Try again in a moment.", - cause, - extra: { userId: user.id }, - }); - return c.json({ error: { ...envelope.error, kind: "transient" as const } }, 503); - } - }); - - // OAuth connect (OpenRouter, Hugging Face): CL-6028 generalized both - // providers' start/callback mechanics into `@corbits/connections`' - // `createOAuthConnectRoutes` — state sealing, PKCE, cookies, rate - // limiting, and the duplicate-callback recovery shape all live there - // now, driven by `CONNECTOR_REGISTRY`'s `openrouter`/`huggingface` - // entries. What stays here, unchanged: persisting the exchanged - // material (`testAndPersistCredential`, the fast half — no probe, no - // workflow deploy), the duplicate-callback recovery lookup - // (`recentlyConnectedCredential`, below), and writing the pending-seed - // row the deferred `/complete-setup` deploy step reads (see - // `./pending-seed.ts`). Every test seam this package's deps already - // exposed (`openrouterConnect`/`huggingfaceConnect` overrides) still - // works — they're threaded into the registry entries' `oauth.exchange` - // below. - - /** - * Adapts `packages/onboarding`'s pre-CL-6028 exchange function shape - * (`{code, codeVerifier} -> {ok, key}|{ok, message}`, still what - * `deps.openrouterConnect.exchange` overrides in tests) onto - * `ConnectorOAuthConfig.exchange`'s generalized shape. - */ - function adaptOpenRouterExchange( - exchange: typeof exchangeCodeForKey = deps.openrouterConnect?.exchange ?? exchangeCodeForKey, - ) { - return async (args: { - code: string; - codeVerifier?: string; - redirectUri: string; - clientId?: string; - }): Promise => { - const result = await exchange({ - code: args.code, - codeVerifier: args.codeVerifier ?? "", - }); - return result.ok ? { ok: true, apiKey: result.key } : result; - }; - } - - function adaptHuggingFaceExchange( - exchange: typeof exchangeHuggingFaceCodeForToken = deps.huggingfaceConnect?.exchange ?? - exchangeHuggingFaceCodeForToken, - ) { - return async (args: { - code: string; - codeVerifier?: string; - redirectUri: string; - clientId?: string; - }): Promise => { - if (args.clientId === undefined) { - return { - ok: false, - message: "huggingface connect is not configured", - }; - } - const result = await exchange({ - code: args.code, - codeVerifier: args.codeVerifier ?? "", - redirectUri: args.redirectUri, - clientId: args.clientId, - }); - if (!result.ok) return result; - return result.expiresAt !== undefined - ? { ok: true, apiKey: result.accessToken, expiresAt: result.expiresAt } - : { ok: true, apiKey: result.accessToken }; - }; - } - - const openrouterDescriptor = CONNECTOR_REGISTRY["openrouter"]; - const huggingfaceDescriptor = CONNECTOR_REGISTRY["huggingface"]; - if (openrouterDescriptor?.oauth === undefined) { - throw new Error("@corbits/connections' registry is missing the openrouter oauth-pkce entry"); - } - if (huggingfaceDescriptor?.oauth === undefined) { - throw new Error("@corbits/connections' registry is missing the huggingface oauth-pkce entry"); - } - // ONLY the two providers onboarding's own first-login flow offers. - // Every other OAuth-capable connector (the GitHub App connect - // included) belongs to the tenant-scoped `connections/oauth` mount in - // `apps/hub` — a `/oauth/github/start` here answers the factory's own - // 404, never a silent fall-through into onboarding's inference-only - // persistence (CL-6394). - const oauthRegistry: Readonly> = { - openrouter: { - ...openrouterDescriptor, - oauth: { - ...openrouterDescriptor.oauth, - exchange: adaptOpenRouterExchange(), - }, - }, - huggingface: { - ...huggingfaceDescriptor, - oauth: { - ...huggingfaceDescriptor.oauth, - exchange: adaptHuggingFaceExchange(), - }, - }, - }; - - /** Everything onboarding's own OAuth mount may ever persist for — - * enforced twice: `oauthRegistry` above keeps any other connector from - * even starting a flow here (a loud 404), and this narrowing refuses - * one that somehow reached persistence anyway, instead of an `as` - * cast letting it fall into inference-only seeding (CL-6394). */ - function onboardingOAuthProvider(connectorId: string): "openrouter" | "huggingface" | undefined { - if (connectorId === "openrouter" || connectorId === "huggingface") { - return connectorId; - } - return undefined; - } - - /** The fast half only — persists the exchanged material, no probe, - * never deploys a workflow. Dispatches to whichever provider's own - * test-seam override (`deps.openrouterConnect`/`deps.huggingfaceConnect`) - * applies, defaulting both to `testAndPersistCredential`. */ - async function connectCredential(args: { - connectorId: string; - userId: string; - userEmail: string; - cookies: string[]; - apiKey: string; - expiresAt?: string; - credentialMetadata?: Record; - }): Promise { - const provider = onboardingOAuthProvider(args.connectorId); - if (provider === undefined) { - return { - kind: "invalid-credential", - message: `onboarding does not connect ${args.connectorId} — use the workbench's own Connections surface`, - }; - } - const impl = - provider === "openrouter" - ? (deps.openrouterConnect?.connectCredential ?? testAndPersistCredential) - : (deps.huggingfaceConnect?.connectCredential ?? testAndPersistCredential); - const connectCredentialArgs = { - api, - cookies: args.cookies, - hubUrl: deps.hubUrl, - userId: args.userId, - userEmail: args.userEmail, - provider, - apiKey: args.apiKey, - pushWorkflow: deps.pushWorkflow, - log: deps.log, - }; - return impl({ - ...connectCredentialArgs, - ...(args.expiresAt !== undefined ? { expiresAt: args.expiresAt } : {}), - ...(args.credentialMetadata !== undefined - ? { credentialMetadata: args.credentialMetadata } - : {}), - }); - } - - async function recentlyConnected(args: { - connectorId: string; - userId: string; - userEmail: string; - cookies: string[]; - withinMs: number; - }): Promise { - const provider = onboardingOAuthProvider(args.connectorId); - if (provider === undefined) return undefined; - return recentlyConnectedCredential(api, args.cookies, { - userId: args.userId, - userEmail: args.userEmail, - provider, - withinMs: args.withinMs, - log: deps.log, - }); - } - - /** Runs only for a connector whose `oauth.deploysDefaultWorkflows` is - * true (both OpenRouter and Hugging Face) — writes the plaintext - * material into the pending-seed store `/complete-setup` reads, so - * the deferred workflow deploy never blocks this redirect. The - * browser gets nothing from this call: no cookie, no ciphertext, only - * the ordinary redirect — see `./pending-seed.ts`'s module comment. */ - async function afterConnected(args: { - c: import("hono").Context; - connectorId: string; - userId: string; - apiKey: string; - tenantId: string; - tenantSlug: string; - principalId: string; - tenantDomain: string; - }): Promise { - const provider = onboardingOAuthProvider(args.connectorId); - if (provider === undefined) { - throw new Error( - `onboarding's pending-seed store only holds its own providers, not ${args.connectorId}`, - ); - } - await deps.pendingSeedStore.put({ - userId: args.userId, - tenantId: args.tenantId, - principalId: args.principalId, - tenantDomain: args.tenantDomain, - provider, - apiKey: args.apiKey, - }); - } - - app.route( - "/oauth", - createOAuthConnectRoutes({ - hubUrl: deps.hubUrl, - log: deps.log, - credentialCipher, - registry: oauthRegistry, - oauthEnv: { - huggingfaceClientId: deps.huggingfaceClientId, - }, - connectCredential, - recentlyConnected, - afterConnected, - defaultReturnPath: "/onboarding", - // The plugins gallery (CL-6090) reuses this same onboarding OAuth - // route for its one-flow connect panel — `/plugins` joins the - // default allowlist rather than widening - // `DEFAULT_RETURN_PATH_ALLOWLIST` itself, per that constant's own - // doc comment. - returnPathAllowlist: [...DEFAULT_RETURN_PATH_ALLOWLIST, "/plugins"], - }), - ); - - app.post("/complete", async (c) => { - const user = c.get("user"); - if (!user) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Sign in to continue.", - }), - 401, - ); - } - - const body: unknown = await c.req.json().catch(() => null); - const parsed = SubmitCredential(body); - if (parsed instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "invalid_request", - userMessage: "Pick a provider and enter a key before connecting.", - }), - 400, - ); - } - - const cookies = cookiesFromHeader(c.req.header("cookie")); - const runTestAndPersistCredential = deps.testAndPersistCredentialFn ?? testAndPersistCredential; - const baseCompleteCredentialArgs = { - api, - cookies, - hubUrl: deps.hubUrl, - userId: user.id, - userEmail: user.email, - provider: parsed.provider, - apiKey: parsed.apiKey, - pushWorkflow: deps.pushWorkflow, - log: deps.log, - }; - // Known once `runTestAndPersistCredential` resolves; a failure - // before that point (the credential itself, say) has no tenant yet. - let tenantId: string | undefined; - try { - // The fast half, and only the fast half (CL-6457): persist the - // credential, seed its catalog, answer. Deploying this bench's - // default workflows is the background drain's job — a request that - // waits on it is the 2+ minute "Connecting…" this route exists to - // never reproduce. - const result = await runTestAndPersistCredential( - parsed.baseURL !== undefined - ? { ...baseCompleteCredentialArgs, baseURLOverride: parsed.baseURL } - : baseCompleteCredentialArgs, - ); - - if (result.kind === "invalid-credential") { - // `result.message` is the provider's own validation verdict - // (e.g. "the key was rejected") — already written in consumer - // language by `testAndPersistCredential`, never raw stack text. - return c.json( - makeErrorEnvelope({ - code: "invalid_credential", - userMessage: result.message, - }), - 422, - ); - } - if (result.kind === "no-personal-bench") { - return c.json( - makeErrorEnvelope({ - code: "no_personal_bench", - userMessage: "No personal bench was found for this account yet. Reload and try again.", - }), - 409, - ); - } - // The credential is durably stored — clear any stale - // needs-attention record for this provider (CL-6092), the same - // clear-on-success rule `@corbits/connections`' own routes - // follow. The credential is proven-durable here whether or not the - // agents have finished deploying. - deps.providerHealth?.clear(result.tenantId, parsed.provider); - tenantId = result.tenantId; - - const status = await provisioningStatus(cookies, result); - if (status.kind === "ready") { - await deps.pendingSeedStore.clear({ - userId: user.id, - tenantId: result.tenantId, - }); - return c.json(status, 200); - } - - // The row is the drain's work item — durable, so a hub that dies - // mid-deploy resumes this bench on its next boot rather than - // stranding it half-provisioned. - await deps.pendingSeedStore.put({ - userId: user.id, - tenantId: result.tenantId, - principalId: result.principalId, - tenantDomain: result.tenantDomain, - provider: parsed.provider, - apiKey: parsed.apiKey, - ...(parsed.baseURL !== undefined ? { baseURLOverride: parsed.baseURL } : {}), - }); - deps.benchProvisioner?.wake(); - return c.json(status, 200); - } catch (cause) { - // Neither `ProvisionError` nor `CliError` messages are safe to show - // 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, { - operation: "onboarding_complete", - userAction: `credential setup for user ${user.id}`, - code: "credential_setup_failed", - userMessage: - "Your key was added, but finishing your workbench setup hit a snag — we're on it. Try again in a moment.", - cause, - ...(tenantId !== undefined ? { tenantId } : {}), - extra: { userId: user.id }, - }); - return c.json({ error: envelope.error }, 500); - } - }); - - // Runs after the onboarding page lands — from a fresh connect - // (`outcome=connected`) or a plain reload — and drives the slow half - // the OAuth callback never runs: deploying the default workflows - // against whichever credential is already on the caller's own - // personal bench. Already-seeded is answered from a single read, no - // pending token required, so a returning fully-set-up account (or a - // second overlapping call once the first finishes) gets the same - // `seeded` answer without redoing any work. `kind: "unseeded"` (200, - // not an error) means there is nothing this call can do yet — no - // pending credential to seed with — and the caller should fall back - // to the ordinary credential step rather than treat it as a failure. - app.post("/complete-setup", async (c) => { - const user = c.get("user"); - if (!user) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Sign in to continue.", - }), - 401, - ); - } - - const cookies = cookiesFromHeader(c.req.header("cookie")); - // Known once the tenant lookup below resolves; a lookup failure - // itself has no tenant yet. - let tenantId: string | undefined; - try { - const expectedSlug = personalTenantSlug(user.email, user.id); - const tenant = await findPersonalTenant(api, cookies, expectedSlug, { - fallbackToFirstPrincipal: true, - }); - if (!tenant) { - return c.json( - makeErrorEnvelope({ - code: "no_personal_bench", - userMessage: "No personal bench was found for this account yet. Reload and try again.", - }), - 409, - ); - } - tenantId = tenant.tenantId; - - const status = await provisioningStatus(cookies, tenant); - if (status.kind === "ready") { - await deps.pendingSeedStore.clear({ - userId: user.id, - tenantId: tenant.tenantId, - }); - return c.json(status, 200); - } - - // Not yet provisioned, and no credential parked to provision with - // — nothing this call can do, and not a failure: the caller falls - // back to the ordinary credential step. - const pending = await deps.pendingSeedStore.read({ - userId: user.id, - tenantId: tenant.tenantId, - }); - if (pending === undefined) return c.json({ kind: "unseeded" }, 200); - - deps.benchProvisioner?.wake(); - return c.json(status, 200); - } catch (cause) { - // report-error-ignore: CL-7234 — reportOnboardingError itself needs - // to call reportError; tracked there rather than at each call site - const envelope = reportOnboardingError(deps.logError ?? deps.log, { - operation: "onboarding_complete_setup", - userAction: `complete-setup for user ${user.id}`, - code: "complete_setup_failed", - userMessage: - "Finishing your workbench setup hit a snag — we're on it. Try again in a moment.", - cause, - ...(tenantId !== undefined ? { tenantId } : {}), - extra: { userId: user.id }, - }); - return c.json({ error: envelope.error }, 500); - } - }); - - // What any surface still waiting on a bench polls for live progress - // (CL-6457) — how many agents are live, how many are still coming. - // Read-only and cheap: it deploys nothing and starts nothing, so a - // client may poll it on a short interval without cost. - app.get("/provisioning-status", async (c) => { - const user = c.get("user"); - if (!user) { - return c.json( - makeErrorEnvelope({ - code: "unauthorized", - userMessage: "Sign in to continue.", - }), - 401, - ); - } - - const query = ProvisioningStatusQuery(c.req.query()); - if (query instanceof type.errors) { - return c.json( - makeErrorEnvelope({ - code: "invalid_request", - userMessage: "Select a workspace to check its agents.", - }), - 400, - ); - } - - const cookies = cookiesFromHeader(c.req.header("cookie")); - // Known once the tenant lookup below resolves; a lookup failure - // itself has no tenant yet. - let tenantId: string | undefined; - try { - const response = await api("GET", "/api/me/principals", undefined, cookies); - const principals = parseAs( - paginatedSchema(PrincipalSummary), - response.data, - "principals response", - ); - const tenant = principals.data.find( - (principal) => principal.tenantId === query.tenantId && principal.status === "active", - ); - if (!tenant) { - return c.json( - makeErrorEnvelope({ - code: "bench_unavailable", - userMessage: "You no longer have access to this workspace. Select another workspace.", - }), - 403, - ); - } - tenantId = tenant.tenantId; - - return c.json(await provisioningStatus(cookies, tenant), 200); - } catch (cause) { - // report-error-ignore: CL-7234 — reportOnboardingError itself needs - // to call reportError; tracked there rather than at each call site - const envelope = reportOnboardingError(deps.logError ?? deps.log, { - operation: "onboarding_provisioning_status", - userAction: `provisioning status for user ${user.id}`, - code: "provisioning_status_failed", - userMessage: "Checking on your agents hit a snag — we're on it. Try again in a moment.", - cause, - ...(tenantId !== undefined ? { tenantId } : {}), - extra: { userId: user.id }, - }); - return c.json({ error: envelope.error }, 500); - } - }); - - return app; -} diff --git a/packages/onboarding/src/schema.ts b/packages/onboarding/src/schema.ts deleted file mode 100644 index c1ceb2b9e8..0000000000 --- a/packages/onboarding/src/schema.ts +++ /dev/null @@ -1,39 +0,0 @@ -// The one product table `@workbench/onboarding` owns: a single pending -// deferred-seed row per (user, tenant), keyed for upsert. Lives in its -// own `onboarding` Postgres schema, never `public` — see -// docs/package-migrations.md. See `./pending-seed.ts` for what the row -// holds and why. -// -// `tenantId` is a hard foreign key into Interchange's own `tenant` table -// (CL-8210) — `hostTenant` below is declared, never migrated, just far -// enough to carry the FK, same pattern as `@corbits/artifacts`'s -// `src/schema.ts`. `userId` is deliberately NOT an FK: it is the -// workbench web app's own better-auth user id (see -// `packages/connections/src/oauth-routes.ts`'s `user.id`), not an -// Interchange principal id, so there is no Interchange row for it to -// reference. -import { pgSchema, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; - -export const onboardingSchema = pgSchema("onboarding"); - -const hostTenant = pgTable("tenant", { id: text("id").primaryKey() }); - -export const pendingSeed = onboardingSchema.table( - "pending_seed", - { - userId: text("user_id").notNull(), - tenantId: text("tenant_id") - .notNull() - .references(() => hostTenant.id, { onDelete: "cascade" }), - provider: text("provider").notNull(), - /** - * `CredentialCipher`-encrypted JSON (`principalId`, `tenantDomain`, - * `apiKey`) — see `./pending-seed.ts` for the AAD discipline. - */ - payload: text("payload").notNull(), - expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), - }, - (table) => [primaryKey({ columns: [table.userId, table.tenantId] })], -); - -export type PendingSeedRow = typeof pendingSeed.$inferSelect; diff --git a/packages/onboarding/src/tenant-seed.ts b/packages/onboarding/src/tenant-seed.ts deleted file mode 100644 index a77cff274d..0000000000 --- a/packages/onboarding/src/tenant-seed.ts +++ /dev/null @@ -1,1246 +0,0 @@ -// Setup-flow tenant seeding, owned by `@workbench/onboarding` (CL-7585): -// The default-workflow set (`DEFAULT_WORKFLOWS`, `CATALOG_WORKFLOWS`), -// the seed grants, skill planting, and -// `seedTenant`, the grants-plus-workflows installer the first-login -// provisioning hook drives. The catalog half (`seedCatalog`, -// `ensureCredential`, `ensureProvider`, `CATALOG_SEEDS`) lives at -// `@corbits/connections/seed-catalog`, and the git-push transport -// (`WorkflowPusher`, `createGitWorkflowPusher`) at -// `@corbits/connections/workflow-push`. -// -// Dropped in the move, on purpose: `CATALOG_TEST_WORKFLOWS` (the -// heartbeat platform-exercise entry) and `EXCLUDED_WORKFLOW_SOURCES` -// were consumed only by the deleted package's own tests — no product -// or suite code outside it referenced either — and -// `NOOP_MODEL_SOURCE` went with the heartbeat entry as its only user. -// The `WORKBENCH_SEED_CATALOG_TEST_WORKFLOWS` opt-in nothing read dies -// with them. -// -// Seeds one already-known tenant with the default workflow set: plants -// the seed grants, then for each default workflow ensures its asset -// exists, pushes its current definition, deploys it, and confirms the -// deployment answers. Validation is part of seeding — a deployment that -// cannot be confirmed is a seed failure, and a run with nothing to seed -// is a failure too. Safe to re-run; every skipped step says so. -// -// Workflow package metadata (automatable, displayName) lives in each -// workflows/*/package.json under `corbits.workflow` and is mirrored in -// `@workbench/templates`. Seed stamps displayName onto the asset so -// the scheduled-workflow picker can show a friendly label without reading package.json. - -import { - AssetResponse, - AssetWithOriginResponse, - GrantResponse, - ModelInfo, - WorkflowRunHealth, - paginatedSchema, -} from "@intx/types"; -import { type } from "arktype"; -import { deriveRunPrincipalId } from "@intx/hub-common"; -import type { InferencePreference } from "@intx/agent"; -import { buildAssistantWorkflow, serializeAssistantWorkflow } from "@corbits/assistant-workflow"; -import { - buildCodeReviewWorkflow, - serializeCodeReviewWorkflow, -} from "@corbits/code-review-workflow"; -import { - buildWorkbenchDigestWorkflow, - serializeWorkbenchDigestWorkflow, -} from "@corbits/workbench-digest-workflow"; -import { buildEchoWorkflow, serializeEchoWorkflow } from "@corbits/echo-workflow"; -import { - buildLast30DaysResearchWorkflow, - serializeLast30DaysResearchWorkflow, -} from "@corbits/last-30-days-research-workflow"; -import { - buildGranolaCallWorkflow, - serializeGranolaCallWorkflow, -} from "@corbits/granola-call-workflow"; -import { - buildMorningBriefWorkflow, - serializeMorningBriefWorkflow, -} from "@corbits/morning-brief-workflow"; -import { - buildExaTopicWatchWorkflow, - serializeExaTopicWatchWorkflow, -} from "@corbits/exa-topic-watch-workflow"; -import { - buildProcessGranolaCallWorkflow, - serializeProcessGranolaCallWorkflow, -} from "@corbits/process-granola-call-workflow"; -import { - buildAttioTaskAgentWorkflow, - serializeAttioTaskAgentWorkflow, -} from "@corbits/attio-task-agent-workflow"; -import { - buildPainPointCollateralWorkflow, - serializePainPointCollateralWorkflow, -} from "@corbits/pain-point-collateral-workflow"; -import { - buildRedditOpportunityScannerWorkflow, - serializeRedditOpportunityScannerWorkflow, -} from "@corbits/reddit-opportunity-scanner-workflow"; -import { - buildCollateralGenerationWorkflow, - serializeCollateralGenerationWorkflow, -} from "@corbits/collateral-generation-workflow"; -import { - buildDiligenceBriefWorkflow, - serializeDiligenceBriefWorkflow, -} from "@corbits/diligence-brief-workflow"; -import { WORKFLOW_CATALOG } from "@corbits/workflows/catalog"; -import { WORKFLOW_SOURCE_ENTRY } from "@corbits/workflows"; -import { - HubApiError, - SidecarUnavailableError, - parseAs, - type ApiCall, -} from "@corbits/hub-api-client"; -import { DEFAULT_SKILLS } from "@corbits/connections/default-skills"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; - -const GIT_TOKEN_TTL_MS = 10 * 60 * 1000; -const ECHO_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const ASSISTANT_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const WORKBENCH_DIGEST_TURN_TIMEOUT_MS = 30 * 1000; -// A research turn fans out across multiple source tools and writes a -// long-form report, so it gets the same generous allowance as the -// other conversational workflows above, not the short catalog-test -// budget. -const LAST_30_DAYS_RESEARCH_TURN_TIMEOUT_MS = 2 * 60 * 1000; -// Matches the conversational default every folded builder in this -// codebase uses: a review turn reads a diff and posts one review, the -// same order of work as a research or assistant turn. -const CODE_REVIEW_TURN_TIMEOUT_MS = 2 * 60 * 1000; -// Same conversational default as the entries above: one mail-triggered -// reasoning turn per run, no multi-step DAG. -const GRANOLA_CALL_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const MORNING_BRIEF_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const EXA_TOPIC_WATCH_TURN_TIMEOUT_MS = 2 * 60 * 1000; -// A transcript-plus-extraction-plus-verification pass over a long call -// can run well past the shortest steps in the catalog (see the -// workflow's own README), so this gets extra headroom. -const PROCESS_GRANOLA_CALL_TURN_TIMEOUT_MS = 5 * 60 * 1000; -const ATTIO_TASK_AGENT_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const PAIN_POINT_COLLATERAL_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const REDDIT_OPPORTUNITY_SCANNER_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const COLLATERAL_GENERATION_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const DILIGENCE_BRIEF_TURN_TIMEOUT_MS = 2 * 60 * 1000; -const RUN_START_TIMEOUT_MS = 30_000; -const RUN_POLL_INTERVAL_MS = 1000; - -const GitTokenMintResponse = type({ id: "string", secret: "string" }); -const WorkflowDeploymentResponse = type({ - id: "string", - tenantId: "string", - definitionAssetId: "string", - status: "string", - createdAt: "string", -}); -const WorkflowRunListResponse = type({ runIds: "string[]" }); -// Post-deployment-dissolution wire shape: the trigger answers with the -// (self-anchored) run id, not a deployment id. -const WorkflowRunTriggerResponse = type({ - runId: "string", - address: "string", - messageId: "string", -}); - -/** - * A provider/model pair a deployed workflow's rendered definition names - * in its inference preferences (`DefaultWorkflow.buildJson`'s second - * argument). What a deployment actually resolves inference against is - * the tenant's catalog offerings, resolved separately by - * `resolveRealSourceOfferingIds` — this type carries no `baseURL`/`apiKey` - * because `seedTenant` never needs either (CL-7461). - */ -export type ModelSource = { - readonly provider: string; - readonly model: string; -}; - -/** - * Whether a deployments-API row counts as live. The wire vocabulary is - * "deployed" / "pending" / failure states (vendor hub-api - * formatAllocationStatus) — there is no "active". This is the ONE - * definition of "already deployed"; every seeded/skip check imports it. - */ -export function isLiveDeploymentStatus(status: string): boolean { - return status === "deployed" || status === "pending"; -} - -export type DefaultWorkflow = { - /** Asset name; lowercase-kebab so the smart-HTTP repo path is clean. */ - assetName: string; - /** Friendly label stamped on the asset at create time. */ - displayName: string; - /** - * True when this workflow is a legitimate Routines-picker candidate - * (schedulable automation). Conversational agents stay false. - */ - automatable: boolean; - /** - * Renders the definition's JSON given the tenant's mail domain and the - * ordered provider/model preferences to deploy against. Takes the bare - * preference list — never a full `ModelSource` — so this same - * function serves both `seedTenant`'s HTTP-deploy path (whose actual - * deploy source is a set of catalog offering ids, resolved separately - * by `ensureDeployment`) and a native in-process deploy path - * (`apps/hub/src/templates/block-workflows.ts`) that only ever has the - * tenant's real, possibly multi-entry inference preferences on hand. - */ - buildJson: (tenantDomain: string, inferencePreferences: readonly InferencePreference[]) => string; - /** - * When true, PUT the authored definition to `stopped` after deploy so - * a native ScheduleTrigger does not fire every tenant at the next - * matching minute. Absent means leave the schema default (`deployed`). - */ - startStopped?: true; -}; - -function catalogDisplayName(assetName: string): string { - return WORKFLOW_CATALOG.find((entry) => entry.assetName === assetName)?.displayName ?? assetName; -} - -function catalogAutomatable(assetName: string): boolean { - return WORKFLOW_CATALOG.find((entry) => entry.assetName === assetName)?.automatable ?? false; -} - -/** - * The asset name of the agent a person actually talks to on a brand-new - * bench — Myra, the setup agent. Named here because deploy ORDER depends - * on it (see `DEFAULT_WORKFLOWS`) and because every surface that asks - * "can this person start yet?" answers by looking for this one asset, - * never by counting the whole set. - */ -export const SETUP_AGENT_ASSET_NAME = "assistant"; - -/** - * The workflow set every real tenant starts with: the general-purpose - * assistant, and nothing else (CL-7074). This is what - * `provisionPersonalTenantIfNeeded` (`@workbench/onboarding`) deploys - * on first login for every real user — growing it is adding an entry - * here, nothing more, but an entry here reaches every signup, so it is - * never the place for a workflow that is not something every person - * needs the moment they land. `echo`, `workbench-digest`, and - * `last-30-days-research` used to live here; a signup paid a git push - * and a sidecar probe for each of them even though nobody asked for - * them. They now live in `CATALOG_WORKFLOWS`, deployable through the - * catalog instantiate route (CL-7073) rather than seeded onto every - * bench. - * - * Order is a product decision, not a formality (CL-6462): `seedTenant` - * deploys this array in sequence at roughly 20s each, and the setup - * agent is the only entry a person needs before they can start talking. - * It goes first so a fresh signup lands in a working conversation in - * seconds while the rest converge behind them; a signup that waited on - * the whole set stared at a progress screen for minutes. - */ -export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ - { - assetName: SETUP_AGENT_ASSET_NAME, - displayName: catalogDisplayName(SETUP_AGENT_ASSET_NAME), - automatable: catalogAutomatable(SETUP_AGENT_ASSET_NAME), - buildJson: (tenantDomain, inferencePreferences) => - serializeAssistantWorkflow( - buildAssistantWorkflow({ - triggerAddress: `${SETUP_AGENT_ASSET_NAME}@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: ASSISTANT_TURN_TIMEOUT_MS, - }), - ), - }, -]; - -/** - * Workflows every real tenant CAN have, but none is deployed by default - * (CL-7074) — a caller deploys one on demand the same way `seedTenant` - * deploys any `DefaultWorkflow`: `ensureWorkflowAsset` → - * `pushWorkflow` → `ensureDeployment`. `CL-7073` is the caller that - * offers these from a catalog/instantiate surface; nothing here reaches - * a bench until something asks for it by name. An asset already - * deployed on an existing bench (a prior seed run, before these moved - * out of `DEFAULT_WORKFLOWS`) is untouched — there is no orphan-retire - * for these entries, on purpose (see `docs/seed-reconciliation.md`). - */ -export const CATALOG_WORKFLOWS: readonly DefaultWorkflow[] = [ - { - assetName: "echo", - displayName: catalogDisplayName("echo"), - automatable: catalogAutomatable("echo"), - buildJson: (tenantDomain, inferencePreferences) => - serializeEchoWorkflow( - buildEchoWorkflow({ - triggerAddress: `echo@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: ECHO_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "workbench-digest", - displayName: catalogDisplayName("workbench-digest"), - automatable: catalogAutomatable("workbench-digest"), - buildJson: (tenantDomain, inferencePreferences) => - serializeWorkbenchDigestWorkflow( - buildWorkbenchDigestWorkflow({ - triggerAddress: `workbench-digest@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: WORKBENCH_DIGEST_TURN_TIMEOUT_MS, - }), - ), - startStopped: true, - }, - { - assetName: "last-30-days-research", - displayName: catalogDisplayName("last-30-days-research"), - automatable: catalogAutomatable("last-30-days-research"), - // Deployed automation, on demand. Seed never POSTs a wrapper row; - // last-30-days-research stays a deployed workflow without a native - // ScheduleTrigger. - buildJson: (tenantDomain, inferencePreferences) => - serializeLast30DaysResearchWorkflow( - buildLast30DaysResearchWorkflow({ - triggerAddress: `last-30-days-research@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: LAST_30_DAYS_RESEARCH_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "code-review", - displayName: catalogDisplayName("code-review"), - automatable: catalogAutomatable("code-review"), - // Deployed automation, on demand, same as every other entry here - // (CL-7073): the instantiate route used to build this one definition - // through its own hardcoded copy in - // `apps/hub/src/templates/block-workflows.ts`; that copy is gone and - // this entry is now the one source of truth for it, same as every - // other catalog workflow. - buildJson: (tenantDomain, inferencePreferences) => - serializeCodeReviewWorkflow( - buildCodeReviewWorkflow({ - triggerAddress: `code-review@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: CODE_REVIEW_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "granola-call", - displayName: catalogDisplayName("granola-call"), - automatable: catalogAutomatable("granola-call"), - buildJson: (tenantDomain, inferencePreferences) => - serializeGranolaCallWorkflow( - buildGranolaCallWorkflow({ - triggerAddress: `granola-call@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: GRANOLA_CALL_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "morning-brief", - displayName: catalogDisplayName("morning-brief"), - automatable: catalogAutomatable("morning-brief"), - buildJson: (tenantDomain, inferencePreferences) => - serializeMorningBriefWorkflow( - buildMorningBriefWorkflow({ - triggerAddress: `morning-brief@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: MORNING_BRIEF_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "exa-topic-watch", - displayName: catalogDisplayName("exa-topic-watch"), - automatable: catalogAutomatable("exa-topic-watch"), - buildJson: (tenantDomain, inferencePreferences) => - serializeExaTopicWatchWorkflow( - buildExaTopicWatchWorkflow({ - triggerAddress: `exa-topic-watch@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: EXA_TOPIC_WATCH_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "process-granola-call", - displayName: catalogDisplayName("process-granola-call"), - automatable: catalogAutomatable("process-granola-call"), - buildJson: (tenantDomain, inferencePreferences) => - serializeProcessGranolaCallWorkflow( - buildProcessGranolaCallWorkflow({ - triggerAddress: `process-granola-call@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: PROCESS_GRANOLA_CALL_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "attio-task-agent", - displayName: catalogDisplayName("attio-task-agent"), - automatable: catalogAutomatable("attio-task-agent"), - buildJson: (tenantDomain, inferencePreferences) => - serializeAttioTaskAgentWorkflow( - buildAttioTaskAgentWorkflow({ - triggerAddress: `attio-task-agent@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: ATTIO_TASK_AGENT_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "pain-point-collateral", - displayName: catalogDisplayName("pain-point-collateral"), - automatable: catalogAutomatable("pain-point-collateral"), - buildJson: (tenantDomain, inferencePreferences) => - serializePainPointCollateralWorkflow( - buildPainPointCollateralWorkflow({ - triggerAddress: `pain-point-collateral@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: PAIN_POINT_COLLATERAL_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "reddit-opportunity-scanner", - displayName: catalogDisplayName("reddit-opportunity-scanner"), - automatable: catalogAutomatable("reddit-opportunity-scanner"), - buildJson: (tenantDomain, inferencePreferences) => - serializeRedditOpportunityScannerWorkflow( - buildRedditOpportunityScannerWorkflow({ - triggerAddress: `reddit-opportunity-scanner@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: REDDIT_OPPORTUNITY_SCANNER_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "collateral-generation", - displayName: catalogDisplayName("collateral-generation"), - automatable: catalogAutomatable("collateral-generation"), - buildJson: (tenantDomain, inferencePreferences) => - serializeCollateralGenerationWorkflow( - buildCollateralGenerationWorkflow({ - triggerAddress: `collateral-generation@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: COLLATERAL_GENERATION_TURN_TIMEOUT_MS, - }), - ), - }, - { - assetName: "diligence-brief", - displayName: catalogDisplayName("diligence-brief"), - automatable: catalogAutomatable("diligence-brief"), - buildJson: (tenantDomain, inferencePreferences) => - serializeDiligenceBriefWorkflow( - buildDiligenceBriefWorkflow({ - triggerAddress: `diligence-brief@${tenantDomain}`, - inferencePreferences, - turnTimeoutMs: DILIGENCE_BRIEF_TURN_TIMEOUT_MS, - }), - ), - }, -]; - -/** - * The asset names of `CATALOG_WORKFLOWS`, strings only (CL-7585): callers - * that name the catalog set without building anything from it (the hub's - * scheduled-routes catalog, the Routines Available list) import this - * rather than `CATALOG_WORKFLOWS` itself, so no per-workflow `buildJson` - * closure crosses into their code. - */ -export const CATALOG_WORKFLOW_ASSET_NAMES: readonly string[] = CATALOG_WORKFLOWS.map( - (workflow) => workflow.assetName, -); - -/** - * The deployable-through-the-catalog-instantiate-route entry for one - * asset name (CL-7073), or `undefined` if none exists. `CATALOG_WORKFLOWS` - * is the one source of truth for "has a source package under - * `workflows/` and can be deployed on demand" — `DEFAULT_WORKFLOWS` - * (seeded already, never re-deployed through this path) answers - * `undefined` here on purpose. - */ -export function deployableCatalogWorkflow(assetName: string): DefaultWorkflow | undefined { - return CATALOG_WORKFLOWS.find((workflow) => workflow.assetName === assetName); -} - -// The grants the deploy, trigger, and run-listing routes gate on, -// planted at the wildcard scope the authz glob matcher resolves -// against any concrete deployment (the deployment id is minted at -// deploy time, so a concrete resource cannot be planted up front). -export const SEED_GRANTS: readonly { resource: string; action: string }[] = [ - { resource: "workflow:*", action: "create" }, - { resource: "workflow:*", action: "read" }, - { resource: "workflow-run:*", action: "manage" }, - { resource: "workflow-run:*", action: "read" }, - // Workflow-definition read/update (stop a startStopped deploy, list - // definitions) and extra workflow-run verbs none of the grants above - // cover. Those routes gate on their own resource/action pairs. - { resource: "workflow-definition:*", action: "read" }, - { resource: "workflow-definition:*", action: "update" }, - { resource: "workflow-run:*", action: "create" }, - { resource: "workflow-run:*", action: "write" }, - // CL-6346 moved the room routes (post a message, read-state, typing, - // reactions, pins, the live stream) off `workflow-run:` and onto - // `room:`. The two grants above used to be what carried a - // non-owner principal through those routes; without the room pair - // beside them the rename leaves every seeded principal that is not a - // wildcard owner unable to read or write its own workbenches. - { resource: "room:*", action: "read" }, - { resource: "room:*", action: "write" }, - // CL-6465: the eval-run read routes (`GET .../eval-runs/runs`, - // `GET .../eval-runs/runs/:runId`) gate on this resource. - { resource: "eval-run:*", action: "read" }, - // Agent-authored workflows (`@corbits/workflows`'s `./authoring` - // `author`/`republish` routes): a seeded principal was never granted - // "create"/"write" on "asset:*" before, because no workflow-run write - // surface checked it — every prior workflow-run write route (skills, - // capabilities, agent-directory) either wrote as the "hub" RepoStore - // principal with no grant-store check, or was scoped narrowly enough - // to skip one (see those packages' own CL-6085-referencing doc - // comments). Authoring a workflow asset is deploying executable code, - // not a markdown skill, so this is the one write surface that adds a - // real per-write grant check rather than following that precedent. - // These are the SAME resource/verb the human-session asset routes - // already gate on (`requireGrant("asset:*", "create")` and the - // tarball routes' `requireGrant(idResource("asset", "assetId"), - // "write")`) — extended to workflow-run principals, not a new grant - // vocabulary. - { resource: "asset:*", action: "create" }, - { resource: "asset:*", action: "write" }, - // Firm memory (CL-8186): `@corbits/memory`'s mounted routes gate on - // `requireGrant("memory", action)` (see its `routes/deps.ts` - // `grantGuard`) — "add" for `POST .../memory/add`, "search" for both - // `POST .../memory/search` and `GET .../memory/list` (list reuses the - // search grant; there is no separate "list" action). Grants never - // inherit across tenants, so every workflow-run principal needs its - // own pair, exactly like every other tenant-scoped resource above. - { resource: "memory", action: "add" }, - { resource: "memory", action: "search" }, -]; - -// Grants planted ONLY on Myra's own run principal (see -// `plantAssistantRunPrincipalGrants`), not the tenant's shared principal -// `SEED_GRANTS` above reconciles — these let Myra mint/revoke grants -// for her own specialist agents without handing -// every seeded principal in the tenant that same reach. -// -// `@corbits/access-tools`' workflow-run-authenticated routes -// (`list_principals`, `list_grants`, `grant_access`, `revoke_access`): -// read on both, plus create/manage on grants so Myra can mint and revoke -// the scoped grants she stands up for her own specialist agents. -// `principal:*`/`grant:*` on the shared principal would let it read and -// mint grants for every principal in the tenant, not just its own -// specialist agents. -// -// CL-7588: catalog administration is UI-only — writes go through the -// tenant-admin catalog routes (`vendor/intx/hub-api/src/routes/ -// {models,model-providers,model-offerings}.ts`) behind a browser session, -// never through a workflow child. Myra's run principal keeps the catalog -// reads her `list_model_concepts`/`pick_models`/`estimate_run_cost` tools -// resolve through, and nothing more: a tenant-wide grant would let every -// seeded principal administer the catalog, not just Myra. -const ASSISTANT_RUN_PRINCIPAL_GRANTS: readonly { - resource: string; - action: string; -}[] = [ - { resource: "principal:*", action: "read" }, - { resource: "grant:*", action: "read" }, - { resource: "grant:*", action: "create" }, - { resource: "grant:*", action: "manage" }, - { resource: "model:*", action: "read" }, - { resource: "model-provider:*", action: "read" }, - { resource: "model-offering:*", action: "read" }, -]; - -// The grants table has no unique constraint and the create route is a -// plain insert, so a re-run would accumulate duplicate rows; check for -// an equivalent grant first and report the skip. -async function plantGrant( - api: ApiCall, - cookies: string[], - args: { - tenantId: string; - principalId: string; - resource: string; - action: string; - }, - log: (line: string) => void, -): Promise { - const listed = await api( - "GET", - `/api/tenants/${args.tenantId}/grants?principalId=${encodeURIComponent(args.principalId)}&resource=${encodeURIComponent(args.resource)}&limit=200`, - undefined, - cookies, - ); - const grants = parseAs(paginatedSchema(GrantResponse), listed.data, "grants response").data; - const existing = grants.find( - (g) => - g.resource === args.resource && - g.action === args.action && - g.effect === "allow" && - g.principalId === args.principalId, - ); - if (existing) { - log(`grant ${args.resource}/${args.action} already exists (skipped)`); - return; - } - const created = await api( - "POST", - `/api/tenants/${args.tenantId}/grants`, - { - principalId: args.principalId, - resource: args.resource, - action: args.action, - effect: "allow", - origin: "creator", - }, - cookies, - ); - if (created.status !== 201) { - throw new HubApiError( - `the hub rejected the ${args.resource}/${args.action} grant with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - log(`granted ${args.resource}/${args.action}`); -} - -/** - * Reconciles one tenant's grants to exactly `SEED_GRANTS`: every declared - * grant present, nothing beyond it. This is the one path that plants - * `SEED_GRANTS` — `seedTenant`'s full seed and `provisionPersonalTenantIfNeeded`'s - * already-seeded short-circuit both call this instead of each owning - * their own pass, so a grant added to `SEED_GRANTS` after a tenant was - * first seeded reaches that tenant the next time either path runs, not - * only on a brand-new signup. `plantGrant` is itself idempotent (it - * checks for an equivalent grant before creating one), so reconciling - * twice never duplicates a row. - */ -export async function reconcileSeedGrants( - api: ApiCall, - cookies: string[], - tenantId: string, - principalId: string, - log: (line: string) => void, -): Promise { - for (const grant of SEED_GRANTS) { - await plantGrant( - api, - cookies, - { tenantId, principalId, resource: grant.resource, action: grant.action }, - log, - ); - } -} - -/** - * Plants `ASSISTANT_RUN_PRINCIPAL_GRANTS` (the `@corbits/access-tools` - * and `@corbits/catalog-tools` grants) on Myra's OWN run principal, not - * the tenant's shared principal (CL-7467, CL-7468). `deriveRunPrincipalId` - * is the same deterministic `(tenantId, runId)` derivation - * `@intx/hub-api`'s `workflow-run-trigger` uses to mint a deployment's - * run principal on its first materialized trigger — since a deployment's - * top-level run id never changes across a relaunch, this stays stable - * for the life of the deployment, unlike a fresh per-invocation run id. - * Only called once `confirmDeploymentAnswers` has actually triggered the - * assistant deployment (`confirmDeployments: true`), because that - * trigger is what commits the run-principal row this grant references; - * the connect flow's `confirmDeployments: false` path defers this until - * the tenant's next `workbench seed` run finds a confirmed deployment. - */ -async function plantAssistantRunPrincipalGrants( - api: ApiCall, - cookies: string[], - args: { tenantId: string; deploymentId: string }, - log: (line: string) => void, -): Promise { - const runPrincipalId = await deriveRunPrincipalId(args.tenantId, args.deploymentId); - for (const grant of ASSISTANT_RUN_PRINCIPAL_GRANTS) { - await plantGrant( - api, - cookies, - { - tenantId: args.tenantId, - principalId: runPrincipalId, - resource: grant.resource, - action: grant.action, - }, - log, - ); - } -} - -async function plantDefaultSkills( - api: ApiCall, - cookies: string[], - tenantId: string, - log: (line: string) => void, -): Promise { - for (const skill of DEFAULT_SKILLS) { - const existing = await api( - "GET", - `/api/tenants/${tenantId}/skills/${encodeURIComponent(skill.name)}`, - undefined, - cookies, - ); - if (existing.status === 200) { - log(`skill ${skill.name} already exists (skipped)`); - continue; - } - const created = await api( - "POST", - `/api/tenants/${tenantId}/skills`, - { - name: skill.name, - description: skill.description, - body: skill.body, - scope: "tenant", - }, - cookies, - ); - if (created.status === 409) { - // The by-name GET above missed a row that the create route still - // considers a conflict (an inherited/other-scope row, or a race - // with a concurrent seed pass) — "already exists" is a skip, not - // a fatal error, exactly like every other seed step's 409. - log(`skill ${skill.name} already exists (skipped)`); - continue; - } - if (created.status === 404) { - // Stock Interchange cutover (hub fd3a43e2): the skills mount is - // gone, so the plant endpoint 404s with the rest. Bounded and - // loud — log through the pipeline (swallowing is acceptable here; - // the reconcile re-read below reports these pins "blocked" on its - // own) and stop trying further default skills. Every other status - // keeps its old meaning. - log( - `[seed] default skill "${skill.name}" unavailable: POST /api/tenants/${tenantId}/skills returned 404 (skills surface removed by the stock cutover); skipping remaining default skills`, - ); - return; - } - if (created.status !== 201) { - throw new HubApiError( - `the hub rejected the default skill "${skill.name}" with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - log(`seeded skill ${skill.name}`); - } -} - -async function ensureWorkflowAsset( - api: ApiCall, - cookies: string[], - args: { tenantId: string; assetName: string; displayName: string }, - log: (line: string) => void, -): Promise { - const created = await api( - "POST", - `/api/tenants/${args.tenantId}/assets`, - { - kind: "workflow", - name: args.assetName, - displayName: args.displayName, - }, - cookies, - ); - if (created.status === 201) { - const asset = parseAs(AssetResponse, created.data, "asset response"); - log(`created workflow asset ${args.assetName} (${args.displayName})`); - return asset.id; - } - if (created.status !== 409) { - throw new HubApiError( - `the hub rejected creation of workflow asset ${args.assetName} with status ${created.status}: ${JSON.stringify(created.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - - const listed = await api( - "GET", - `/api/tenants/${args.tenantId}/assets?kind=workflow&inherited=false`, - undefined, - cookies, - ); - const assets = parseAs(AssetWithOriginResponse.array(), listed.data, "assets response"); - const existing = assets.find((a) => a.name === args.assetName); - if (!existing) { - throw new HubApiError( - `workflow asset ${args.assetName} reported a name conflict but is not listable on the bench`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - log(`workflow asset ${args.assetName} already exists (skipped)`); - return existing.id; -} - -async function mintGitToken(api: ApiCall, cookies: string[], tenantId: string): Promise { - const minted = await api( - "POST", - `/api/tenants/${tenantId}/git-tokens`, - { - // Unique per run: a token's secret is only returned at mint, so a - // re-run can never reuse the previous token — and an active token - // with the same (user, name) makes the mint violate the hub's - // uniqueness index. The short TTL reaps the leftovers. - name: `workbench-seed-push-${crypto.randomUUID().slice(0, 8)}`, - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + GIT_TOKEN_TTL_MS).toISOString(), - }, - cookies, - ); - if (minted.status !== 201) { - throw new HubApiError( - `the hub refused to mint a git token for the workflow push (status ${minted.status}): ${JSON.stringify(minted.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - return parseAs(GitTokenMintResponse, minted.data, "git token response").secret; -} - -async function listRunIds( - api: ApiCall, - cookies: string[], - args: { tenantId: string; deploymentId: string }, -): Promise { - const runs = await api( - "GET", - `/api/tenants/${args.tenantId}/workflows/${args.deploymentId}/runs`, - undefined, - cookies, - ); - return parseAs(WorkflowRunListResponse, runs.data, "runs response").runIds; -} - -/** - * Whether a "deployed" deployment's run is actually routable right now. - * `GET .../runs/:runId/health` reads `sidecarRouter.getRoutableAddresses()` - * — the hub's in-memory table binding an agent address to the specific - * connected sidecar socket that owns it — so this is a live check, not a - * read of the persisted `workflow_run.status` column the caller already - * has. That column survives a hub/sidecar restart; the routing table - * does not, so a "deployed" row can answer `false` here forever until - * something redeploys it. 404 (run never existed) and 410 (run stopped) - * both count as not routable: either way, nothing this deployment id - * names can be reused. - */ -async function isDeploymentRoutable( - api: ApiCall, - cookies: string[], - tenantId: string, - deploymentId: string, -): Promise { - const health = await api( - "GET", - `/api/tenants/${tenantId}/workflows/runs/${deploymentId}/health`, - undefined, - cookies, - ); - if (health.status === 404 || health.status === 410) return false; - if (health.status !== 200) { - throw new HubApiError( - `the hub answered deployment ${deploymentId}'s health check with status ${health.status}: ${JSON.stringify(health.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - return parseAs(WorkflowRunHealth, health.data, "run health response").liveness === "ok"; -} - -async function ensureDeployment( - api: ApiCall, - cookies: string[], - args: { - tenantId: string; - assetId: string; - assetName: string; - commitSha: string; - sourceOfferingIds: readonly string[]; - defaultSourceOfferingId: string; - }, - log: (line: string) => void, -): Promise { - const listed = await api( - "GET", - `/api/tenants/${args.tenantId}/workflows/deployments`, - undefined, - cookies, - ); - const deployments = parseAs( - WorkflowDeploymentResponse.array(), - listed.data, - "deployments response", - ); - const active = deployments.find( - (d) => d.definitionAssetId === args.assetId && isLiveDeploymentStatus(d.status), - ); - if (active) { - if (await isDeploymentRoutable(api, cookies, args.tenantId, active.id)) { - log(`workflow ${args.assetName} already deployed as ${active.id} (skipped)`); - return active.id; - } - // The DB row survives a stack restart; the in-memory sidecar - // routing table that binds an address to a live process does not. - // Restart the hub and sidecar and every previously "deployed" - // workflow_run still reads "deployed" while nothing routes its - // address. Skipping here would just move the same 409 - // `confirmDeploymentAnswers` hits below one step earlier — redeploy - // fresh instead of trusting a status column that outlived the - // process it described. - log( - `workflow ${args.assetName}'s deployment ${active.id} is stale (its sidecar is gone); redeploying`, - ); - } - - const deployed = await api( - "POST", - `/api/tenants/${args.tenantId}/workflows/deployments`, - { - source: { - kind: "asset", - assetId: args.assetId, - package: { format: "source", commitSha: args.commitSha }, - }, - entry: WORKFLOW_SOURCE_ENTRY, - sourceOfferingIds: args.sourceOfferingIds, - defaultSourceOfferingId: args.defaultSourceOfferingId, - }, - cookies, - ); - if (deployed.status === 502) { - throw new SidecarUnavailableError( - `the hub could not deploy workflow ${args.assetName}: the sidecar is unavailable (${JSON.stringify(deployed.data)})`, - "start the stack (`bun run dev` runs the hub and sidecar together), wait for the sidecar to connect, then re-run: workbench seed", - ); - } - if (deployed.status !== 201) { - throw new HubApiError( - `the hub rejected deployment of workflow ${args.assetName} with status ${deployed.status}: ${JSON.stringify(deployed.data)}`, - "re-run: workbench seed (it re-pushes the workflow definition); if this persists, check the hub logs for the hydration failure", - ); - } - const deployment = parseAs(WorkflowDeploymentResponse, deployed.data, "deployment response"); - log(`deployed workflow ${args.assetName} as ${deployment.id}`); - return deployment.id; -} - -const DiscoveredModelsResponse = ModelInfo.array(); - -/** - * Lists the offering ids visible to a tenant by flattening the platform's - * own model discovery (`GET .../models`): every model resolved for the - * tenant after inheritance, shadowing, and disable suppression, with each - * model's offerings in resolution order. Seeding a tenant whose catalog is - * inherited rather than directly owned (e.g. a sub-tenant under a - * parent that already carries a real provider key) needs this resolved - * view, not the tenant-owned-only `GET .../catalog/offerings`. - */ -async function listDiscoveredModelOfferings( - api: ApiCall, - cookies: string[], - tenantId: string, -): Promise<{ id: string; priority: number }[]> { - const listed = await api("GET", `/api/tenants/${tenantId}/models`, undefined, cookies); - const models = parseAs(DiscoveredModelsResponse, listed.data, "discovered models response"); - return models.flatMap((model) => - model.offerings.map((offering) => ({ - id: offering.offeringId, - priority: offering.priority, - })), - ); -} - -/** - * Resolves the catalog offering ids a workflow deploys against: every - * offering visible to the tenant (owned or inherited from an ancestor), - * ordered by priority ascending, with the lowest-priority offering as - * the default — the SAME rule `apps/hub/src/index.ts`'s - * `workflowDeployer.deploy` and `packages/chat/src/platform-adapter.ts`'s - * `catalogOfferings` apply in-process via `listVisibleOfferings`. - * - * Throws when nothing is left to deploy against: "seeded but not - * launchable" (`seedCatalog` with no credential) is a valid catalog - * state, but a workflow deploy that names zero sources is not a - * lesser success, it is the failure this function exists to catch - * before the hub's own `sourceOfferingIds` validation would. - */ -async function resolveRealSourceOfferingIds( - api: ApiCall, - cookies: string[], - tenantId: string, -): Promise<{ - sourceOfferingIds: readonly string[]; - defaultSourceOfferingId: string; -}> { - const offerings = (await listDiscoveredModelOfferings(api, cookies, tenantId)).sort( - (a, b) => a.priority - b.priority, - ); - const defaultSourceOfferingId = offerings[0]?.id; - if (defaultSourceOfferingId === undefined) { - throw new HubApiError( - "this tenant has no catalog offerings to deploy against — nothing is launchable yet", - "seed the tenant's catalog with a real provider key (or a placeholder credential for a keyless dev/CI run), then re-run: workbench seed", - ); - } - return { - sourceOfferingIds: offerings.map((offering) => offering.id), - defaultSourceOfferingId, - }; -} - -async function confirmDeploymentAnswers( - api: ApiCall, - cookies: string[], - args: { - tenantId: string; - deploymentId: string; - assetName: string; - sleep: (ms: number) => Promise; - timeoutMs: number; - intervalMs: number; - }, - log: (line: string) => void, -): Promise { - const before = new Set( - await listRunIds(api, cookies, { - tenantId: args.tenantId, - deploymentId: args.deploymentId, - }), - ); - - const triggered = await api( - "POST", - `/api/tenants/${args.tenantId}/workflows/${args.deploymentId}/mail`, - { content: "workbench seed validation: confirm this deployment answers" }, - cookies, - ); - if (triggered.status === 409) { - throw new HubApiError( - `deployment ${args.deploymentId} of workflow ${args.assetName} is deployed but its address is not routable — the sidecar that hosts it is not connected`, - "start the stack (`bun run dev` runs the hub and sidecar together), wait for the sidecar to connect, then re-run: workbench seed", - ); - } - if (triggered.status !== 202) { - throw new HubApiError( - `the validation trigger for workflow ${args.assetName} was rejected with status ${triggered.status}: ${JSON.stringify(triggered.data)}`, - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - } - parseAs(WorkflowRunTriggerResponse, triggered.data, "trigger response"); - - const attempts = Math.max(1, Math.ceil(args.timeoutMs / args.intervalMs)); - for (let attempt = 0; attempt < attempts; attempt += 1) { - const runIds = await listRunIds(api, cookies, { - tenantId: args.tenantId, - deploymentId: args.deploymentId, - }); - const started = runIds.find((id) => !before.has(id)); - if (started !== undefined) { - log(`confirmed workflow ${args.assetName}: run ${started} started`); - return; - } - await args.sleep(args.intervalMs); - } - - throw new HubApiError( - `deployment ${args.deploymentId} of workflow ${args.assetName} accepted the validation trigger but no run started within ${Math.round(args.timeoutMs / 1000)}s`, - "check the sidecar logs for the run failure, fix it, then re-run: workbench seed", - ); -} - -/** The tenant identity `seedTenant` needs; already known to a caller - * (such as the first-login provisioning hook) that just minted the - * tenant. */ -export type SeedTenant = { - tenantId: string; - principalId: string; - domain: string; -}; - -export type SeedTenantArgs = { - api: ApiCall; - cookies: string[]; - hubUrl: string; - tenant: SeedTenant; - /** - * The provider/model pair a real (non-noop-pinned) workflow's rendered - * definition names in its inference preferences. The deploy itself - * resolves inference from the tenant's own catalog offerings - * (`resolveRealSourceOfferingIds`), not from this pair — a tenant with - * no catalog offerings still fails clearly at deploy time even if this - * pair happens to name a real model. - */ - model: ModelSource; - pushWorkflow: WorkflowPusher; - log: (line: string) => void; - workflows?: readonly DefaultWorkflow[]; - sleep?: (ms: number) => Promise; - runStartTimeoutMs?: number; - runPollIntervalMs?: number; - /** - * Whether each deployment is confirmed by triggering a real mail - * message and waiting for a run to start. Defaults to `true` — the - * behavior `workbench seed` and the operator-key first-login hook - * rely on, where a deployment nothing ever confirmed is treated as a - * seed failure. A self-served connect flow (`@workbench/onboarding`'s - * `completeCredentialSetup`) passes `false`: the key was already - * proven with a free, auth-only probe before seeding started, so - * spending the connecting user's own (possibly credit-less) balance - * on a real inference call here would only re-litigate a question - * already answered, at the user's expense. - */ - confirmDeployments?: boolean; -}; - -/** - * Plants the seed grants and deploys — and, unless told not to, - * confirms — every default workflow for one already-known tenant. A - * caller that already holds an authenticated session and a freshly - * created tenant (the first-login provisioning hook, in particular) - * seeds it without re-authenticating or re-resolving the tenant by - * slug. - * - * Grants + workflows, then prune of leftover preset routine wrappers. - * 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 { - api, - cookies, - hubUrl, - tenant, - model, - log, - workflows = DEFAULT_WORKFLOWS, - confirmDeployments = true, - } = args; - const sleep = args.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))); - const timeoutMs = args.runStartTimeoutMs ?? RUN_START_TIMEOUT_MS; - const intervalMs = args.runPollIntervalMs ?? RUN_POLL_INTERVAL_MS; - - if (workflows.length === 0) { - throw new HubApiError( - "the default workflow set is empty; seeding zero workflows is a failure, not a success", - "restore the default workflow set (packages/onboarding/src/tenant-seed.ts) before running: workbench seed", - ); - } - - await reconcileSeedGrants(api, cookies, tenant.tenantId, tenant.principalId, log); - - await plantDefaultSkills(api, cookies, tenant.tenantId, log); - - // Resolved lazily and cached across the loop below: most seed runs - // deploy several workflows against the same tenant catalog state, so - // this fetches the tenant's real offerings at most once, however many - // workflows ask for them. - let realOfferings: - | { sourceOfferingIds: readonly string[]; defaultSourceOfferingId: string } - | undefined; - - let confirmed = 0; - for (const workflow of workflows) { - const workflowModel = model; - - realOfferings ??= await resolveRealSourceOfferingIds(api, cookies, tenant.tenantId); - const { sourceOfferingIds, defaultSourceOfferingId } = realOfferings; - - const assetId = await ensureWorkflowAsset( - api, - cookies, - { - tenantId: tenant.tenantId, - assetName: workflow.assetName, - displayName: workflow.displayName, - }, - log, - ); - - const tokenSecret = await mintGitToken(api, cookies, tenant.tenantId); - const pushed = await args.pushWorkflow({ - remoteUrl: `${hubUrl}/api/tenants/${tenant.tenantId}/assets/workflow/${workflow.assetName}.git`, - tokenSecret, - workflowJson: workflow.buildJson(tenant.domain, [ - { provider: workflowModel.provider, model: workflowModel.model }, - ]), - packageName: `@workbench-seed/${workflow.assetName}`, - }); - log( - pushed.outcome === "pushed" - ? `pushed the workflow source package for ${workflow.assetName}` - : `workflow source for ${workflow.assetName} already current (skipped)`, - ); - - const deploymentId = await ensureDeployment( - api, - cookies, - { - tenantId: tenant.tenantId, - assetId, - assetName: workflow.assetName, - commitSha: pushed.commitSha, - sourceOfferingIds, - defaultSourceOfferingId, - }, - log, - ); - - if (workflow.startStopped === true) { - // Stock Interchange cutover: the old - // `PUT .../agent-definitions/:id/status` stop surface is gone and - // stock `@intx/hub-api` exposes no deployment-stop route, so a - // `startStopped` seed can no longer stop its definition after - // deploy. The flag is honored best-effort: schedule suppression - // now lives in the workflow source itself. Logged, not thrown — - // a seed must not fail because a stop surface no longer exists. - log( - `startStopped requested for ${workflow.assetName} but stock composition exposes no stop route; leaving it deployed`, - ); - } - if (confirmDeployments) { - await confirmDeploymentAnswers( - api, - cookies, - { - tenantId: tenant.tenantId, - deploymentId, - assetName: workflow.assetName, - sleep, - timeoutMs, - intervalMs, - }, - log, - ); - if (workflow.assetName === SETUP_AGENT_ASSET_NAME) { - await plantAssistantRunPrincipalGrants( - api, - cookies, - { tenantId: tenant.tenantId, deploymentId }, - log, - ); - } - } - confirmed += 1; - } - - if (confirmed !== workflows.length) { - throw new HubApiError( - `only ${confirmed} of ${workflows.length} default workflows were confirmed`, - "check the failures reported above, fix them, then re-run: workbench seed", - ); - } - - log( - confirmDeployments - ? `seed complete: ${confirmed} workflow(s) deployed and confirmed` - : `seed complete: ${confirmed} workflow(s) deployed`, - ); -} diff --git a/packages/onboarding/test/bench-provisioning.test.ts b/packages/onboarding/test/bench-provisioning.test.ts deleted file mode 100644 index 1b5c1aebff..0000000000 --- a/packages/onboarding/test/bench-provisioning.test.ts +++ /dev/null @@ -1,381 +0,0 @@ -// The background provisioner: the only thing in the system that -// converges a connected bench (CL-6457, doc-driven since CL-7584). -// Connect persists a credential and returns; this reconciles the bench -// against the tenant desired-state document afterwards, and has to hold -// three properties no HTTP request can hold for it — it is idempotent (a -// second pass over an already-seeded bench installs nothing), -// convergent (a half-provisioned bench finishes on a later pass), and -// restart-safe (a fresh process with nothing in memory picks up -// whatever the crashed one left in the pending-seed table). -import { describe, expect, test } from "bun:test"; -import { createEnvKeyCredentialCipher } from "@intx/crypto"; -import type { CredentialCipher } from "@intx/types"; -import { DEFAULT_WORKFLOWS } from "../src/tenant-seed"; -import { createBenchProvisioner, type BenchProvisionerDeps } from "../src/bench-provisioning"; -import { - createInMemoryPendingSeedStore, - PENDING_SEED_SCAN_LIMIT, - type PendingSeed, - type PendingSeedStore, -} from "../src/pending-seed"; - -type ReconcileArgsLike = Parameters>[0]; - -const TEST_KEY = Buffer.alloc(32, 33); -function testCipher(): CredentialCipher { - return createEnvKeyCredentialCipher(TEST_KEY); -} - -const SEED: PendingSeed = { - userId: "user_1", - tenantId: "ten_1", - principalId: "prn_1", - tenantDomain: "user-1.bench.local", - provider: "anthropic", - apiKey: "sk-ant-connected", -}; - -const ALL_WORKFLOWS = DEFAULT_WORKFLOWS.map((workflow) => workflow.assetName); - -function readyReport(tenantId: string) { - return { - tenantId, - ready: true as const, - pins: ALL_WORKFLOWS.map((name) => ({ - name, - kind: "workflow" as const, - status: "installed" as const, - })), - }; -} - -function blockedReport(tenantId: string) { - return { - tenantId, - ready: false as const, - pins: [ - { - name: "assistant", - kind: "workflow" as const, - status: "blocked" as const, - }, - ], - }; -} - -function failedReport(tenantId: string) { - return { - tenantId, - ready: false as const, - pins: [ - { - name: "assistant", - kind: "workflow" as const, - status: "failed" as const, - }, - ], - }; -} - -/** A provisioner wired entirely to fakes: no hub, no sidecar, no git - * push. `deployedByTenant` is the fake bench state the reconcile seam - * reads and writes, so idempotence and convergence are observable as - * call counts rather than asserted by inspection. */ -function harness(overrides: Partial & { store?: PendingSeedStore } = {}) { - const store = overrides.store ?? createInMemoryPendingSeedStore(testCipher()); - const deployedByTenant = new Map(); - const calls = { - reconcile: 0, - sessionFor: 0, - }; - const logged: string[] = []; - - const deps: BenchProvisionerDeps = { - api: (async () => { - throw new Error("the fakes below stand in for every hub call"); - }) as unknown as BenchProvisionerDeps["api"], - hubUrl: "https://bench.example.com", - store, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - sessionFor: async () => { - calls.sessionFor += 1; - return ["better-auth.session_token=minted"]; - }, - log: (line) => logged.push(line), - reconcileFn: async (args) => { - calls.reconcile += 1; - deployedByTenant.set(args.tenant.tenantId, [...ALL_WORKFLOWS]); - return readyReport(args.tenant.tenantId); - }, - ...overrides, - }; - - return { - provisioner: createBenchProvisioner(deps), - store, - calls, - logged, - deployedByTenant, - }; -} - -describe("createBenchProvisioner", () => { - test("converges a freshly connected bench and clears its pending row", async () => { - const { provisioner, store, calls, deployedByTenant } = harness(); - await store.put(SEED); - - const report = await provisioner.drainOnce(); - - expect(calls.reconcile).toBe(1); - expect(deployedByTenant.get("ten_1")).toEqual(ALL_WORKFLOWS); - expect(report).toMatchObject({ converged: 1, truncated: false }); - expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toBeUndefined(); - }); - - test("is idempotent: a second drain over an already-seeded bench installs nothing", async () => { - const { provisioner, store, calls } = harness(); - await store.put(SEED); - - await provisioner.drainOnce(); - // The row is gone after the first pass, so re-arm it the way a - // duplicate connect would and prove the reconcile short-circuits. - await store.put(SEED); - await provisioner.drainOnce(); - - expect(calls.reconcile).toBe(2); - }); - - test("reconciling a bench someone else already converged reports ready and still clears the row", async () => { - const { provisioner, store } = harness(); - await store.put(SEED); - - const report = await provisioner.drainOnce(); - - expect(report).toMatchObject({ converged: 1, truncated: false }); - expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toBeUndefined(); - }); - - test("a blocked bench keeps its row and converges on a later pass", async () => { - let attempt = 0; - const { provisioner, store } = harness({ - reconcileFn: async (args) => { - attempt += 1; - if (attempt > 1) { - return readyReport(args.tenant.tenantId); - } - return blockedReport(args.tenant.tenantId); - }, - }); - await store.put(SEED); - - const first = await provisioner.drainOnce(); - expect(first).toMatchObject({ pending: 1 }); - // The row survives precisely so the next pass can finish the job. - expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual(SEED); - - const second = await provisioner.drainOnce({ ignoreBackoff: true }); - expect(second).toMatchObject({ converged: 1 }); - expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toBeUndefined(); - }); - - test("a reconcile failure leaves the row for the next pass rather than losing the bench", async () => { - let attempt = 0; - const { provisioner, store, logged } = harness({ - reconcileFn: async (args) => { - attempt += 1; - if (attempt === 1) throw new Error("sidecar exploded"); - return readyReport(args.tenant.tenantId); - }, - }); - await store.put(SEED); - - const first = await provisioner.drainOnce(); - expect(first).toMatchObject({ failed: 1 }); - expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual(SEED); - expect(logged.some((line) => line.includes("sidecar exploded"))).toBe(true); - - const second = await provisioner.drainOnce({ ignoreBackoff: true }); - expect(second).toMatchObject({ converged: 1 }); - }); - - test("a failed-pin report counts as failed for backoff purposes", async () => { - const { provisioner, store } = harness({ - reconcileFn: async (args) => failedReport(args.tenant.tenantId), - }); - await store.put(SEED); - - await provisioner.drainOnce(); - const held = await provisioner.drainOnce(); - - expect(held).toMatchObject({ deferred: 1 }); - expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual(SEED); - }); - - test("restart-resume: a fresh provisioner with empty memory finishes what a crashed one left behind", async () => { - const store = createInMemoryPendingSeedStore(testCipher()); - // The "crashed" process: it wrote the row, then died before its - // deploy ever ran. - await store.put(SEED); - - // A brand-new provisioner — no in-flight map, no backoff state, no - // knowledge of the connect that wrote the row — boots and drains. - const { provisioner, calls, deployedByTenant } = harness({ store }); - const report = await provisioner.drainOnce(); - - expect(calls.reconcile).toBe(1); - expect(deployedByTenant.get("ten_1")).toEqual(ALL_WORKFLOWS); - expect(report).toMatchObject({ converged: 1, truncated: false }); - }); - - test("overlapping drains never double-provision the same bench", async () => { - let inFlight = 0; - let maxConcurrent = 0; - let reconciles = 0; - const { provisioner, store } = harness({ - reconcileFn: async (args) => { - reconciles += 1; - inFlight += 1; - maxConcurrent = Math.max(maxConcurrent, inFlight); - await new Promise((resolve) => setTimeout(resolve, 20)); - inFlight -= 1; - return readyReport(args.tenant.tenantId); - }, - }); - await store.put(SEED); - - await Promise.all([provisioner.drainOnce(), provisioner.drainOnce()]); - - expect(maxConcurrent).toBe(1); - expect(reconciles).toBe(1); - }); - - test("a blocked report holds the pending row as pending, not failed", async () => { - const { provisioner, store } = harness({ - reconcileFn: async (args) => blockedReport(args.tenant.tenantId), - }); - await store.put(SEED); - - const report = await provisioner.drainOnce(); - - expect(report).toMatchObject({ pending: 1, failed: 0 }); - expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual(SEED); - }); - - test("a bench whose user has no mintable session is left alone, not dropped", async () => { - const { provisioner, store, calls } = harness({ - sessionFor: async () => undefined, - }); - await store.put(SEED); - - const report = await provisioner.drainOnce(); - - expect(calls.reconcile).toBe(0); - expect(report).toMatchObject({ failed: 1 }); - expect(await store.read({ userId: "user_1", tenantId: "ten_1" })).toEqual(SEED); - }); - - test("a permanently-failing bench's backoff is reclaimed once its row is gone, not only on success", async () => { - // Simulates the CL-7233 orphan case: the pending_seed row disappears - // (TTL-expiry or otherwise) while the bench is still backed off from - // repeated failures — the retry-hold bookkeeping must not survive - // the row that justified it. - const { provisioner, store } = harness({ - reconcileFn: async () => { - throw new Error("sidecar still down"); - }, - }); - await store.put(SEED); - - const first = await provisioner.drainOnce(); - expect(first).toMatchObject({ failed: 1 }); - // Confirm the hold is actually in effect before the row disappears — - // otherwise this test would pass for the wrong reason. - const stillBackedOff = await provisioner.drainOnce(); - expect(stillBackedOff).toMatchObject({ deferred: 1 }); - - // The row is gone by some path other than this provisioner's own - // convergence (an admin action, or read-time TTL expiry elsewhere). - await store.clear({ userId: "user_1", tenantId: "ten_1" }); - const afterRowGone = await provisioner.drainOnce(); - expect(afterRowGone).toMatchObject({ - converged: 0, - pending: 0, - failed: 0, - deferred: 0, - }); - - // A brand-new connect for the same user/tenant must not inherit the - // dead bench's backoff — without eviction, this would come back - // deferred instead of attempted. - await store.put(SEED); - const freshAttempt = await provisioner.drainOnce(); - expect(freshAttempt).toMatchObject({ failed: 1 }); - }); - - test("drains every waiting bench in one tick, not just the first", async () => { - const { provisioner, store, calls } = harness(); - await store.put(SEED); - await store.put({ ...SEED, userId: "user_2", tenantId: "ten_2" }); - - const report = await provisioner.drainOnce(); - - expect(calls.reconcile).toBe(2); - expect(report).toMatchObject({ converged: 2, truncated: false }); - }); - - test("DrainReport.truncated is true when more due rows remain behind this tick's page", async () => { - const seen = new Set(); - const { provisioner, store } = harness({ - reconcileFn: async (args: ReconcileArgsLike) => { - seen.add(args.tenant.tenantId); - return blockedReport(args.tenant.tenantId); - }, - }); - for (let index = 0; index < PENDING_SEED_SCAN_LIMIT + 3; index += 1) { - await store.put({ - ...SEED, - userId: `user_${index}`, - tenantId: `ten_${index}`, - }); - } - - const first = await provisioner.drainOnce(); - expect(first.truncated).toBe(true); - expect(first.pending).toBe(PENDING_SEED_SCAN_LIMIT); - expect(seen.size).toBe(PENDING_SEED_SCAN_LIMIT); - - const second = await provisioner.drainOnce(); - expect(second.truncated).toBe(false); - expect(second.pending).toBe(3); - expect(seen.size).toBe(PENDING_SEED_SCAN_LIMIT + 3); - }); - - test("rows past the scan limit still get a drain pass across ticks, even when the first page never converges", async () => { - const seen = new Set(); - const { provisioner, store } = harness({ - reconcileFn: async (args: ReconcileArgsLike) => { - seen.add(args.tenant.tenantId); - return blockedReport(args.tenant.tenantId); - }, - }); - const total = PENDING_SEED_SCAN_LIMIT + 3; - for (let index = 0; index < total; index += 1) { - await store.put({ - ...SEED, - userId: `user_${index}`, - tenantId: `ten_${index}`, - }); - } - - await provisioner.drainOnce(); - await provisioner.drainOnce(); - - expect(seen.size).toBe(total); - for (let index = 0; index < total; index += 1) { - expect(seen.has(`ten_${index}`)).toBe(true); - } - }); -}); diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts deleted file mode 100644 index 86f3673717..0000000000 --- a/packages/onboarding/test/complete-credential.test.ts +++ /dev/null @@ -1,2208 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import { CATALOG_SEEDS } from "@corbits/connections/catalog-seed-data"; -import { SETUP_AGENT_ASSET_NAME } from "../src/tenant-seed"; -import { type ApiCall, type ApiResult, SidecarUnavailableError } from "@corbits/hub-api-client"; -import { - completeCredentialSetup, - ensureSeeded, - findPersonalTenant, - modelSourceFor, - testAndPersistCredential, -} from "../src/complete-credential"; - -// A key that was never probed (CL-6123 dropped the onboarding probe) -// must not be proven with a billed call either: seedTenant runs with -// confirmDeployments: false so a valid, credit-less account is not -// turned into a false "setup failed" by a workflow trigger it never -// asked for. -function expectNoConfirmation(seedTenantCalls: { confirmDeployments?: boolean }[]) { - expect(seedTenantCalls).toHaveLength(1); - expect(seedTenantCalls[0]?.confirmDeployments).toBe(false); -} - -const TENANT_ID = "ten_personal"; -const PRINCIPAL_ID = "prn_personal"; -const TENANT_SLUG = "alice-user1"; - -const noopPush: WorkflowPusher = async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), -}); - -// Stubs for the provider/credential half of the shared persist-and-seed -// sequence (CL-6394) — paired with every stubbed `seedCatalogFn` so a -// test that fakes the catalog side never dials the real credential -// endpoints either. -const stubPersistFns = { - ensureProviderFn: async (_api: unknown, _cookies: string[], args: { name: string }) => - `prv_${args.name}`, - ensureCredentialFn: async (_api: unknown, _cookies: string[], args: { providerId: string }) => - `cred_${args.providerId}`, -}; - -function collector() { - const lines: string[] = []; - return { lines, log: (line: string) => lines.push(line) }; -} - -// Local copy of the single handshake fixture this suite needs (inlined -// when the shared test helpers were deleted with CL-7585): a "pristine" -// scheduled assistant deployment whose definition handshake reports zero -// revisions and zero chat history. -const PRISTINE_TIMESTAMP = "2026-01-01T00:00:00.000Z"; -const PRISTINE_DIGEST_DEFINITION_ID = "wfd_digests-pristine"; - -function pristineScheduledDefinitionHandshake( - method: string, - path: string, - tenantId: string, -): ApiResult | undefined { - const definitionMatch = /^\/v1\/definitions\/([^/]+)$/.exec(path); - if (definitionMatch && method === "GET") { - return { - status: 200, - data: { - digest: { - definitionId: PRISTINE_DIGEST_DEFINITION_ID, - revision: 0, - historyCount: 0, - mailboxCount: 0, - chatCount: 0, - }, - }, - cookies: [], - }; - } - const deploymentMatch = /^\/v1\/deployments\/([^/]+)$/.exec(path); - if (deploymentMatch && method === "GET") { - return { - status: 200, - data: { - deployment: { - id: deploymentMatch[1], - tenantId, - kind: "scheduled", - workerId: null, - processId: null, - definitionId: PRISTINE_DIGEST_DEFINITION_ID, - channelKeys: [], - createdAt: PRISTINE_TIMESTAMP, - updatedAt: PRISTINE_TIMESTAMP, - }, - }, - cookies: [], - }; - } - return undefined; -} - -function seedHandshake(method: string, path: string): ApiResult | undefined { - return pristineScheduledDefinitionHandshake(method, path, TENANT_ID); -} - -// `ensureDeployment` resolves a real (non-noop-pinned) workflow's deploy -// source from the tenant's native model discovery (CL-7588). A test that -// never seeds its own catalog (it stubs `seedCatalogFn` instead) still -// needs one discoverable offering for the "assistant" workflow's own -// deploy to succeed — this fixed single row is that. -function fixedDiscoveredModelsResponse() { - return { - status: 200, - data: [ - { - id: "mdl_1", - canonicalName: "model-1", - displayName: null, - description: null, - offerings: [ - { - offeringId: "off_1", - providerId: "mpr_1", - providerName: "anthropic", - plugin: "anthropic", - priority: 0, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - }, - ], - cookies: [], - }; -} - -function principalsResponse() { - return { - status: 200, - data: { - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "Alice's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }, - cookies: [], - }; -} - -function tenantResponse() { - return { - status: 200, - data: { - id: TENANT_ID, - name: "Alice's workbench", - slug: TENANT_SLUG, - domain: "alice-user1.bench.local", - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; -} - -function resolvedCatalogResponse( - models: { - canonicalName: string; - providerName: string; - priority?: number; - capabilities?: string[]; - }[], -) { - return { - status: 200, - data: models.map((model, index) => ({ - id: `mdl_${index}`, - canonicalName: model.canonicalName, - offerings: [ - { - offeringId: `off_${index}`, - providerId: "cpv_1", - providerName: model.providerName, - plugin: "openai-compatible", - priority: model.priority ?? 0, - deploymentTags: [], - capabilities: model.capabilities ?? [], - pricing: [], - }, - ], - })), - cookies: [], - }; -} - -function ownedCatalogModelsResponse(canonicalNames: string[]) { - return { - status: 200, - data: { - data: canonicalNames.map((canonicalName, index) => ({ - id: `own_mdl_${index}`, - tenantId: TENANT_ID, - canonicalName, - disabled: false, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - })), - nextCursor: null, - }, - cookies: [], - }; -} - -describe("modelSourceFor", () => { - test("every other provider resolves to its curated default and never calls the hub", async () => { - const api: ApiCall = (async () => { - throw new Error("must not be called for a fixed curated provider"); - }) as ApiCall; - expect(await modelSourceFor(api, ["session=abc"], TENANT_ID, "anthropic")).toEqual({ - provider: "anthropic", - model: "claude-sonnet-5", - }); - }); - - // CL-6366 red/green: a fresh instance whose live catalog carries only - // llama3.2 (the curated default name is absent entirely) still resolves - // to what the instance actually serves, never a pin it can't answer for. - test("ollama resolves to the instance's own seeded model, never the curated pin it may lack", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return resolvedCatalogResponse([ - { - canonicalName: "llama3.2", - providerName: "ollama", - capabilities: ["plain-text", "plain-text-streaming"], - }, - ]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - expect(await modelSourceFor(api, ["session=abc"], TENANT_ID, "ollama")).toEqual({ - provider: "openai-compatible", - model: "llama3.2", - }); - }); - - // CL-6366 red/green: an alphabetical-first embedding pull must never - // win over a completion-capable model just because its name sorts - // first — real capability data, not name order, decides. - test("ollama prefers a completion-capable model over an alphabetically-earlier embedding model", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return resolvedCatalogResponse([ - { - canonicalName: "all-minilm", - providerName: "ollama", - capabilities: [], - }, - { - canonicalName: "llama3.2", - providerName: "ollama", - capabilities: ["plain-text", "plain-text-streaming"], - }, - ]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await modelSourceFor(api, ["session=abc"], TENANT_ID, "ollama"); - expect(result.model).toBe("llama3.2"); - }); - - test("ollama prefers the curated name among completion-capable candidates when the instance offers it", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return resolvedCatalogResponse([ - { - canonicalName: "llama3.2", - providerName: "ollama", - capabilities: ["plain-text"], - }, - { - canonicalName: "gpt-oss:20b", - providerName: "ollama", - capabilities: ["plain-text"], - }, - ]); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return ownedCatalogModelsResponse(["llama3.2", "gpt-oss:20b"]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await modelSourceFor(api, ["session=abc"], TENANT_ID, "ollama"); - expect(result.model).toBe("gpt-oss:20b"); - }); - - // CL-7185: discovery includes the inherited curated name, but this - // tenant's own catalog only lists the model the instance actually - // pulled. Prefer the owned name, never the inherited pin. - test("ollama prefers a tenant-owned model over an inherited curated name the instance does not own", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return resolvedCatalogResponse([ - { - canonicalName: "gpt-oss:20b", - providerName: "ollama", - capabilities: ["plain-text"], - }, - { - canonicalName: "llama3.2", - providerName: "ollama", - capabilities: ["plain-text"], - }, - ]); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return ownedCatalogModelsResponse(["llama3.2"]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await modelSourceFor(api, ["session=abc"], TENANT_ID, "ollama"); - expect(result.model).toBe("llama3.2"); - }); - - // CL-7185: an empty owned list means inherit-only — keep discovery's - // curated preference rather than failing open to "no candidates". - test("ollama keeps the discovery pick when the tenant owns no catalog models", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return resolvedCatalogResponse([ - { - canonicalName: "gpt-oss:20b", - providerName: "ollama", - capabilities: ["plain-text"], - }, - { - canonicalName: "llama3.2", - providerName: "ollama", - capabilities: ["plain-text"], - }, - ]); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return ownedCatalogModelsResponse([]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await modelSourceFor(api, ["session=abc"], TENANT_ID, "ollama"); - expect(result.model).toBe("gpt-oss:20b"); - }); -}); - -describe("completeCredentialSetup", () => { - // CL-6123: onboarding no longer probes a submitted key before storing - // it — any key, wrong or right, is stored and seeded immediately. A - // wrong key is caught later, the first time it's actually dialed, and - // surfaces in-chat through the credential-error + "Fix this - // connection" flow (CL-6092), not here. - test("an unproven key is stored and seeded immediately, with no probe call", async () => { - const seedCatalogCalls: unknown[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-never-probed", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - seedTenantFn: async () => {}, - }); - - expect(result.kind).toBe("seeded"); - expect(seedCatalogCalls).toHaveLength(1); - }); - - test("a valid key with no personal bench yet is reported, not guessed at", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-good", - pushWorkflow: noopPush, - log: collector().log, - }); - - expect(result).toEqual({ kind: "no-personal-bench" }); - }); - - test("a valid Anthropic key seeds the catalog, the tenant, and reports what ran", async () => { - const seedCatalogCalls: unknown[] = []; - const seedTenantCalls: { - model: { provider: string; model: string }; - confirmDeployments?: boolean; - }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-good", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - seedTenantFn: async (args) => { - seedTenantCalls.push(args as never); - }, - }); - - expect(result).toEqual({ - kind: "seeded", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - workflows: ["assistant"], - }); - expect(seedCatalogCalls).toHaveLength(1); - expect(seedTenantCalls[0]?.model.provider).toBe("anthropic"); - expectNoConfirmation(seedTenantCalls); - }); - - test("a valid OpenAI key seeds its own catalog and routines", async () => { - const seedCatalogCalls: unknown[] = []; - const seedTenantCalls: { model: { provider: string; model: string } }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "openai", - apiKey: "sk-good", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - seedTenantFn: async (args) => { - seedTenantCalls.push(args as never); - }, - }); - - expect(result).toEqual({ - kind: "seeded", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - workflows: ["assistant"], - }); - expect(seedCatalogCalls).toHaveLength(1); - expect(seedTenantCalls).toHaveLength(1); - expect(seedTenantCalls[0]?.model.provider).toBe("openai"); - }); - - test("a valid Groq key seeds the shared OpenAI-compatible catalog and routines", async () => { - const seedCatalogCalls: { provider?: string }[] = []; - const seedTenantCalls: { model: { provider: string; model: string } }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "groq", - apiKey: "gsk-good", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args as never); - return { hasCompletionCapableModel: true }; - }, - seedTenantFn: async (args) => { - seedTenantCalls.push(args as never); - }, - }); - - expect(result).toEqual({ - kind: "seeded", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - workflows: ["assistant"], - }); - expect(seedCatalogCalls).toHaveLength(1); - expect(seedCatalogCalls[0]?.provider).toBe("groq"); - expect(seedTenantCalls).toHaveLength(1); - expect(seedTenantCalls[0]?.model.provider).toBe("openai-compatible"); - }); - - test("a Hugging Face connect token stores its expiry as oauth_token credential metadata", async () => { - const seedCatalogCalls: { - provider?: string; - credentialType?: string; - credentialMetadata?: Record; - }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "huggingface", - apiKey: "hf_oauth_minted", - credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args as never); - return { hasCompletionCapableModel: true }; - }, - seedTenantFn: async () => {}, - }); - - expect(result.kind).toBe("seeded"); - expect(seedCatalogCalls).toEqual([ - expect.objectContaining({ - provider: "huggingface", - credentialType: "oauth_token", - credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - }), - ]); - }); - - test("an OAuth exchange's expiresAt reaches the credential row's column, never just metadata", async () => { - // The onboarding mount's share of CL-7508: the exchanged expiry is - // the first-class field persistConnectorCredential writes to the - // `expires_at` COLUMN serving-time refresh keys on — the metadata - // fold only types the row `oauth_token` and must not be the expiry's - // only ride. - const ensureCredentialArgs: Record[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - const baseArgs = { - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "huggingface" as const, - apiKey: "hf_oauth_minted", - pushWorkflow: noopPush, - log: collector().log, - seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), - seedTenantFn: async () => {}, - }; - - await completeCredentialSetup({ - ...baseArgs, - credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - expiresAt: "2026-08-13T20:00:00.000Z", - ...stubPersistFns, - ensureCredentialFn: async ( - _api: unknown, - _cookies: string[], - args: { providerId: string }, - ) => { - ensureCredentialArgs.push(args as unknown as Record); - return `cred_${args.providerId}`; - }, - }); - expect(ensureCredentialArgs).toEqual([ - expect.objectContaining({ - type: "oauth_token", - expiresAt: "2026-08-13T20:00:00.000Z", - metadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - }), - ]); - - // A pasted key (no exchange, no expiry) must omit the column key - // entirely — under exactOptionalPropertyTypes an explicit undefined - // is a different request shape than an absent field. - ensureCredentialArgs.length = 0; - await completeCredentialSetup({ - ...baseArgs, - apiKey: "hf_pasted_key", - ...stubPersistFns, - ensureCredentialFn: async ( - _api: unknown, - _cookies: string[], - args: { providerId: string }, - ) => { - ensureCredentialArgs.push(args as unknown as Record); - return `cred_${args.providerId}`; - }, - }); - expect(ensureCredentialArgs[0]).not.toHaveProperty("expiresAt"); - }); - - test("a reconnect against an expired Hugging Face credential rotates it and still reports seeded", async () => { - const TIMESTAMP = "2026-01-01T00:00:00.000Z"; - const staleCredentialRow = () => ({ - id: "cre_old", - tenantId: TENANT_ID, - providerId: "prv_1", - name: "Hugging Face", - type: "oauth_token", - status: "expired", - metadata: { expiresAt: "2026-01-01T00:00:00.000Z" }, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }); - let patchCalls = 0; - let patchBody: unknown; - - const api: ApiCall = async (method, path, body) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) { - return { - status: 201, - data: { - id: "prv_1", - tenantId: TENANT_ID, - name: "huggingface", - plugin: "openai-compatible", - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) { - return { status: 409, data: { error: "name taken" }, cookies: [] }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/credentials`) { - return { - status: 200, - data: { data: [staleCredentialRow()], nextCursor: null }, - cookies: [], - }; - } - if (method === "PATCH" && path === `/api/tenants/${TENANT_ID}/credentials/cre_old`) { - patchCalls += 1; - patchBody = body; - return { - status: 200, - data: { - ...staleCredentialRow(), - status: "active", - metadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return { - status: 201, - data: { - id: "mdl_1", - tenantId: TENANT_ID, - canonicalName: "deepseek-ai/DeepSeek-V4-Flash", - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - return { - status: 201, - data: { - id: "cpv_1", - tenantId: TENANT_ID, - name: "huggingface", - plugin: "openai-compatible", - baseURL: "https://router.huggingface.co/v1", - credentialId: "cre_old", - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - return { - status: 201, - data: { - id: "off_1", - tenantId: TENANT_ID, - modelId: "mdl_1", - providerId: "cpv_1", - priority: 0, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return { - status: 200, - data: [], - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "huggingface", - apiKey: "hf_freshly_minted_token", - credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - pushWorkflow: noopPush, - log: collector().log, - // The real seedCatalog runs here (not mocked) so the rotation - // actually happens through ensureCredential; only the workflow - // deploy side is stubbed, since it is not this defect's concern. - seedTenantFn: async () => {}, - }); - - expect(result.kind).toBe("seeded"); - expect(patchCalls).toBe(1); - expect(patchBody).toEqual({ - secret: "hf_freshly_minted_token", - status: "active", - metadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - }); - }); - - test("a valid key seeds every default workflow without any deploy-confirmation trigger call", async () => { - // No `seedTenantFn` override here: the real `seedTenant` runs, so - // this is the actual code path a connect callback drives end to - // end (only `seedCatalogFn` is stubbed — the catalog side is - // unrelated to this defect and already covered above). A fake - // `api` that throws on any workflow run-listing or mail-trigger - // call proves `completeCredentialSetup` never asks seedTenant to - // confirm a deployment by triggering real inference — the fix for - // the false "setup failed" a credit-less but valid key used to get. - const TIMESTAMP = "2026-01-01T00:00:00.000Z"; - const assets: { name: string; id: string }[] = []; - const deployments: { definitionAssetId: string; id: string }[] = []; - const api: ApiCall = async (method, path, body) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return fixedDiscoveredModelsResponse(); - } - 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`) { - const name = (body as { name: string }).name; - const id = `ast_${name}`; - assets.push({ name, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: name, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: assets.map((a) => ({ - id: a.id, - tenantId: TENANT_ID, - kind: "workflow", - name: a.name, - displayName: a.name, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - origin: { tenantId: TENANT_ID, direct: true }, - })), - 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: [] }; - } - const handshake = seedHandshake(method, path); - if (handshake) return handshake; - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - return { - status: 200, - data: deployments.map((d) => ({ - id: d.id, - tenantId: TENANT_ID, - definitionAssetId: d.definitionAssetId, - status: "deployed", - createdAt: TIMESTAMP, - })), - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - const assetId = (body as { source: { assetId: string } }).source.assetId; - const id = `dep_${assetId}`; - deployments.push({ definitionAssetId: assetId, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - definitionAssetId: assetId, - status: "deployed", - createdAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "GET" && path.includes("/workflows/") && path.endsWith("/runs")) { - throw new Error( - `unexpected run-listing call for an unproven, never-triggered key: ${method} ${path}`, - ); - } - if (method === "POST" && path.includes("/workflows/") && path.endsWith("/mail")) { - throw new Error( - `unexpected workflow trigger call for an unproven, never-triggered key: ${method} ${path}`, - ); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-good", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), - }); - - expect(result.kind).toBe("seeded"); - if (result.kind === "seeded") { - expect(result.workflows).toEqual(["assistant"]); - } - }); - - test("a second credential save for the same account is idempotent — no duplicate assets, deployments, grants, or catalog rows", async () => { - // Two credential saves racing (or a person resubmitting the same - // provider) must not double-seed: seedCatalog and seedTenant's - // ensure-then-create helpers already tolerate a 409 on the second - // create by listing the existing row instead, and this proves that - // tolerance holds end to end through `completeCredentialSetup`, - // called twice, with the real (non-mocked) seedCatalog and - // seedTenant driving a stateful fake hub. - const TIMESTAMP = "2026-01-01T00:00:00.000Z"; - type Row = { name: string; id: string }; - const grants: { resource: string; action: string }[] = []; - const assets: Row[] = []; - const deployments: { definitionAssetId: string; id: string }[] = []; - const catalogModels: Row[] = []; - const catalogProviders: Row[] = []; - const catalogOfferings: { - id: string; - modelId: string; - providerId: string; - priority: number; - }[] = []; - const providers: (Row & { apiBaseUrl: string | null })[] = []; - const credentials: Row[] = []; - let assetCreatePosts = 0; - let deploymentCreatePosts = 0; - let catalogModelCreatePosts = 0; - let catalogProviderCreatePosts = 0; - let catalogOfferingCreatePosts = 0; - let credentialCreatePosts = 0; - let credentialRotatePatches = 0; - - const api: ApiCall = async (method, path, body) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/grants?`)) { - return { - status: 200, - data: { - data: grants.map((g, index) => ({ - id: `grt_${index}`, - tenantId: TENANT_ID, - resource: g.resource, - action: g.action, - effect: "allow", - principalId: PRINCIPAL_ID, - origin: "creator", - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - })), - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - const g = body as { resource: string; action: string }; - grants.push({ resource: g.resource, action: g.action }); - return { status: 201, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - const name = (body as { name: string }).name; - const existing = assets.find((a) => a.name === name); - if (existing) return { status: 409, data: {}, cookies: [] }; - assetCreatePosts += 1; - const id = `ast_${name}`; - assets.push({ name, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: name, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: assets.map((a) => ({ - id: a.id, - tenantId: TENANT_ID, - kind: "workflow", - name: a.name, - displayName: a.name, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - origin: { tenantId: TENANT_ID, direct: true }, - })), - 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: [] }; - } - const handshake = seedHandshake(method, path); - if (handshake) return handshake; - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - return { - status: 200, - data: deployments.map((d) => ({ - id: d.id, - tenantId: TENANT_ID, - definitionAssetId: d.definitionAssetId, - status: "deployed", - createdAt: TIMESTAMP, - })), - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - deploymentCreatePosts += 1; - const assetId = (body as { source: { assetId: string } }).source.assetId; - const id = `dep_${assetId}`; - deployments.push({ definitionAssetId: assetId, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - definitionAssetId: assetId, - status: "deployed", - createdAt: TIMESTAMP, - }, - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/runs/`) && - path.endsWith("/health") - ) { - return { - status: 200, - data: { liveness: "ok", readiness: "ok", lastCheckedAt: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/providers`) { - const { name, apiBaseUrl = null } = body as { - name: string; - apiBaseUrl?: string; - }; - const existing = providers.find((p) => p.name === name); - if (existing) return { status: 409, data: {}, cookies: [] }; - const id = `prv_${name}`; - providers.push({ name, id, apiBaseUrl }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - name, - plugin: "anthropic", - apiBaseUrl, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "PATCH" && path === `/api/tenants/${TENANT_ID}/providers/prv_anthropic`) { - const provider = providers.find((p) => p.id === "prv_anthropic"); - if (provider === undefined) throw new Error("provider must exist before updating it"); - provider.apiBaseUrl = (body as { apiBaseUrl: string }).apiBaseUrl; - return { - status: 200, - data: { - id: provider.id, - tenantId: TENANT_ID, - name: provider.name, - plugin: "anthropic", - apiBaseUrl: provider.apiBaseUrl, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/providers?inherited=false`) { - return { - status: 200, - data: { - data: providers.map((p) => ({ - id: p.id, - tenantId: TENANT_ID, - name: p.name, - plugin: "anthropic", - apiBaseUrl: p.apiBaseUrl, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - })), - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/credentials`) { - const name = (body as { name: string }).name; - const existing = credentials.find((c) => c.name === name); - if (existing) return { status: 409, data: {}, cookies: [] }; - credentialCreatePosts += 1; - const id = `cre_${name}`; - credentials.push({ name, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - providerId: "prv_anthropic", - name, - type: "api_key", - status: "active", - metadata: null, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/credentials`) { - return { - status: 200, - data: { - data: credentials.map((c) => ({ - id: c.id, - tenantId: TENANT_ID, - providerId: "prv_anthropic", - name: c.name, - type: "api_key", - status: "active", - metadata: null, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - })), - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "PATCH" && path.startsWith(`/api/tenants/${TENANT_ID}/credentials/`)) { - // The second `completeCredentialSetup` call is itself an - // explicit user submission (`testAndPersistCredential` always - // sets `credentialVerified: true`, no probe required — CL-6123), - // so `ensureCredential` rotates the existing api_key row instead - // of leaving it untouched — this is the fix under test here, - // not a duplicate creation, so `credentialCreatePosts` still - // stays at 1. - credentialRotatePatches += 1; - const id = path.split("/").pop() as string; - return { - status: 200, - data: { - id, - tenantId: TENANT_ID, - providerId: "prv_anthropic", - name: "Anthropic", - type: "api_key", - status: "active", - metadata: null, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/models`) { - const canonicalName = (body as { canonicalName: string }).canonicalName; - const existing = catalogModels.find((m) => m.name === canonicalName); - if (existing) return { status: 409, data: {}, cookies: [] }; - catalogModelCreatePosts += 1; - const id = `mdl_${canonicalName}`; - catalogModels.push({ name: canonicalName, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - canonicalName, - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/catalog/models`) { - return { - status: 200, - data: { - data: catalogModels.map((m) => ({ - id: m.id, - tenantId: TENANT_ID, - canonicalName: m.name, - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - })), - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - const name = (body as { name: string }).name; - const existing = catalogProviders.find((p) => p.name === name); - if (existing) return { status: 409, data: {}, cookies: [] }; - catalogProviderCreatePosts += 1; - const id = `cpv_${name}`; - catalogProviders.push({ name, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - name, - plugin: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "cre_anthropic-default", - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/catalog/providers`) { - return { - status: 200, - data: { - data: catalogProviders.map((p) => ({ - id: p.id, - tenantId: TENANT_ID, - name: p.name, - plugin: "anthropic", - baseURL: "https://api.anthropic.com", - credentialId: "cre_anthropic-default", - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - })), - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - const b = body as { - modelId: string; - providerId: string; - priority: number; - }; - const existing = catalogOfferings.find( - (o) => o.modelId === b.modelId && o.providerId === b.providerId, - ); - if (existing) return { status: 409, data: {}, cookies: [] }; - catalogOfferingCreatePosts += 1; - const id = `off_${catalogOfferings.length + 1}`; - catalogOfferings.push({ - id, - modelId: b.modelId, - providerId: b.providerId, - priority: b.priority, - }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - modelId: b.modelId, - providerId: b.providerId, - priority: b.priority, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/catalog/offerings`) { - return { - status: 200, - data: { - data: catalogOfferings.map((o) => ({ - id: o.id, - tenantId: TENANT_ID, - modelId: o.modelId, - providerId: o.providerId, - priority: o.priority, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - })), - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return { - status: 200, - data: catalogOfferings.map((o) => ({ - id: o.modelId, - canonicalName: o.modelId, - displayName: null, - description: null, - offerings: [ - { - offeringId: o.id, - providerId: o.providerId, - providerName: "anthropic", - plugin: "anthropic", - priority: o.priority, - deploymentTags: [], - capabilities: [], - pricing: [], - }, - ], - })), - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const submitCredential = () => - completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-good", - pushWorkflow: noopPush, - log: collector().log, - }); - - const first = await submitCredential(); - expect(first.kind).toBe("seeded"); - const second = await submitCredential(); - expect(second.kind).toBe("seeded"); - - // Every ensure-then-create helper hit its 409 branch on the second - // pass and listed the row it already created on the first — nothing - // was ever created twice. Anthropic's curated seed is several models - // (one POST each for model and offering); the rest of the chain is - // still a single provider/credential row. - const anthropicCatalogSize = CATALOG_SEEDS.anthropic.models.length; - expect(assetCreatePosts).toBe(1); - expect(deploymentCreatePosts).toBe(1); - expect(catalogModelCreatePosts).toBe(anthropicCatalogSize); - expect(catalogProviderCreatePosts).toBe(1); - expect(catalogOfferingCreatePosts).toBe(anthropicCatalogSize); - expect(credentialCreatePosts).toBe(1); - // The second pass is itself an explicit submission and rotates the - // existing row rather than leaving it untouched (the CL-6103 fix, - // updated by CL-6123 to no longer require a probe first). - expect(credentialRotatePatches).toBe(1); - expect(assets.length).toBe(1); - expect(deployments.length).toBe(1); - }); - - test("a pasted key with no metadata stays an ordinary api_key credential", async () => { - const seedCatalogCalls: { credentialType?: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "huggingface", - apiKey: "hf_pasted_pat", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args as never); - return { hasCompletionCapableModel: true }; - }, - seedTenantFn: async () => {}, - }); - - expect(seedCatalogCalls).toEqual([expect.objectContaining({ credentialType: "api_key" })]); - }); - - // CL-6264: the credential-persist half already succeeded (the tenant, - // principal, and credential are real and durable) by the time the - // deploy step hits a sidecar-unavailable failure, so `/complete`'s own - // route can write a pending-seed row for the retry path — it needs - // `principalId`/`tenantDomain` threaded all the way out here to do it. - test("a sidecar-unavailable deploy still reports the durable tenant identity, not just deployed/pending", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { status: 200, data: [], cookies: [] }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - return { status: 200, data: [], cookies: [] }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await completeCredentialSetup({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-good", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), - seedTenantFn: async () => { - throw new SidecarUnavailableError( - "the hub could not deploy workflow echo: the sidecar is unavailable", - "start the stack (`bun run dev` runs the hub and sidecar together), wait for the sidecar to connect, then re-run: workbench seed", - ); - }, - }); - - expect(result).toEqual({ - kind: "seeded-pending-agents", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: PRINCIPAL_ID, - tenantDomain: "alice-user1.bench.local", - deployed: [], - pending: ["assistant"], - message: "Your workbench is ready — agents will come online shortly.", - }); - }); -}); - -describe("testAndPersistCredential (the fast half)", () => { - test("proves and persists the key, but never deploys a workflow", async () => { - let seedTenantCalled = false; - const seedCatalogCalls: unknown[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - // Any workflow-shaped call proves this half reached past its - // remit — `testAndPersistCredential` should never touch these. - if (path.includes("/workflows/") || path.endsWith("/assets")) { - throw new Error(`unexpected workflow-shaped call: ${method} ${path}`); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await testAndPersistCredential({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-good", - pushWorkflow: async () => { - seedTenantCalled = true; - return { outcome: "pushed" as const, commitSha: "a".repeat(40) }; - }, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(result).toEqual({ - kind: "connected", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: PRINCIPAL_ID, - tenantDomain: "alice-user1.bench.local", - }); - expect(seedCatalogCalls).toHaveLength(1); - expect(seedTenantCalled).toBe(false); - }); - - test("an unproven key still reaches and persists on the tenant — no probe gates this", async () => { - const seedCatalogCalls: unknown[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await testAndPersistCredential({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-never-probed", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(result.kind).toBe("connected"); - expect(seedCatalogCalls).toHaveLength(1); - }); - - // CL-6105: the Plugins gallery resolves a connector's connection status - // by `GET .../credentials/resolve/:name`, looking that credential up by - // the connector's own `descriptor.displayName` ("OpenRouter"). Seeding - // it under the catalog-seed convention (`openrouter-default`) instead - // — as `seedCatalog` does by default — left a freshly connected - // OpenRouter credential permanently invisible to that gallery: the - // callback redirected back with `outcome=connected`, but the card kept - // reading "not connected" because the two write/read paths disagreed - // on the row's name. - test("seeds the OAuth connect flow's credential under the Plugins gallery's own lookup name", async () => { - const seedCatalogCalls: { credentialName?: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse(); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantResponse(); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await testAndPersistCredential({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "openrouter", - apiKey: "sk-or-good", - pushWorkflow: noopPush, - log: collector().log, - ...stubPersistFns, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(result.kind).toBe("connected"); - expect(seedCatalogCalls).toHaveLength(1); - expect(seedCatalogCalls[0]?.credentialName).toBe("OpenRouter"); - }); - - test("a valid key with no personal bench yet is reported, not guessed at", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return { - status: 200, - data: { data: [], nextCursor: null }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await testAndPersistCredential({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - userId: "user_1", - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: "sk-ant-good", - pushWorkflow: noopPush, - log: collector().log, - }); - - expect(result).toEqual({ kind: "no-personal-bench" }); - }); -}); - -describe("ensureSeeded (the slow half)", () => { - const TENANT: { - tenantId: string; - tenantSlug: string; - principalId: string; - tenantDomain: string; - } = { - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: PRINCIPAL_ID, - tenantDomain: "alice-user1.bench.local", - }; - - test("deploys every default workflow against the connected provider's own model, unconfirmed", async () => { - const seedTenantCalls: { - model: { provider: string; model: string }; - confirmDeployments?: boolean; - }[] = []; - - const result = await ensureSeeded({ - api: (async () => { - throw new Error("the real api must not be called — seedTenantFn is stubbed"); - }) as ApiCall, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - pushWorkflow: noopPush, - log: collector().log, - tenant: TENANT, - provider: "anthropic", - apiKey: "sk-ant-good", - seedTenantFn: async (args) => { - seedTenantCalls.push(args as never); - }, - }); - - expect(result).toEqual({ - kind: "seeded", - workflows: ["assistant"], - }); - expectNoConfirmation(seedTenantCalls); - expect(seedTenantCalls[0]?.model.provider).toBe("anthropic"); - }); - - test("deploys the setup agent before anything else, so a waiting person can start as soon as she is live", async () => { - // CL-6462. This is the deploy path the background drain runs - // (bench-provisioning's `runOnce` → `ensureSeeded` → `seedTenant`), - // and `seedTenant` works through the list in order at ~20s each — so - // the order handed in here is the order a fresh signup experiences. - // CL-7074 narrowed DEFAULT_WORKFLOWS to just the setup agent, so - // "before anything else" is now trivially satisfied by being the - // only entry — this still pins that fact rather than assuming it. - const workflowOrder: string[] = []; - - await ensureSeeded({ - api: (async () => { - throw new Error("the real api must not be called — seedTenantFn is stubbed"); - }) as ApiCall, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - pushWorkflow: noopPush, - log: collector().log, - tenant: TENANT, - provider: "anthropic", - apiKey: "sk-ant-good", - seedTenantFn: async (args) => { - for (const workflow of args.workflows ?? []) { - workflowOrder.push(workflow.assetName); - } - }, - }); - - expect(workflowOrder[0]).toBe(SETUP_AGENT_ASSET_NAME); - }); - - test("two overlapping calls for the same tenant never double-deploy — the same 409-then-list tolerance seedTenant already has", async () => { - const TIMESTAMP = "2026-01-01T00:00:00.000Z"; - type Row = { name: string; id: string }; - const grants: { resource: string; action: string }[] = []; - const assets: Row[] = []; - const deployments: { definitionAssetId: string; id: string }[] = []; - let assetCreatePosts = 0; - let deploymentCreatePosts = 0; - - const api: ApiCall = async (method, path, body) => { - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return fixedDiscoveredModelsResponse(); - } - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/grants?`)) { - return { - status: 200, - data: { - data: grants.map((g, index) => ({ - id: `grt_${index}`, - tenantId: TENANT_ID, - resource: g.resource, - action: g.action, - effect: "allow", - principalId: PRINCIPAL_ID, - origin: "creator", - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - })), - nextCursor: null, - }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/grants`) { - const g = body as { resource: string; action: string }; - grants.push({ resource: g.resource, action: g.action }); - return { status: 201, data: {}, cookies: [] }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - const name = (body as { name: string }).name; - const existing = assets.find((a) => a.name === name); - if (existing) return { status: 409, data: {}, cookies: [] }; - assetCreatePosts += 1; - const id = `ast_${name}`; - assets.push({ name, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: name, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }, - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: assets.map((a) => ({ - id: a.id, - tenantId: TENANT_ID, - kind: "workflow", - name: a.name, - displayName: a.name, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - origin: { tenantId: TENANT_ID, direct: true }, - })), - 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: [] }; - } - const handshake = seedHandshake(method, path); - if (handshake) return handshake; - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - return { - status: 200, - data: deployments.map((d) => ({ - id: d.id, - tenantId: TENANT_ID, - definitionAssetId: d.definitionAssetId, - status: "deployed", - createdAt: TIMESTAMP, - })), - cookies: [], - }; - } - if ( - method === "GET" && - path.startsWith(`/api/tenants/${TENANT_ID}/workflows/runs/`) && - path.endsWith("/health") - ) { - return { - status: 200, - data: { liveness: "ok", readiness: "ok", lastCheckedAt: null }, - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - deploymentCreatePosts += 1; - const assetId = (body as { source: { assetId: string } }).source.assetId; - const id = `dep_${assetId}`; - deployments.push({ definitionAssetId: assetId, id }); - return { - status: 201, - data: { - id, - tenantId: TENANT_ID, - definitionAssetId: assetId, - status: "deployed", - createdAt: TIMESTAMP, - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const runEnsureSeeded = () => - ensureSeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - pushWorkflow: noopPush, - log: collector().log, - tenant: TENANT, - provider: "anthropic", - apiKey: "sk-ant-good", - }); - - // Two overlapping calls, exactly like two concurrent - // `/complete-setup` requests reading the same still-valid pending - // token, running back to back against the same stateful fake hub. - const [first, second] = await Promise.all([runEnsureSeeded(), runEnsureSeeded()]); - - expect(first.kind).toBe("seeded"); - expect(second.kind).toBe("seeded"); - expect(assetCreatePosts).toBe(1); - expect(deploymentCreatePosts).toBe(1); - expect(assets.length).toBe(1); - expect(deployments.length).toBe(1); - }); - - // CL-6264: tonight's live failure — completeCredentialSetup -> - // seedTenant -> deployWorkflow hit sidecar_unavailable and the whole - // flow failed, even though the credential, tenant, grants, and assets - // all durably succeeded. `ensureSeeded` must parse that specific - // `CliError` subclass and report an honest partial state instead of - // throwing. - test("a sidecar-unavailable deploy reports which workflows deployed and which are pending, instead of failing the whole flow", async () => { - const TIMESTAMP = "2026-01-01T00:00:00.000Z"; - const assetRow = (name: string) => ({ - id: `ast_${name}`, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: name, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - origin: { tenantId: TENANT_ID, direct: true }, - }); - const api: ApiCall = async (method, path) => { - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: [assetRow("echo"), assetRow("assistant")], - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - return { - status: 200, - data: [ - { definitionAssetId: "ast_echo", status: "deployed" }, - { definitionAssetId: "ast_assistant", status: "deployed" }, - ], - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await ensureSeeded({ - api, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - pushWorkflow: noopPush, - log: collector().log, - tenant: TENANT, - provider: "anthropic", - apiKey: "sk-ant-good", - seedTenantFn: async () => { - throw new SidecarUnavailableError( - "the hub could not deploy workflow workbench-digest: the sidecar is unavailable", - "start the stack (`bun run dev` runs the hub and sidecar together), wait for the sidecar to connect, then re-run: workbench seed", - ); - }, - }); - - expect(result).toEqual({ - kind: "seeded-pending-agents", - deployed: ["assistant"], - pending: [], - message: "Your workbench is ready — agents will come online shortly.", - }); - }); - - test("a non-sidecar deploy failure still fails the flow loudly", async () => { - await expect( - ensureSeeded({ - api: (async () => { - throw new Error("api must not be called before seedTenantFn runs"); - }) as ApiCall, - cookies: ["session=abc"], - hubUrl: "http://localhost:3000", - pushWorkflow: noopPush, - log: collector().log, - tenant: TENANT, - provider: "anthropic", - apiKey: "sk-ant-good", - seedTenantFn: async () => { - throw new Error("the hub rejected the deployment with status 500"); - }, - }), - ).rejects.toThrow("the hub rejected the deployment with status 500"); - }); -}); - -// CL-7506: a seeded admin's only membership is the root bench (slug = the -// org's own slug), which never equals personalTenantSlug(userEmail, -// userId) — the connect flow must fall back to that existing principal -// rather than 409 with no_personal_bench. The fallback is opt-in: the -// default (no options) is a strict slug match so a caller waiting for a -// specific bench does not silently resolve to the first principal. -describe("findPersonalTenant", () => { - function principalsPage( - principals: { - principalId: string; - tenantId: string; - tenantSlug: string; - }[], - ) { - return { - status: 200, - data: { - data: principals.map((p) => ({ - kind: "user", - status: "active", - roles: [], - tenantName: p.tenantSlug, - ...p, - })), - nextCursor: null, - }, - cookies: [], - }; - } - - function tenantFor(slug: string, domain: string) { - return { - status: 200, - data: { - id: slug, - name: slug, - slug, - domain, - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - - function seedAdminHub() { - return async (method: string, path: string) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsPage([ - { - principalId: "prn_root", - tenantId: "ten_root", - tenantSlug: "acme", - }, - ]); - } - if (method === "GET" && path === "/api/tenants/ten_root") { - return tenantFor("acme", "acme.bench.local"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - } - - test("strict by default: a slug mismatch resolves to nothing, so a caller waiting for a specific bench does not silently resolve", async () => { - // personalTenantSlug("alice@example.com", "user_1") is "alice-user1"; - // the only principal is the root bench "acme". Without the fallback - // flag this must NOT silently resolve. - expect( - await findPersonalTenant(seedAdminHub(), ["session=abc"], "alice-user1"), - ).toBeUndefined(); - }); - - test("with the fallback flag, the seeded-admin shape resolves to the root bench instead of no-personal-bench", async () => { - expect( - await findPersonalTenant(seedAdminHub(), ["session=abc"], "alice-user1", { - fallbackToFirstPrincipal: true, - }), - ).toEqual({ - tenantId: "ten_root", - tenantSlug: "acme", - principalId: "prn_root", - tenantDomain: "acme.bench.local", - }); - }); - - test("a caller with zero principals still gets no-personal-bench, fallback or not", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsPage([]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - expect(await findPersonalTenant(api, ["session=abc"], "alice-user1")).toBeUndefined(); - expect( - await findPersonalTenant(api, ["session=abc"], "alice-user1", { - fallbackToFirstPrincipal: true, - }), - ).toBeUndefined(); - }); - - test("an exact slug match wins even when a mismatched principal comes first", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsPage([ - { - principalId: "prn_root", - tenantId: "ten_root", - tenantSlug: "acme", - }, - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}`) { - return tenantFor(TENANT_SLUG, "alice-user1.bench.local"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - expect(await findPersonalTenant(api, ["session=abc"], TENANT_SLUG)).toEqual({ - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: PRINCIPAL_ID, - tenantDomain: "alice-user1.bench.local", - }); - }); - - test("the fallback pick is the first principal in page order", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsPage([ - { - principalId: "prn_first", - tenantId: "ten_first", - tenantSlug: "first-bench", - }, - { - principalId: "prn_second", - tenantId: "ten_second", - tenantSlug: "second-bench", - }, - ]); - } - if (method === "GET" && path === "/api/tenants/ten_first") { - return tenantFor("first-bench", "first.bench.local"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - expect( - await findPersonalTenant(api, ["session=abc"], "alice-user1", { - fallbackToFirstPrincipal: true, - }), - ).toEqual({ - tenantId: "ten_first", - tenantSlug: "first-bench", - principalId: "prn_first", - tenantDomain: "first.bench.local", - }); - }); -}); diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts deleted file mode 100644 index 836476b5a6..0000000000 --- a/packages/onboarding/test/complete-setup-routes.test.ts +++ /dev/null @@ -1,794 +0,0 @@ -// `POST /complete-setup` reports where a bench stands; it never -// deploys. CL-6457 moved every workflow deploy onto the background -// drain in `../src/bench-provisioning.ts`, because a request that -// waits on deploys is the two-minute "Connecting…" onboarding was -// stuck behind. What is left here is a status reporter over two cheap -// hub reads, and it has to answer three cases correctly: every default -// workflow live (`ready` — clear the pending row, the drain has -// nothing left to do), still deploying with a pending row parked -// (`provisioning` — nudge the drain and leave the row alone, it is the -// drain's durable work item), and nothing live with no row to work -// from (`unseeded`, a 200 and not an error, telling the caller to fall -// back to the ordinary credential step). -// -// The deploying itself — its idempotency, its retries, its -// half-provisioned recovery — is covered where it now lives, in -// `./bench-provisioning.test.ts`. -// -// CL-6031 moved the pending credential off the browser: what used to -// be a sealed HttpOnly cookie is now a row in -// `createInMemoryPendingSeedStore` (the same store shape -// `createDrizzlePendingSeedStore` gives Postgres — see -// `../src/pending-seed.test.ts` for that logic's own direct coverage), -// written by calling `store.put(...)` the way the OAuth callback would, -// instead of round-tripping a cookie header. -import { describe, expect, test } from "bun:test"; -import type { AppEnv } from "@intx/hub-api"; -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; -import { createEnvKeyCredentialCipher } from "@intx/crypto"; -import type { CredentialCipher } from "@intx/types"; -import { DEFAULT_WORKFLOWS } from "../src/tenant-seed"; -import { createOnboardingRoutes } from "../src/routes"; -import { createInMemoryPendingSeedStore, type PendingSeedStore } from "../src/pending-seed"; - -const TEST_KEY = Buffer.alloc(32, 21); -function testCipher(): CredentialCipher { - return createEnvKeyCredentialCipher(TEST_KEY); -} - -// These tests never exercise the genesis-or-join join path — the stub -// satisfies the required tenancy wiring without standing up a DB. -const emptyHubTenancy = { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("these suites never exercise the join path"); - }, -}; - -const TENANT_ID = "ten_1"; -const PRINCIPAL_ID = "prn_1"; -const TENANT_SLUG = "user-1-user1"; -const TENANT_DOMAIN = "user-1-user1.bench.local"; -const TIMESTAMP = "2026-01-01T00:00:00.000Z"; - -function asUser(): MiddlewareHandler { - return async (c, next) => { - c.set("user", { id: "user_1", email: "user_1@example.com" } as never); - await next(); - }; -} - -function mountAuthenticated(routes: Hono): Hono { - const app = new Hono(); - app.use("*", asUser()); - app.route("/api/onboarding", routes); - return app; -} - -function principalsRoute(hub: Hono) { - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "user_1's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - hub.get(`/api/tenants/${TENANT_ID}`, (c) => - c.json({ - id: TENANT_ID, - name: "user_1's workbench", - slug: TENANT_SLUG, - domain: TENANT_DOMAIN, - parentId: null, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }), - ); -} - -async function withPendingSeed( - store: PendingSeedStore, - args: { userId?: string; ttlMs?: number } = {}, -): Promise { - await store.put( - { - userId: args.userId ?? "user_1", - tenantId: TENANT_ID, - principalId: PRINCIPAL_ID, - tenantDomain: TENANT_DOMAIN, - provider: "openrouter", - apiKey: "sk-or-v1-minted", - }, - args.ttlMs !== undefined ? { ttlMs: args.ttlMs } : {}, - ); -} - -describe("POST /complete-setup", () => { - test("requires authentication", async () => { - const app = new Hono(); - app.route( - "/api/onboarding", - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "https://bench.example.com", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), - }), - ); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(401); - }); - - test("no personal bench yet reports 409, not a fabricated seed", async () => { - const hub = new Hono(); - hub.get("/api/me/principals", (c) => c.json({ data: [], nextCursor: null })); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), - }), - ); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(409); - const body = (await response.json()) as { error: { code: string } }; - expect(body.error.code).toBe("no_personal_bench"); - } finally { - server.stop(true); - } - }); - - // CL-7506: a seeded admin's only membership is the root bench, whose - // slug is the org's own — never the computed personal-bench slug. The - // fallback must resolve that principal here too, answering from the - // read instead of 409ing. - test("a root-bench-only admin (slug mismatch) resolves through the fallback and reports unseeded, not 409", async () => { - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: "prn_root", - tenantId: "ten_root", - tenantName: "acme", - tenantSlug: "acme", - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - hub.get("/api/tenants/ten_root", (c) => - c.json({ - id: "ten_root", - name: "acme", - slug: "acme", - domain: "acme.bench.local", - parentId: null, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }), - ); - hub.get("/api/tenants/ten_root/assets", (c) => c.json([])); - hub.get("/api/tenants/ten_root/workflows/deployments", (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), - }), - ); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("unseeded"); - } finally { - server.stop(true); - } - }); - - test("an already fully seeded bench reports ready without needing a pending row", async () => { - const hub = new Hono(); - principalsRoute(hub); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => - c.json( - DEFAULT_WORKFLOWS.map((workflow, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name: workflow.assetName, - displayName: workflow.displayName, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - origin: { tenantId: TENANT_ID, direct: true }, - })), - ), - ); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => - c.json( - DEFAULT_WORKFLOWS.map((_workflow, index) => ({ - definitionAssetId: `ast_${index}`, - status: "deployed", - })), - ), - ); - let ensureSeededCalls = 0; - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), - ensureSeededFn: async () => { - ensureSeededCalls += 1; - return { kind: "seeded", workflows: [] }; - }, - }), - ); - - // No pending-seed row at all — an already-seeded bench must - // answer from the read alone, no pending row required. - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { - kind: string; - tenantSlug: string; - deployed: string[]; - pending: string[]; - }; - expect(body.kind).toBe("ready"); - expect(body.tenantSlug).toBe(TENANT_SLUG); - expect(body.deployed.sort()).toEqual(DEFAULT_WORKFLOWS.map((w) => w.assetName).sort()); - expect(body.pending).toEqual([]); - expect(ensureSeededCalls).toBe(0); - } finally { - server.stop(true); - } - }); - - test("unseeded with no pending row reports unseeded, not an error", async () => { - const hub = new Hono(); - principalsRoute(hub); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => c.json([])); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore: createInMemoryPendingSeedStore(testCipher()), - }), - ); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("unseeded"); - } finally { - server.stop(true); - } - }); - - test("a bench still deploying reports provisioning and never deploys inline", async () => { - const hub = new Hono(); - principalsRoute(hub); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => c.json([])); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const pendingSeedStore = createInMemoryPendingSeedStore(testCipher()); - try { - let ensureSeededCalls = 0; - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - ensureSeededFn: async () => { - ensureSeededCalls += 1; - return { kind: "seeded", workflows: [] }; - }, - }), - ); - await withPendingSeed(pendingSeedStore); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { - kind: string; - tenantId: string; - tenantSlug: string; - setupAgentReady: boolean; - deployed: string[]; - pending: string[]; - steps: { name: string; status: string }[]; - }; - expect(body).toEqual({ - kind: "provisioning", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - setupAgentReady: false, - deployed: [], - pending: DEFAULT_WORKFLOWS.map((w) => w.assetName), - steps: expect.any(Array), - }); - // The point of CL-6457: a pending row in front of it is not a - // licence to deploy on the request path. The seam still exists, - // it just belongs to the drain now. - expect(ensureSeededCalls).toBe(0); - // And the row stays exactly where it is — it is the drain's - // durable work item, not this request's scratch state. - const stillThere = await pendingSeedStore.read({ - userId: "user_1", - tenantId: TENANT_ID, - }); - expect(stillThere).toBeDefined(); - } finally { - server.stop(true); - } - }); - - test("a still-provisioning bench nudges the drain instead of waiting on it", async () => { - const hub = new Hono(); - principalsRoute(hub); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => c.json([])); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const pendingSeedStore = createInMemoryPendingSeedStore(testCipher()); - try { - let wakes = 0; - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - benchProvisioner: { - wake: () => { - wakes += 1; - }, - }, - }), - ); - await withPendingSeed(pendingSeedStore); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("provisioning"); - // Without the nudge a freshly parked row waits out the drain's - // whole tick interval before anything happens — the reason a - // waiting onboarding page calls this route at all. - expect(wakes).toBe(1); - } finally { - server.stop(true); - } - }); - - test("an already fully seeded bench also clears a stray pending row, not just the never-written case", async () => { - const hub = new Hono(); - principalsRoute(hub); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => - c.json( - DEFAULT_WORKFLOWS.map((workflow, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name: workflow.assetName, - displayName: workflow.displayName, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - origin: { tenantId: TENANT_ID, direct: true }, - })), - ), - ); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => - c.json( - DEFAULT_WORKFLOWS.map((_workflow, index) => ({ - definitionAssetId: `ast_${index}`, - status: "deployed", - })), - ), - ); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const pendingSeedStore = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }), - ); - - // The drain finished this bench a moment ago but its row is - // still sitting there — a crash between the last deploy and the - // clear, or a connect that parked a fresh row over an already - // complete bench. A `ready` read is the authority: the row has no - // work left in it and must not be left for the drain to pick up - // again. - await withPendingSeed(pendingSeedStore); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("ready"); - const stillThere = await pendingSeedStore.read({ - userId: "user_1", - tenantId: TENANT_ID, - }); - expect(stillThere).toBeUndefined(); - } finally { - server.stop(true); - } - }); - - test("an expired pending row is cleared rather than left to linger unused", async () => { - const hub = new Hono(); - principalsRoute(hub); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => c.json([])); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const pendingSeedStore = createInMemoryPendingSeedStore(testCipher()); - try { - await withPendingSeed(pendingSeedStore, { ttlMs: -1 }); - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }), - ); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("unseeded"); - const stillThere = await pendingSeedStore.read({ - userId: "user_1", - tenantId: TENANT_ID, - }); - expect(stillThere).toBeUndefined(); - } finally { - server.stop(true); - } - }); - - test("a pending row written for a different user is invisible to this session", async () => { - const hub = new Hono(); - principalsRoute(hub); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => c.json([])); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const pendingSeedStore = createInMemoryPendingSeedStore(testCipher()); - try { - await withPendingSeed(pendingSeedStore, { userId: "someone_else" }); - let ensureSeededCalls = 0; - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - ensureSeededFn: async () => { - ensureSeededCalls += 1; - return { kind: "seeded", workflows: [] }; - }, - }), - ); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("unseeded"); - expect(ensureSeededCalls).toBe(0); - } finally { - server.stop(true); - } - }); - - test("two overlapping calls both report provisioning and neither deploys", async () => { - // Two "finish setup" requests racing (a double effect fire, a - // retried fetch) used to be this route's sharpest edge, because - // both would deploy. Post-CL-6457 the route deploys nothing at all, - // so the only thing left to hold is that overlapping callers get - // the same honest status and still start no work of their own. The - // dedupe that matters now lives one layer down and is covered - // there: "overlapping drains never double-deploy the same bench" in - // ./bench-provisioning.test.ts. - const hub = new Hono(); - // Deterministic overlap, not a race against real wall-clock - // scheduling: `findPersonalTenant` is the first hub call each - // `/complete-setup` request makes, so gating it on "both requests - // have arrived" guarantees the two calls are genuinely in flight - // together every run, rather than hoping `Promise.all` happens to - // interleave that way under whatever load the machine is under. - let arrivals = 0; - let releaseArrivals: () => void = () => undefined; - const bothArrived = new Promise((resolve) => { - releaseArrivals = resolve; - }); - hub.get("/api/me/principals", async (c) => { - arrivals += 1; - if (arrivals >= 2) releaseArrivals(); - else await bothArrived; - return c.json({ - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "user_1's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }); - }); - hub.get(`/api/tenants/${TENANT_ID}`, (c) => - c.json({ - id: TENANT_ID, - name: "user_1's workbench", - slug: TENANT_SLUG, - domain: TENANT_DOMAIN, - parentId: null, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - }), - ); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => c.json([])); - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const pendingSeedStore = createInMemoryPendingSeedStore(testCipher()); - try { - let ensureSeededCalls = 0; - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - ensureSeededFn: async () => { - ensureSeededCalls += 1; - return { kind: "seeded", workflows: [] }; - }, - }), - ); - await withPendingSeed(pendingSeedStore); - - const [first, second] = await Promise.all([ - app.request("/api/onboarding/complete-setup", { - method: "POST", - }), - app.request("/api/onboarding/complete-setup", { - method: "POST", - }), - ]); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - const firstBody = (await first.json()) as { kind: string }; - const secondBody = (await second.json()) as { kind: string }; - expect(firstBody.kind).toBe("provisioning"); - expect(secondBody.kind).toBe("provisioning"); - expect(ensureSeededCalls).toBe(0); - - // One work item, however many callers ask about it. - const stillThere = await pendingSeedStore.read({ - userId: "user_1", - tenantId: TENANT_ID, - }); - expect(stillThere).toBeDefined(); - } finally { - server.stop(true); - } - }); - - // CL-6264, re-homed by CL-6457: a bench that got partway through its - // workflows must read as still provisioning, and must keep its - // pending row so the drain can finish the rest. The convergence - // itself — deploying only what is missing on a later pass — is - // covered by "a half-provisioned bench keeps its row and converges on - // a later pass" in ./bench-provisioning.test.ts. - // - // CL-7074 narrowed DEFAULT_WORKFLOWS to just the setup agent, so - // "partway through" no longer means "one of several live, the rest - // pending" — it means the one default workflow's asset exists but - // has not gone live yet (the sidecar push landed, the deploy - // confirmation has not). - test("a bench whose agent is not yet live keeps its pending row for the drain", async () => { - const liveWorkflow = DEFAULT_WORKFLOWS[0]; - if (liveWorkflow === undefined) { - throw new Error("DEFAULT_WORKFLOWS is empty"); - } - const hub = new Hono(); - principalsRoute(hub); - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => - c.json( - DEFAULT_WORKFLOWS.map((workflow, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name: workflow.assetName, - displayName: workflow.displayName, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: TIMESTAMP, - updatedAt: TIMESTAMP, - origin: { tenantId: TENANT_ID, direct: true }, - })), - ), - ); - // The asset exists, but no deployment has gone live yet. - hub.get(`/api/tenants/${TENANT_ID}/workflows/deployments`, (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const pendingSeedStore = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }), - ); - await withPendingSeed(pendingSeedStore); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { - kind: string; - tenantId: string; - tenantSlug: string; - setupAgentReady: boolean; - deployed: string[]; - pending: string[]; - steps: { name: string; status: string }[]; - }; - expect(body).toEqual({ - kind: "provisioning", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - setupAgentReady: false, - deployed: [], - pending: [liveWorkflow.assetName], - steps: expect.any(Array), - }); - - // Not finished yet — clearing the row here would strand the - // remaining agents with nothing left to deploy them. - const stillThere = await pendingSeedStore.read({ - userId: "user_1", - tenantId: TENANT_ID, - }); - expect(stillThere).toBeDefined(); - } finally { - server.stop(true); - } - }); -}); diff --git a/packages/onboarding/test/connect-deploys-nothing.test.ts b/packages/onboarding/test/connect-deploys-nothing.test.ts deleted file mode 100644 index 66c78691d9..0000000000 --- a/packages/onboarding/test/connect-deploys-nothing.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -// "Connecting should deploy nothing" (CL-6457), asserted at the route -// layer. The live repro: pasting a key sat on "Connecting…" for 2+ -// minutes because `POST /complete` deployed five default workflows — -// ~20s each — before it answered. The fix is structural, so the test is -// too: the deploy step is handed in as a seam that takes five seconds -// and records when it ran, and the route has to answer long before it -// could possibly have waited on that. A route that ever awaits a deploy -// again fails here on the clock, not on a mock's call count alone. -import { describe, expect, test } from "bun:test"; -import type { AppEnv } from "@intx/hub-api"; -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; -import { createEnvKeyCredentialCipher } from "@intx/crypto"; -import type { CredentialCipher } from "@intx/types"; -import { DEFAULT_WORKFLOWS, SETUP_AGENT_ASSET_NAME } from "../src/tenant-seed"; -import { createOnboardingRoutes } from "../src/routes"; -import { createInMemoryPendingSeedStore, type PendingSeedStore } from "../src/pending-seed"; - -const TEST_KEY = Buffer.alloc(32, 44); -function testCipher(): CredentialCipher { - return createEnvKeyCredentialCipher(TEST_KEY); -} - -const TENANT_ID = "ten_1"; -const PRINCIPAL_ID = "prn_1"; -const TENANT_SLUG = "user-1-user1"; -const TENANT_DOMAIN = "user-1-user1.bench.local"; -const ALL_WORKFLOWS = DEFAULT_WORKFLOWS.map((workflow) => workflow.assetName); - -function asUser(): MiddlewareHandler { - return async (c, next) => { - c.set("user", { id: "user_1", email: "user_1@example.com" } as never); - await next(); - }; -} - -/** A hub that answers only the reads the fast half performs. Any deploy - * traffic would have to go somewhere else entirely — and the deploy seam - * below proves it never even starts. */ -function fakeHub(args: { seededWorkflows?: string[]; tenantSlug?: string } = {}) { - const seeded = args.seededWorkflows ?? []; - const hub = new Hono(); - const requests: string[] = []; - hub.use("*", async (c, next) => { - requests.push(`${c.req.method} ${new URL(c.req.url).pathname}`); - await next(); - }); - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantName: "user_1's workbench", - tenantSlug: args.tenantSlug ?? TENANT_SLUG, - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - hub.get("/api/tenants/:id", (c) => - c.json({ - id: TENANT_ID, - name: "user_1's workbench", - slug: TENANT_SLUG, - domain: TENANT_DOMAIN, - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }), - ); - hub.get("/api/tenants/:id/assets", (c) => { - if (c.req.query("kind") === "package-registry") { - // The corbits-tools registry is seeded in every state this suite - // models — readiness now also requires resolvable tool packages. - return c.json([ - { - id: "ast_registry", - tenantId: TENANT_ID, - kind: "package-registry", - name: "corbits-tools", - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - }, - ]); - } - return c.json( - seeded.map((name, index) => ({ - id: `ast_${index}`, - tenantId: TENANT_ID, - kind: "workflow", - name, - displayName: name, - creatorPrincipalId: PRINCIPAL_ID, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - })), - ); - }); - hub.get("/api/tenants/:id/assets/ast_registry/tarballs", (c) => - c.json([ - { - filename: "corbits-capability-tools-0.0.6.tgz", - size: 1, - integrity: "sha512-x", - }, - ]), - ); - hub.get("/api/tenants/:id/skills/:name", (c) => c.json({ name: c.req.param("name") })); - hub.get("/api/tenants/:id/workflows/deployments", (c) => - c.json( - seeded.map((_name, index) => ({ - definitionAssetId: `ast_${index}`, - status: "deployed", - })), - ), - ); - return { hub, requests }; -} - -function routeDeps(args: { - hubUrl: string; - store: PendingSeedStore; - onDeploy?: () => void; - deployDelayMs?: number; -}) { - return { - hubUrl: args.hubUrl, - // This suite never exercises the genesis-or-join join path; the - // stub satisfies the required tenancy wiring without a DB. - tenancy: { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("this suite never exercises the join path"); - }, - }, - defaultTenantSlug: "workbench", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore: args.store, - testAndPersistCredentialFn: async () => ({ - kind: "connected" as const, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: PRINCIPAL_ID, - tenantDomain: TENANT_DOMAIN, - }), - ensureSeededFn: async () => { - args.onDeploy?.(); - await new Promise((resolve) => setTimeout(resolve, args.deployDelayMs ?? 0)); - return { kind: "seeded" as const, workflows: ALL_WORKFLOWS }; - }, - }; -} - -function mountAuthenticated(routes: Hono): Hono { - const app = new Hono(); - app.use("*", asUser()); - app.route("/api/onboarding", routes); - return app; -} - -describe("POST /complete — connecting deploys nothing", () => { - test("answers in a moment even when a deploy would take five seconds", async () => { - const { hub, requests } = fakeHub(); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - let deployStarted = false; - try { - const app = mountAuthenticated( - createOnboardingRoutes( - routeDeps({ - hubUrl: `http://localhost:${server.port}`, - store, - deployDelayMs: 5_000, - onDeploy: () => { - deployStarted = true; - }, - }), - ), - ); - - const startedAt = Date.now(); - const response = await app.request("/api/onboarding/complete", { - method: "POST", - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-x" }), - headers: { "content-type": "application/json" }, - }); - const elapsedMs = Date.now() - startedAt; - - expect(response.status).toBe(200); - expect(elapsedMs).toBeLessThan(1_000); - // The response never waited on a deploy — the whole point. - expect(deployStarted).toBe(false); - // And nothing deploy-shaped was even attempted against the hub. - expect( - requests.filter( - (line) => line.includes("/workflows/deployments") && line.startsWith("POST"), - ), - ).toEqual([]); - expect(requests.filter((line) => line.startsWith("POST /api/tenants/ten_1/assets"))).toEqual( - [], - ); - } finally { - server.stop(true); - } - }); - - test("reports the bench as provisioning, naming the agents still to come", async () => { - const { hub } = fakeHub(); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes(routeDeps({ hubUrl: `http://localhost:${server.port}`, store })), - ); - - const response = await app.request("/api/onboarding/complete", { - method: "POST", - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-x" }), - headers: { "content-type": "application/json" }, - }); - const body = (await response.json()) as { - kind: string; - tenantSlug: string; - deployed: string[]; - pending: string[]; - }; - - expect(body.kind).toBe("provisioning"); - expect(body.tenantSlug).toBe(TENANT_SLUG); - expect(body.deployed).toEqual([]); - expect(body.pending).toEqual(ALL_WORKFLOWS); - } finally { - server.stop(true); - } - }); - - test("hands the drain a pending row carrying the key it will deploy against", async () => { - const { hub } = fakeHub(); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes(routeDeps({ hubUrl: `http://localhost:${server.port}`, store })), - ); - - await app.request("/api/onboarding/complete", { - method: "POST", - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-x" }), - headers: { "content-type": "application/json" }, - }); - - expect(await store.read({ userId: "user_1", tenantId: TENANT_ID })).toEqual({ - userId: "user_1", - tenantId: TENANT_ID, - principalId: PRINCIPAL_ID, - tenantDomain: TENANT_DOMAIN, - provider: "anthropic", - apiKey: "sk-ant-x", - }); - } finally { - server.stop(true); - } - }); - - test("an already-provisioned bench reconnecting reports ready, with nothing left pending", async () => { - const { hub } = fakeHub({ seededWorkflows: ALL_WORKFLOWS }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes(routeDeps({ hubUrl: `http://localhost:${server.port}`, store })), - ); - - const response = await app.request("/api/onboarding/complete", { - method: "POST", - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-x" }), - headers: { "content-type": "application/json" }, - }); - const body = (await response.json()) as { - kind: string; - pending: string[]; - }; - - expect(body.kind).toBe("ready"); - expect(body.pending).toEqual([]); - } finally { - server.stop(true); - } - }); -}); - -describe("GET /provisioning-status", () => { - test.each([ - ["", 400], - ["?tenantId=", 400], - ["?tenantId=someone-elses-bench", 403], - ])("rejects invalid or inaccessible selected benches: %s", async (query, expectedStatus) => { - const { hub, requests } = fakeHub(); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes(routeDeps({ hubUrl: `http://localhost:${server.port}`, store })), - ); - const response = await app.request(`/api/onboarding/provisioning-status${query}`); - expect(response.status).toBe(expectedStatus); - expect(requests.some((request) => request.includes("/assets"))).toBe(false); - } finally { - server.stop(true); - } - }); - - // CL-7074 narrowed DEFAULT_WORKFLOWS to just the setup agent, so the - // "some deployed, some still pending" progress bar this route used to - // report (CL-6462, when the default set was echo/assistant/ - // workbench-digest) can no longer happen for a real bench: with one - // default workflow, provisioning-status is binary — nothing deployed - // yet (`provisioning`, setup agent not ready) or fully deployed - // (`ready`). This test replaces the old two-test "partial progress" - // coverage with that binary reality; `setupAgentReady` and - // `kind: "ready"` are asserted at their other call sites below. - test("reports provisioning, with the setup agent not yet ready, before she deploys", async () => { - const { hub } = fakeHub({ seededWorkflows: [] }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes(routeDeps({ hubUrl: `http://localhost:${server.port}`, store })), - ); - - const response = await app.request( - `/api/onboarding/provisioning-status?tenantId=${TENANT_ID}`, - ); - const body = (await response.json()) as { - kind: string; - setupAgentReady: boolean; - deployed: string[]; - pending: string[]; - }; - - expect(response.status).toBe(200); - expect(body.kind).toBe("provisioning"); - expect(body.setupAgentReady).toBe(false); - expect(body.deployed).toEqual([]); - expect(body.pending).toEqual(ALL_WORKFLOWS); - } finally { - server.stop(true); - } - }); - - test("a bench whose other workflows landed first is not reported as chat-ready", async () => { - const others = ALL_WORKFLOWS.filter((name) => name !== SETUP_AGENT_ASSET_NAME); - const { hub } = fakeHub({ seededWorkflows: others }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes(routeDeps({ hubUrl: `http://localhost:${server.port}`, store })), - ); - - const response = await app.request( - `/api/onboarding/provisioning-status?tenantId=${TENANT_ID}`, - ); - const body = (await response.json()) as { setupAgentReady: boolean }; - - expect(body.setupAgentReady).toBe(false); - } finally { - server.stop(true); - } - }); - - test("reports readiness for a selected shared bench without a personal bench", async () => { - const { hub } = fakeHub({ - seededWorkflows: ALL_WORKFLOWS, - tenantSlug: "workbench", - }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes(routeDeps({ hubUrl: `http://localhost:${server.port}`, store })), - ); - - const response = await app.request( - `/api/onboarding/provisioning-status?tenantId=${TENANT_ID}`, - ); - const body = (await response.json()) as { kind: string }; - - expect(body.kind).toBe("ready"); - } finally { - server.stop(true); - } - }); -}); - -describe("POST /complete-setup — no longer deploys inline either", () => { - test("returns provisioning status without waiting on a deploy", async () => { - const { hub } = fakeHub(); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - let deployStarted = false; - try { - await store.put({ - userId: "user_1", - tenantId: TENANT_ID, - principalId: PRINCIPAL_ID, - tenantDomain: TENANT_DOMAIN, - provider: "openrouter", - apiKey: "sk-or-v1-minted", - }); - const app = mountAuthenticated( - createOnboardingRoutes( - routeDeps({ - hubUrl: `http://localhost:${server.port}`, - store, - deployDelayMs: 5_000, - onDeploy: () => { - deployStarted = true; - }, - }), - ), - ); - - const startedAt = Date.now(); - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - const elapsedMs = Date.now() - startedAt; - const body = (await response.json()) as { kind: string }; - - expect(response.status).toBe(200); - expect(elapsedMs).toBeLessThan(1_000); - expect(deployStarted).toBe(false); - expect(body.kind).toBe("provisioning"); - // The row stays: it is the drain's work item, not a spent token. - expect(await store.read({ userId: "user_1", tenantId: TENANT_ID })).toBeDefined(); - } finally { - server.stop(true); - } - }); - - test("still reports unseeded when there is nothing to provision with yet", async () => { - const { hub } = fakeHub(); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const store = createInMemoryPendingSeedStore(testCipher()); - try { - const app = mountAuthenticated( - createOnboardingRoutes(routeDeps({ hubUrl: `http://localhost:${server.port}`, store })), - ); - - const response = await app.request("/api/onboarding/complete-setup", { - method: "POST", - }); - const body = (await response.json()) as { kind: string }; - - expect(body.kind).toBe("unseeded"); - } finally { - server.stop(true); - } - }); -}); diff --git a/packages/onboarding/test/desired-state-reconcile.test.ts b/packages/onboarding/test/desired-state-reconcile.test.ts deleted file mode 100644 index 31a6020d03..0000000000 --- a/packages/onboarding/test/desired-state-reconcile.test.ts +++ /dev/null @@ -1,483 +0,0 @@ -// CL-7584: `reconcileTenantDesiredState` installs ONLY absent pins, in -// order (tools, then skills + grants + workflows via `seedTenant`), and -// is idempotent — a second pass over a converged tenant issues zero -// non-GET calls. Sidecar-unavailable failures report `blocked` without -// throwing; anything else reports `failed` and is safe to re-run. -import { describe, expect, test } from "bun:test"; -import type { ApiCall } from "@corbits/hub-api-client"; -import { SidecarUnavailableError } from "@corbits/hub-api-client"; -import type { ModelSource } from "../src/tenant-seed"; -import type { WorkflowPusher } from "@corbits/connections/workflow-push"; -import { installRegistryTarball } from "@corbits/tool-registry-publish"; -import { - reconcileTenantDesiredState, - resolveTenantDeployer, - resolveTenantModelSource, - TENANT_DESIRED_STATE, - type ReconcileArgs, -} from "../src/desired-state"; - -const TENANT_ID = "ten_reconcile"; -const MODEL: ModelSource = { provider: "anthropic", model: "claude-x" }; - -const ASSISTANT_ASSET = { - id: "ast_assistant", - tenantId: TENANT_ID, - kind: "workflow", - name: "assistant", - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, -}; -const REGISTRY_ASSET = { - ...ASSISTANT_ASSET, - id: "ast_corbits-tools", - kind: "package-registry", - name: "corbits-tools", -}; - -type Stub = { - workflowAssets: boolean; - liveDeployments: boolean; - registryTarballs: boolean; - skills: boolean; - catalogOfferings: boolean; -}; - -function harness(state: Stub) { - const calls: { method: string; path: string }[] = []; - let seedTenantCalls = 0; - const api = (async (method: string, path: string): Promise => { - calls.push({ method, path }); - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: state.workflowAssets ? [ASSISTANT_ASSET] : [], - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - return { - status: 200, - data: - state.workflowAssets && state.liveDeployments - ? [{ definitionAssetId: ASSISTANT_ASSET.id, status: "deployed" }] - : [], - cookies: [], - }; - } - if ( - method === "GET" && - (path === `/api/tenants/${TENANT_ID}/assets?kind=package-registry&inherited=true` || - path === `/api/tenants/${TENANT_ID}/assets?kind=package-registry&inherited=false`) - ) { - return { - status: 200, - data: state.registryTarballs ? [REGISTRY_ASSET] : [], - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets/${REGISTRY_ASSET.id}/tarballs` - ) { - return { - status: 200, - data: state.registryTarballs - ? [ - { - filename: "corbits-capability-tools-0.0.6.tgz", - size: 1, - integrity: "sha512-x", - }, - ] - : [], - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=skill&inherited=false` - ) { - return { - status: 200, - data: state.skills - ? TENANT_DESIRED_STATE.skills.map((skill, index) => ({ - id: `ast_skill_${index}`, - tenantId: TENANT_ID, - kind: "skill", - name: skill.name, - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: TENANT_ID, direct: true }, - })) - : [], - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/models`) { - return { - status: 200, - data: state.catalogOfferings - ? [ - { - id: "mdl_1", - canonicalName: "claude-x", - offerings: [ - { - offeringId: "off_1", - providerId: "prv_1", - providerName: "Anthropic", - plugin: "anthropic", - priority: 0, - deploymentTags: [], - capabilities: ["function-calling"], - pricing: [], - }, - ], - }, - ] - : [], - cookies: [], - }; - } - if (method === "POST" && path === `/api/tenants/${TENANT_ID}/assets`) { - // create-first: the registry asset is ensured here on the - // tarball-url install path. - return { status: 201, data: REGISTRY_ASSET, cookies: [] }; - } - throw new Error(`stub api: unhandled ${method} ${path}`); - }) as unknown as ApiCall; - - const pushWorkflow: WorkflowPusher = async (args) => { - calls.push({ method: "PUSH", path: args.remoteUrl }); - return { outcome: "pushed", commitSha: "a".repeat(40) }; - }; - - const args: ReconcileArgs = { - api, - cookies: ["session=1"], - hubUrl: "https://hub.example.com", - tenant: { tenantId: TENANT_ID, principalId: "prn_1", domain: "t.local" }, - model: MODEL, - pushWorkflow, - seedTenantFn: async (seedArgs) => { - calls.push({ method: "SEED_TENANT", path: seedArgs.tenant.tenantId }); - seedTenantCalls += 1; - state.workflowAssets = true; - state.liveDeployments = true; - state.skills = true; - }, - log: () => undefined, - }; - - return { - args, - state, - calls, - nonGetCalls: () => calls.filter((c) => c.method !== "GET"), - seedTenantCalls: () => seedTenantCalls, - }; -} - -describe("reconcileTenantDesiredState", () => { - test("a fresh tenant reports absent workspace-pack tools blocked (CL-8190: never packed here) while workflows still install", async () => { - const h = harness({ - workflowAssets: false, - liveDeployments: false, - registryTarballs: false, - skills: false, - catalogOfferings: true, - }); - const report = await reconcileTenantDesiredState(h.args); - expect(report.ready).toBe(false); - expect(h.seedTenantCalls()).toBe(1); - const tools = report.pins.filter((p) => p.kind === "tool-package"); - expect(tools.every((p) => p.status === "blocked")).toBe(true); - expect(h.nonGetCalls().some((c) => c.method === "PUBLISH")).toBe(false); - expect(report.pins.filter((p) => p.kind === "workflow")).toEqual( - TENANT_DESIRED_STATE.workflows.map((w) => ({ - name: w.assetName, - kind: "workflow", - status: "installed", - })), - ); - expect(report.pins.filter((p) => p.kind === "skill").length).toBe( - TENANT_DESIRED_STATE.skills.length, - ); - }); - - test("SECOND PASS on a converged tenant issues ZERO non-GET calls", async () => { - const h = harness({ - workflowAssets: true, - liveDeployments: true, - registryTarballs: true, - skills: true, - catalogOfferings: true, - }); - // First pass over an already-converged tenant (the standalone proof: - // everything present means nothing may write). - const first = await reconcileTenantDesiredState(h.args); - expect(first.ready).toBe(true); - expect(h.seedTenantCalls()).toBe(0); - expect(h.nonGetCalls().length).toBe(0); - - // And a fresh workflow/skill install (tools already present, as an - // operator's out-of-band `bun run publish-tools` would leave them) - // followed by a revisit behaves identically. - const h2 = harness({ - workflowAssets: false, - liveDeployments: false, - registryTarballs: true, - skills: false, - catalogOfferings: true, - }); - await reconcileTenantDesiredState(h2.args); - const writesAfterFirst = h2.nonGetCalls().length; - expect(writesAfterFirst).toBeGreaterThan(0); - const second = await reconcileTenantDesiredState(h2.args); - expect(second.ready).toBe(true); - expect(second.pins.every((p) => p.status === "present")).toBe(true); - expect(h2.nonGetCalls().length).toBe(writesAfterFirst); - }); - - test("a sidecar-unavailable deploy reports blocked without throwing", async () => { - const h = harness({ - workflowAssets: false, - liveDeployments: false, - registryTarballs: true, - skills: false, - catalogOfferings: true, - }); - h.args.seedTenantFn = async () => { - throw new SidecarUnavailableError("sidecar unreachable", "retry later"); - }; - const report = await reconcileTenantDesiredState(h.args); - expect(report.ready).toBe(false); - expect( - report.pins.filter((p) => p.kind === "workflow").every((p) => p.status === "blocked"), - ).toBe(true); - }); - - test("no catalog offerings reports workflow pins blocked, not a throw", async () => { - const h = harness({ - workflowAssets: false, - liveDeployments: false, - registryTarballs: true, - skills: false, - catalogOfferings: false, - }); - // No offerings: the caller cannot resolve a model, so the deploy - // half is blocked rather than attempted. - h.args.model = undefined as unknown as ModelSource; - const report = await reconcileTenantDesiredState(h.args); - expect(report.ready).toBe(false); - expect( - report.pins.filter((p) => p.kind === "workflow").every((p) => p.status === "blocked"), - ).toBe(true); - expect(h.seedTenantCalls()).toBe(0); - }); - - test("a non-sidecar failure reports failed, and a re-run can converge", async () => { - const h = harness({ - workflowAssets: false, - liveDeployments: false, - registryTarballs: true, - skills: true, - catalogOfferings: true, - }); - let failing = true; - h.args.seedTenantFn = async () => { - if (failing) throw new Error("grant reconcile blew up"); - h.args.seedTenantFn = async () => undefined; - }; - const first = await reconcileTenantDesiredState(h.args); - expect(first.ready).toBe(false); - expect(first.pins.some((p) => p.status === "failed")).toBe(true); - - failing = false; - const second = await reconcileTenantDesiredState(h.args); - expect(second.ready).toBe(true); - }); - - test("a tarball-url pin is fetched, integrity-verified, and PUT once", async () => { - const h = harness({ - workflowAssets: true, - liveDeployments: true, - registryTarballs: false, - skills: true, - catalogOfferings: true, - }); - const bytes = new TextEncoder().encode("fake-tarball-bytes"); - const putUrls: string[] = []; - const outcome = await installRegistryTarball({ - api: h.args.api, - cookies: [], - hubUrl: "https://hub.example.com", - tenantId: TENANT_ID, - name: "@corbits/capability-tools", - version: "0.0.6", - fetchSource: async () => bytes, - fetchImpl: async (input) => { - putUrls.push(String(input)); - return Response.json({ commit: "c".repeat(40), integrity: "sha512-x" }); - }, - log: () => undefined, - }); - expect(outcome).toBe("installed"); - expect(putUrls.length).toBe(1); - expect(putUrls[0]).toContain( - `/api/tenants/${TENANT_ID}/assets/${REGISTRY_ASSET.id}/tarballs/corbits-capability-tools-0.0.6.tgz`, - ); - - // And a republished same name@version is skipped (immutable). The - // listing now carries the tarball the PUT above created. - h.state.registryTarballs = true; - const skipped = await installRegistryTarball({ - api: h.args.api, - cookies: [], - hubUrl: "https://hub.example.com", - tenantId: TENANT_ID, - name: "@corbits/capability-tools", - version: "0.0.6", - fetchSource: async () => bytes, - fetchImpl: async () => { - throw new Error("must not PUT over an existing name@version"); - }, - log: () => undefined, - }); - expect(skipped).toBe("present"); - }); -}); - -describe("resolveTenantModelSource", () => { - test("picks the top-priority resolved offering", async () => { - const h = harness({ - workflowAssets: true, - liveDeployments: true, - registryTarballs: true, - skills: true, - catalogOfferings: true, - }); - const model = await resolveTenantModelSource(h.args.api, [], TENANT_ID); - expect(model).toEqual({ provider: "anthropic", model: "claude-x" }); - }); - - test("undefined when the tenant has no offerings", async () => { - const h = harness({ - workflowAssets: true, - liveDeployments: true, - registryTarballs: true, - skills: true, - catalogOfferings: false, - }); - const model = await resolveTenantModelSource(h.args.api, [], TENANT_ID); - expect(model).toBeUndefined(); - }); -}); - -describe("resolveTenantDeployer", () => { - // The tenant-create kick (CL-7584) reconciles a tenant nobody has - // connected a credential to yet, so there is no pending-seed row to - // read deploy identity from — it resolves the creator's owner - // principal plus the tenant domain straight from the API instead of - // deploying under empty-string ids. - function deployerPrincipalsPage( - principals: { - principalId: string; - tenantId: string; - kind?: string; - status?: string; - }[], - ) { - return { - status: 200, - data: { - data: principals.map((p) => ({ - kind: "user", - status: "active", - roles: [], - tenantName: p.tenantId, - tenantSlug: p.tenantId, - ...p, - })), - nextCursor: null, - }, - cookies: [], - }; - } - - function deployerTenant(domain: string) { - return { - status: 200, - data: { - id: "ten_child", - name: "second", - slug: "second", - domain, - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }, - cookies: [], - }; - } - - test("resolves the active user principal for the tenant id plus the tenant domain", async () => { - const api = (async (method: string, path: string) => { - if (method === "GET" && path === "/api/me/principals") { - return deployerPrincipalsPage([ - { principalId: "prn_root", tenantId: "ten_root" }, - { principalId: "prn_child", tenantId: "ten_child" }, - ]); - } - if (method === "GET" && path === "/api/tenants/ten_child") { - return deployerTenant("second.localhost"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }) as unknown as ApiCall; - - await expect(resolveTenantDeployer(api, ["session=abc"], "ten_child")).resolves.toEqual({ - tenantId: "ten_child", - principalId: "prn_child", - tenantDomain: "second.localhost", - }); - }); - - test("ignores suspended and non-user principals, resolving to nothing without reading the tenant", async () => { - const api = (async (method: string, path: string) => { - if (method === "GET" && path === "/api/me/principals") { - return deployerPrincipalsPage([ - { principalId: "prn_root", tenantId: "ten_root" }, - { - principalId: "prn_suspended", - tenantId: "ten_child", - status: "suspended", - }, - { principalId: "prn_agent", tenantId: "ten_child", kind: "agent" }, - ]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }) as unknown as ApiCall; - - await expect(resolveTenantDeployer(api, ["session=abc"], "ten_child")).resolves.toBeUndefined(); - }); - - test("zero principals resolves to nothing without reading the tenant", async () => { - const api = (async (method: string, path: string) => { - if (method === "GET" && path === "/api/me/principals") { - return deployerPrincipalsPage([]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }) as unknown as ApiCall; - - await expect(resolveTenantDeployer(api, ["session=abc"], "ten_child")).resolves.toBeUndefined(); - }); -}); diff --git a/packages/onboarding/test/desired-state.test.ts b/packages/onboarding/test/desired-state.test.ts deleted file mode 100644 index ca4ec18d6d..0000000000 --- a/packages/onboarding/test/desired-state.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -// CL-7584: the tenant desired-state document is client-side data — a -// plain const composed by reference over the existing single-source -// constants — and `readTenantDesiredStateStatus` reads a tenant's real -// state against it using native hub reads only. -import { describe, expect, test } from "bun:test"; -import type { ApiCall } from "@corbits/hub-api-client"; -import { SETUP_AGENT_ASSET_NAME } from "../src/tenant-seed"; -import { - desiredStateSteps, - readTenantDesiredStateStatus, - TENANT_DESIRED_STATE, -} from "../src/desired-state"; - -const TENANT_ID = "ten_doc"; -const TOOLS_ASSET_ID = "ast_corbits-tools"; - -function assetRow(tenantId: string, kind: string, name: string) { - return { - id: `ast_${name}`, - tenantId, - kind, - name, - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId, direct: true }, - }; -} - -type StubState = { - workflowAssets?: boolean; - liveDeployments?: boolean; - registryTarballs?: boolean; - skills?: boolean; - failSkillReadsWith?: number; -}; - -function stubApi(state: StubState): ApiCall & { calls: [string, string][] } { - const calls: [string, string][] = []; - const call = (async (method: string, path: string): Promise => { - calls.push([method, path]); - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=workflow&inherited=false` - ) { - return { - status: 200, - data: state.workflowAssets ? [assetRow(TENANT_ID, "workflow", SETUP_AGENT_ASSET_NAME)] : [], - cookies: [], - }; - } - if (method === "GET" && path === `/api/tenants/${TENANT_ID}/workflows/deployments`) { - return { - status: 200, - data: - state.liveDeployments && state.workflowAssets - ? [ - { - definitionAssetId: `ast_${SETUP_AGENT_ASSET_NAME}`, - status: "deployed", - }, - ] - : [], - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=package-registry&inherited=true` - ) { - return { - status: 200, - data: state.registryTarballs - ? [assetRow(TENANT_ID, "package-registry", "corbits-tools")] - : [], - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets/${TOOLS_ASSET_ID}/tarballs` - ) { - return { - status: 200, - data: state.registryTarballs - ? [ - { - filename: "corbits-capability-tools-0.0.6.tgz", - size: 1, - integrity: "sha512-x", - }, - ] - : [], - cookies: [], - }; - } - if ( - method === "GET" && - path === `/api/tenants/${TENANT_ID}/assets?kind=skill&inherited=false` - ) { - if (state.failSkillReadsWith !== undefined) { - return { status: state.failSkillReadsWith, data: {}, cookies: [] }; - } - return { - status: 200, - data: - state.skills === true - ? TENANT_DESIRED_STATE.skills.map((skill) => assetRow(TENANT_ID, "skill", skill.name)) - : [], - cookies: [], - }; - } - throw new Error(`stub api: unhandled ${method} ${path}`); - }) as unknown as ApiCall & { calls: [string, string][] }; - (call as unknown as { calls: [string, string][] }).calls = calls; - return call; -} - -describe("TENANT_DESIRED_STATE", () => { - test("is a plain const composed by reference: Myra first, no DB in sight", () => { - expect(TENANT_DESIRED_STATE.workflows.length).toBeGreaterThan(0); - expect(TENANT_DESIRED_STATE.workflows[0]?.assetName).toBe(SETUP_AGENT_ASSET_NAME); - for (const pin of TENANT_DESIRED_STATE.workflows) { - expect(typeof pin.assetName).toBe("string"); - expect(typeof pin.version).toBe("string"); - expect(typeof pin.definition).toBe("function"); - } - for (const pin of TENANT_DESIRED_STATE.toolPackages) { - expect(["workspace-pack", "tarball-url"]).toContain(pin.source.kind); - } - for (const pin of TENANT_DESIRED_STATE.skills) { - expect(typeof pin.name).toBe("string"); - expect(typeof pin.body).toBe("string"); - } - }); -}); - -describe("readTenantDesiredStateStatus", () => { - test("a fully converged tenant reports ready with every pin present", async () => { - const api = stubApi({ - workflowAssets: true, - liveDeployments: true, - registryTarballs: true, - skills: true, - }); - const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); - expect(status.ready).toBe(true); - expect(status.workflows[SETUP_AGENT_ASSET_NAME]).toBe("present"); - expect(status.tools).toBe("present"); - for (const skill of TENANT_DESIRED_STATE.skills) { - expect(status.skills[skill.name]).toBe("present"); - } - }); - - test("a fresh tenant reports every pin pending", async () => { - const api = stubApi({}); - const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); - expect(status.ready).toBe(false); - expect(status.workflows[SETUP_AGENT_ASSET_NAME]).toBe("pending"); - expect(status.tools).toBe("pending"); - for (const skill of TENANT_DESIRED_STATE.skills) { - expect(status.skills[skill.name]).toBe("pending"); - } - }); - - test("an asset without a live deployment is pending, not present", async () => { - const api = stubApi({ workflowAssets: true, liveDeployments: false }); - const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); - expect(status.workflows[SETUP_AGENT_ASSET_NAME]).toBe("pending"); - }); - - test("a skill read failure is blocked, not pending", async () => { - const api = stubApi({ failSkillReadsWith: 502 }); - const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); - const firstSkill = TENANT_DESIRED_STATE.skills[0]; - if (firstSkill === undefined) throw new Error("doc has no skills"); - expect(status.skills[firstSkill.name]).toBe("blocked"); - expect(status.ready).toBe(false); - }); -}); - -describe("desiredStateSteps", () => { - test("derives a labeled, doc-ordered step list from a status", async () => { - const api = stubApi({ workflowAssets: true, liveDeployments: false }); - const status = await readTenantDesiredStateStatus(api, [], TENANT_ID); - const steps = desiredStateSteps(status); - expect(steps.length).toBe( - TENANT_DESIRED_STATE.workflows.length + - TENANT_DESIRED_STATE.toolPackages.length + - TENANT_DESIRED_STATE.skills.length, - ); - expect(steps[0]?.name).toBe(SETUP_AGENT_ASSET_NAME); - expect(steps[0]?.status).toBe("pending"); - expect(typeof steps[0]?.label).toBe("string"); - expect(steps.every((s) => ["present", "pending", "blocked"].includes(s.status))).toBe(true); - }); -}); diff --git a/packages/onboarding/test/genesis.test.ts b/packages/onboarding/test/genesis.test.ts deleted file mode 100644 index 825bb25270..0000000000 --- a/packages/onboarding/test/genesis.test.ts +++ /dev/null @@ -1,375 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ApiCall } from "@corbits/hub-api-client"; -import { - genesisOrJoinHubSignup, - ProvisionError, - type GenesisOrJoinArgs, - type HubSignupTenancy, -} from "../src/genesis"; - -const TENANT_ID = "ten_root"; -const TENANT_SLUG = "workbench"; -const PRINCIPAL_ID = "prn_owner"; -const MEMBER_PRINCIPAL_ID = "prn_member"; - -function collector() { - const lines: string[] = []; - return { lines, log: (line: string) => lines.push(line) }; -} - -function tenancy(state: { - users?: number; - tenants?: number; - root?: { id: string; slug: string } | null; - joins?: { tenantId: string; userId: string; roleName: string }[]; -}): HubSignupTenancy { - const joins = state.joins ?? []; - return { - countUsers: async () => state.users ?? 0, - countTenants: async () => state.tenants ?? 0, - findRootTenant: async () => (state.root === undefined ? null : state.root), - addActiveMember: async (args) => { - joins.push(args); - return { principalId: MEMBER_PRINCIPAL_ID }; - }, - }; -} - -function principalsResponse( - rows: { - principalId: string; - tenantId: string; - tenantSlug: string; - }[], -) { - return { - status: 200, - data: { - data: rows.map((row) => ({ - principalId: row.principalId, - tenantId: row.tenantId, - tenantName: "Workbench", - tenantSlug: row.tenantSlug, - kind: "user", - status: "active", - roles: [], - })), - nextCursor: null, - }, - cookies: [], - }; -} - -function argsFor(partial: { - api: ApiCall; - tenancy: HubSignupTenancy; - displayName?: string; - log?: (line: string) => void; - userEmailVerified?: boolean; - userEmail?: string; -}): GenesisOrJoinArgs { - const args: GenesisOrJoinArgs = { - api: partial.api, - cookies: ["session=abc"], - userId: "user_1", - userEmail: partial.userEmail ?? "alice@example.com", - userEmailVerified: partial.userEmailVerified ?? true, - defaultTenantSlug: TENANT_SLUG, - tenancy: partial.tenancy, - log: partial.log ?? collector().log, - }; - if (partial.displayName !== undefined) args.displayName = partial.displayName; - return args; -} - -describe("genesisOrJoinHubSignup", () => { - test("nonempty principals is existing-member: no tenant create, no join, no seed", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - let tenantCreates = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreates += 1; - throw new Error("must not mint a tenant for an existing member"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ - users: 1, - tenants: 1, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - displayName: "Acme", - }), - ); - - expect(result).toEqual({ kind: "existing-member" }); - expect(tenantCreates).toBe(0); - expect(joins).toEqual([]); - }); - - test("an existing tenant joins the caller as member and never POSTs /api/tenants", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - throw new Error("join must never mint a tenant"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ - users: 2, - tenants: 1, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - displayName: "Bob", - }), - ); - - expect(result).toEqual({ - kind: "joined", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: MEMBER_PRINCIPAL_ID, - }); - expect(joins).toEqual([{ tenantId: TENANT_ID, userId: "user_1", roleName: "member" }]); - }); - - test("countUsers > 1 with zero tenants still lets the caller genesis", async () => { - let tenantCreates = 0; - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - let principalsCalls = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) return principalsResponse([]); - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreates += 1; - return { - status: 201, - data: { - id: TENANT_ID, - name: "Acme", - 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}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 2, tenants: 0, root: null, joins }), - displayName: "Acme", - }), - ); - - expect(result).toEqual({ - kind: "genesis", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }); - expect(tenantCreates).toBe(1); - expect(joins).toEqual([]); - }); - - test("zero tenants and no display name returns needs-onboarding and creates nothing", async () => { - let tenantCreates = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreates += 1; - throw new Error("must not create without a display name"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - }), - ); - - expect(result).toEqual({ kind: "needs-onboarding" }); - expect(tenantCreates).toBe(0); - }); - - test("blank display name is needs-onboarding", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: " ", - }), - ); - - expect(result).toEqual({ kind: "needs-onboarding" }); - }); - - test("empty hub genesis POSTs /api/tenants with name and default slug and no parentId", async () => { - let principalsCalls = 0; - const bodies: unknown[] = []; - const api: ApiCall = async (method, path, body) => { - if (path.includes("/assets") || path.includes("/workflows")) { - throw new Error(`signup must not seed: ${method} ${path}`); - } - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) return principalsResponse([]); - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - bodies.push(body); - return { - status: 201, - data: { - id: TENANT_ID, - name: "Acme", - 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}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: "Acme", - }), - ); - - expect(result).toEqual({ - kind: "genesis", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }); - expect(bodies).toEqual([{ name: "Acme", slug: TENANT_SLUG }]); - }); - - test("genesis 409 when a root exists joins as member", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 409, - data: { error: { code: "conflict", message: "Slug already taken" } }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ - users: 1, - tenants: 0, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - displayName: "Acme", - }), - ); - - expect(result).toEqual({ - kind: "joined", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - principalId: MEMBER_PRINCIPAL_ID, - }); - expect(joins).toHaveLength(1); - }); - - test("genesis 409 with no root and no principal is slug_conflict_no_principal", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 409, - data: { error: { code: "conflict", message: "Slug already taken" } }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - await expect( - genesisOrJoinHubSignup( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0, root: null }), - displayName: "Acme", - }), - ), - ).rejects.toMatchObject({ - name: "ProvisionError", - code: "slug_conflict_no_principal", - }); - }); -}); - -test("ProvisionError is constructible for route mapping", () => { - const error = new ProvisionError("tenant_create_failed", "nope", "transient"); - expect(error.errorKind).toBe("transient"); -}); diff --git a/packages/onboarding/test/huggingface-connect-routes.test.ts b/packages/onboarding/test/huggingface-connect-routes.test.ts deleted file mode 100644 index 11cbf192b1..0000000000 --- a/packages/onboarding/test/huggingface-connect-routes.test.ts +++ /dev/null @@ -1,656 +0,0 @@ -// The Hugging Face connect routes' contract with the browser: /start -// parks a single-use state in an HttpOnly cookie and sends the user to -// HF's consent page with a real S256 challenge, a client id, and the -// requested scope; /callback only ever exchanges a code whose state -// round-tripped intact (both in the cookie and echoed back in the -// query string — HF, unlike OpenRouter, supports `state`), and runs -// only the fast half — `connectCredential` proves and persists the -// token, never deploys a workflow — before redirecting. Cross-user/ -// replay/single-use/duplicate-callback-recovery guarantees mirror -// `openrouter-connect-routes.test.ts`'s coverage for the same shape. -import { describe, expect, test } from "bun:test"; -import type { AppEnv } from "@intx/hub-api"; -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; -import { createEnvKeyCredentialCipher, createNoopCredentialCipher } from "@intx/crypto"; -import { createOnboardingRoutes } from "../src/routes"; -import type { CreateOnboardingRoutesDeps } from "../src/routes"; -import { testAndPersistCredential } from "../src/complete-credential"; -import { s256Challenge } from "@corbits/connections"; -import { createInMemoryPendingSeedStore, type PendingSeedStore } from "../src/pending-seed"; - -// Stands in for a stable `CREDENTIAL_ENCRYPTION_KEY`: a fresh cipher -// built from these same bytes is indistinguishable, to the state store, -// from the cipher a still-running process already had — which is -// exactly what a restart needs to be true. -const RESTART_STABLE_KEY = Buffer.alloc(32, 5); - -const MOCK_TIMESTAMP = "2026-01-01T00:00:00.000Z"; - -/** Mirrors `openrouter-connect-routes.test.ts`'s `mockHub` — enough of - * the hub for `testAndPersistCredential`'s real fast half to run - * end to end for a Hugging Face connection, tracking the credential it - * plants so the duplicate-callback recovery check can find it. Never - * wires up a workflow-deploy route at all. */ -function mockHub() { - const credentials: { - id: string; - tenantId: string; - providerId: string; - name: string; - type: string; - status: string; - metadata: unknown; - createdAt: string; - updatedAt: string; - }[] = []; - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: "prn_1", - tenantId: "ten_1", - tenantName: "user_1's workbench", - tenantSlug: "user-1-user1", - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - hub.get("/api/tenants/ten_1", (c) => - c.json({ - id: "ten_1", - name: "user_1's workbench", - slug: "user-1-user1", - domain: "user-1-user1.bench.local", - parentId: null, - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }), - ); - hub.post("/api/tenants/ten_1/catalog/models", (c) => - c.json( - { - id: "mdl_1", - tenantId: "ten_1", - canonicalName: "deepseek-ai/DeepSeek-V4-Flash", - disabled: false, - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }, - 201, - ), - ); - hub.post("/api/tenants/ten_1/providers", (c) => - c.json( - { - id: "prv_1", - tenantId: "ten_1", - name: "huggingface", - plugin: "openai-compatible", - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }, - 201, - ), - ); - hub.post("/api/tenants/ten_1/credentials", (c) => { - const row = { - id: "cre_1", - tenantId: "ten_1", - providerId: "prv_1", - name: "huggingface-default", - type: "oauth_token", - status: "active", - metadata: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - credentials.push(row); - return c.json(row, 201); - }); - hub.get("/api/tenants/ten_1/credentials", (c) => c.json({ data: credentials, nextCursor: null })); - hub.post("/api/tenants/ten_1/catalog/providers", (c) => - c.json( - { - id: "cpv_1", - tenantId: "ten_1", - name: "huggingface", - plugin: "openai-compatible", - baseURL: "https://router.huggingface.co/v1", - credentialId: "cre_1", - disabled: false, - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }, - 201, - ), - ); - hub.post("/api/tenants/ten_1/catalog/offerings", (c) => - c.json( - { - id: "off_1", - tenantId: "ten_1", - modelId: "mdl_1", - providerId: "cpv_1", - priority: 0, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }, - 201, - ), - ); - hub.get("/api/tenants/ten_1/catalog/offerings", (c) => c.json({ data: [], nextCursor: null })); - return hub; -} - -/** The fast half, run for real — CL-6123 dropped the probe that used to - * need stubbing out here, so this now just forwards to the real - * `testAndPersistCredential` unchanged. */ -const connectCredentialAgainstMockHub: NonNullable< - NonNullable["connectCredential"] -> = (args) => testAndPersistCredential(args); - -function asUser(session: { userId: string }): MiddlewareHandler { - return async (c, next) => { - c.set("user", { - id: session.userId, - email: `${session.userId}@example.com`, - } as never); - await next(); - }; -} - -function mountAuthenticated(routes: Hono, session: { userId: string }): Hono { - const app = new Hono(); - app.use("*", asUser(session)); - app.route("/api/onboarding", routes); - return app; -} - -function connectRoutes( - overrides: Partial & { - readonly omitClientId?: boolean; - } = {}, - session: { userId: string } = { userId: "user_1" }, -): Hono { - const deps: CreateOnboardingRoutesDeps = { - hubUrl: overrides.hubUrl ?? "https://bench.example.com", - // This suite never exercises the genesis-or-join join path; the - // stub satisfies the required tenancy wiring without a DB. - tenancy: { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("this suite never exercises the join path"); - }, - }, - defaultTenantSlug: "workbench", - pushWorkflow: - overrides.pushWorkflow ?? - (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), - log: overrides.log ?? (() => undefined), - pendingSeedStore: - overrides.pendingSeedStore ?? createInMemoryPendingSeedStore(createNoopCredentialCipher()), - }; - if (overrides.omitClientId !== true) { - deps.huggingfaceClientId = overrides.huggingfaceClientId ?? "hf_client_1"; - } - if (overrides.huggingfaceConnect !== undefined) - deps.huggingfaceConnect = overrides.huggingfaceConnect; - if (overrides.credentialCipher !== undefined) deps.credentialCipher = overrides.credentialCipher; - return mountAuthenticated(createOnboardingRoutes(deps), session); -} - -function stateCookie(startResponse: Response): string { - const setCookie = startResponse.headers.get("set-cookie") ?? ""; - const match = /workbench_huggingface_connect=([^;]+)/.exec(setCookie); - if (!match?.[1]) throw new Error(`no state cookie in: ${setCookie}`); - return `workbench_huggingface_connect=${match[1]}`; -} - -async function startConnect(app: Hono) { - const response = await app.request("/api/onboarding/oauth/huggingface/start"); - expect(response.status).toBe(302); - const location = new URL(response.headers.get("location") ?? ""); - return { response, location }; -} - -describe("GET /oauth/huggingface/start", () => { - test("redirects to Hugging Face with an S256 challenge, client id, scope, and echoed state", async () => { - const app = connectRoutes({ hubUrl: "https://bench.example.com" }); - - const { response, location } = await startConnect(app); - - expect(location.origin).toBe("https://huggingface.co"); - expect(location.pathname).toBe("/oauth/authorize"); - expect(location.searchParams.get("client_id")).toBe("hf_client_1"); - expect(location.searchParams.get("scope")).toBe("openid inference-api"); - expect(location.searchParams.get("code_challenge_method")).toBe("S256"); - expect(location.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(location.searchParams.get("redirect_uri")).toBe( - "https://bench.example.com/api/onboarding/oauth/huggingface/callback", - ); - const state = location.searchParams.get("state"); - expect(state).toBeTruthy(); - const setCookie = response.headers.get("set-cookie") ?? ""; - // The state cookie carries the sealed state as its value; Hono - // percent-encodes it in `Set-Cookie`, so compare decoded rather than - // as a raw substring. - const cookieMatch = /workbench_huggingface_connect=([^;]+)/.exec(setCookie); - expect(cookieMatch?.[1] !== undefined ? decodeURIComponent(cookieMatch[1]) : undefined).toBe( - state ?? undefined, - ); - expect(setCookie).toContain("HttpOnly"); - }); - - test("without a configured client id, the flow reports not_configured and parks nothing", async () => { - const app = connectRoutes({ omitClientId: true }); - - const response = await app.request("/api/onboarding/oauth/huggingface/start"); - - expect(response.status).toBe(302); - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("connect")).toBe("huggingface"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("not_configured"); - expect(response.headers.get("set-cookie")).toBeNull(); - }); - - test("rapid connect starts from the same user are rate-limited", async () => { - const app = connectRoutes(); - - await startConnect(app); - const second = await app.request("/api/onboarding/oauth/huggingface/start"); - - expect(second.status).toBe(302); - const redirect = new URL(second.headers.get("location") ?? "", "https://bench.example.com"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("rate_limited"); - }); -}); - -describe("GET /oauth/huggingface/callback", () => { - test("happy path: exchanges the code with the verifier behind the challenge, connects with expiry metadata, and reports success without deploying anything", async () => { - const exchanges: { - code: string; - codeVerifier: string; - redirectUri: string; - clientId: string; - }[] = []; - const connections: { - provider: string; - apiKey: string; - userId: string; - expiresAt?: string; - credentialMetadata?: Record; - }[] = []; - const pendingSeedStore: PendingSeedStore = createInMemoryPendingSeedStore( - createNoopCredentialCipher(), - ); - const app = connectRoutes({ - pendingSeedStore, - huggingfaceConnect: { - exchange: async ({ code, codeVerifier, redirectUri, clientId }) => { - exchanges.push({ code, codeVerifier, redirectUri, clientId }); - return { - ok: true, - accessToken: "hf_oauth_minted", - expiresAt: "2026-08-13T20:00:00.000Z", - }; - }, - connectCredential: async (args) => { - const connection = { - provider: args.provider, - apiKey: args.apiKey, - userId: args.userId, - ...(args.expiresAt !== undefined ? { expiresAt: args.expiresAt } : {}), - }; - connections.push( - args.credentialMetadata !== undefined - ? { ...connection, credentialMetadata: args.credentialMetadata } - : connection, - ); - return { - kind: "connected", - tenantId: "ten_1", - tenantSlug: "alice-user1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - }; - }, - }, - }); - - const { response: started, location } = await startConnect(app); - const cookie = stateCookie(started); - const state = location.searchParams.get("state") ?? ""; - - const response = await app.request( - `/api/onboarding/oauth/huggingface/callback?code=auth_code_1&state=${encodeURIComponent(state)}`, - { headers: { cookie } }, - ); - - expect(response.status).toBe(302); - const redirect = new URL(response.headers.get("location") ?? "", "https://bench.example.com"); - expect(redirect.pathname).toBe("/onboarding"); - expect(redirect.searchParams.get("connect")).toBe("huggingface"); - expect(redirect.searchParams.get("outcome")).toBe("connected"); - expect(redirect.searchParams.get("tenantSlug")).toBe("alice-user1"); - // The minted token reaches the connect path but never a URL. - expect(redirect.toString()).not.toContain("hf_oauth_minted"); - - expect(exchanges).toHaveLength(1); - expect(exchanges[0]?.code).toBe("auth_code_1"); - expect(exchanges[0]?.clientId).toBe("hf_client_1"); - expect(exchanges[0]?.redirectUri).toBe( - "https://bench.example.com/api/onboarding/oauth/huggingface/callback", - ); - expect(exchanges[0] && (await s256Challenge(exchanges[0].codeVerifier))).toBe( - location.searchParams.get("code_challenge") ?? "", - ); - - expect(connections).toEqual([ - { - provider: "huggingface", - apiKey: "hf_oauth_minted", - userId: "user_1", - // The first-class field through the onboarding wrapper — the - // hop that used to drop the exchange's expiry, leaving the - // row's expires_at column NULL (CL-7508). - expiresAt: "2026-08-13T20:00:00.000Z", - credentialMetadata: { expiresAt: "2026-08-13T20:00:00.000Z" }, - }, - ]); - - // The plaintext token is carried forward for the deferred deploy - // step server-side, in the pending-seed store — never as a cookie - // (this response still clears the connect-state cookies, but never - // sets the pre-CL-6031 `workbench_pending_seed` one) or a redirect - // query parameter. - const setCookie = response.headers.get("set-cookie") ?? ""; - expect(setCookie).not.toContain("workbench_pending_seed="); - const pending = await pendingSeedStore.read({ - userId: "user_1", - tenantId: "ten_1", - }); - expect(pending).toEqual({ - userId: "user_1", - tenantId: "ten_1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - provider: "huggingface", - apiKey: "hf_oauth_minted", - }); - }); - - test("a callback whose query state disagrees with the cookie never exchanges", async () => { - let exchanged = 0; - const app = connectRoutes({ - huggingfaceConnect: { - exchange: async () => { - exchanged += 1; - return { ok: true, accessToken: "hf_oauth_minted" }; - }, - }, - }); - const { response: started } = await startConnect(app); - const cookie = stateCookie(started); - - const response = await app.request( - "/api/onboarding/oauth/huggingface/callback?code=auth_code_1&state=not-the-real-state", - { headers: { cookie } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("state_expired"); - expect(exchanged).toBe(0); - }); - - test("another user's session cannot redeem a stolen state cookie", async () => { - let exchanged = 0; - let connected = 0; - const session = { userId: "user_1" }; - const app = connectRoutes( - { - huggingfaceConnect: { - exchange: async () => { - exchanged += 1; - return { ok: true, accessToken: "hf_oauth_minted" }; - }, - connectCredential: async () => { - connected += 1; - return { - kind: "connected", - tenantId: "ten_1", - tenantSlug: "alice-user1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - }; - }, - }, - }, - session, - ); - const { response: started, location } = await startConnect(app); - const cookie = stateCookie(started); - const state = location.searchParams.get("state") ?? ""; - - session.userId = "user_2"; - const response = await app.request( - `/api/onboarding/oauth/huggingface/callback?code=auth_code_1&state=${encodeURIComponent(state)}`, - { headers: { cookie } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("state_expired"); - expect(exchanged).toBe(0); - expect(connected).toBe(0); - }); - - test("a duplicate callback with the same code recovers as connected instead of state_expired", async () => { - // Mirrors the OpenRouter regression test: a browser that fires this - // exact callback twice must not see `state_expired` for a - // connection that actually succeeded. - const server = Bun.serve({ port: 0, fetch: mockHub().fetch }); - try { - const app = connectRoutes({ - hubUrl: `http://localhost:${server.port}`, - huggingfaceConnect: { - exchange: async () => ({ ok: true, accessToken: "hf_oauth_minted" }), - connectCredential: connectCredentialAgainstMockHub, - }, - }); - const { response: started, location } = await startConnect(app); - const cookie = stateCookie(started); - const state = location.searchParams.get("state") ?? ""; - const path = `/api/onboarding/oauth/huggingface/callback?code=auth_code_1&state=${encodeURIComponent(state)}`; - - const first = await app.request(path, { headers: { cookie } }); - const second = await app.request(path, { headers: { cookie } }); - - expect( - new URL(first.headers.get("location") ?? "", "https://x").searchParams.get("outcome"), - ).toBe("connected"); - const secondRedirect = new URL(second.headers.get("location") ?? "", "https://x"); - expect(secondRedirect.searchParams.get("outcome")).toBe("connected"); - expect(secondRedirect.searchParams.get("tenantSlug")).toBe("user-1-user1"); - } finally { - server.stop(true); - } - }); - - test("survives a hub restart between /start and /callback, and still recovers as connected after it", async () => { - // /start runs against the pre-restart app; a fresh app (a new - // `createOnboardingRoutes` call, a fresh in-memory state store, the - // works) stands in for the process that comes back up after a - // restart. The only thing they share is the cipher key — exactly - // what a stable `CREDENTIAL_ENCRYPTION_KEY` gives a real restart. - const beforeRestart = connectRoutes({ - credentialCipher: createEnvKeyCredentialCipher(RESTART_STABLE_KEY), - }); - const { response: started, location } = await startConnect(beforeRestart); - const cookie = stateCookie(started); - const state = location.searchParams.get("state") ?? ""; - - const server = Bun.serve({ port: 0, fetch: mockHub().fetch }); - try { - const afterRestart = connectRoutes({ - hubUrl: `http://localhost:${server.port}`, - credentialCipher: createEnvKeyCredentialCipher(RESTART_STABLE_KEY), - huggingfaceConnect: { - exchange: async () => ({ ok: true, accessToken: "hf_oauth_minted" }), - connectCredential: connectCredentialAgainstMockHub, - }, - }); - const path = `/api/onboarding/oauth/huggingface/callback?code=auth_code_1&state=${encodeURIComponent(state)}`; - - const first = await afterRestart.request(path, { headers: { cookie } }); - expect( - new URL(first.headers.get("location") ?? "", "https://x").searchParams.get("outcome"), - ).toBe("connected"); - - // Replaying the same cookie and query state against the - // post-restart app — no new /start — recovers as connected too, - // since the first request's connect already persisted the - // credential. - const replay = await afterRestart.request(path, { headers: { cookie } }); - expect( - new URL(replay.headers.get("location") ?? "", "https://x").searchParams.get("outcome"), - ).toBe("connected"); - } finally { - server.stop(true); - } - }); - - test("an exchange failure is reported as such and never reaches connect", async () => { - let connected = 0; - const lines: string[] = []; - const app = connectRoutes({ - log: (line) => lines.push(line), - huggingfaceConnect: { - exchange: async () => ({ - ok: false, - message: "Hugging Face rejected the code exchange with status 400", - }), - connectCredential: async () => { - connected += 1; - return { - kind: "connected", - tenantId: "ten_1", - tenantSlug: "alice-user1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - }; - }, - }, - }); - const { response: started, location } = await startConnect(app); - const state = location.searchParams.get("state") ?? ""; - - const response = await app.request( - `/api/onboarding/oauth/huggingface/callback?code=expired_code&state=${encodeURIComponent(state)}`, - { headers: { cookie: stateCookie(started) } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("exchange_failed"); - expect(connected).toBe(0); - expect(lines.some((line) => line.includes("code exchange failed"))).toBe(true); - }); - - test("a minted token that fails its probe is a key_rejected ending, not a success", async () => { - const app = connectRoutes({ - huggingfaceConnect: { - exchange: async () => ({ ok: true, accessToken: "hf_oauth_minted" }), - connectCredential: async () => ({ - kind: "invalid-credential", - message: "invalid token", - }), - }, - }); - const { response: started, location } = await startConnect(app); - const state = location.searchParams.get("state") ?? ""; - - const response = await app.request( - `/api/onboarding/oauth/huggingface/callback?code=auth_code_1&state=${encodeURIComponent(state)}`, - { headers: { cookie: stateCookie(started) } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("key_rejected"); - }); - - test("a thrown connect failure surfaces honestly without leaking the token", async () => { - const lines: string[] = []; - const app = connectRoutes({ - log: (line) => lines.push(line), - huggingfaceConnect: { - exchange: async () => ({ ok: true, accessToken: "hf_oauth_minted" }), - connectCredential: async () => { - throw new Error("the hub rejected the credential"); - }, - }, - }); - const { response: started, location } = await startConnect(app); - const state = location.searchParams.get("state") ?? ""; - - const response = await app.request( - `/api/onboarding/oauth/huggingface/callback?code=auth_code_1&state=${encodeURIComponent(state)}`, - { headers: { cookie: stateCookie(started) } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("code")).toBe("setup_failed"); - expect(lines.join("\n")).not.toContain("hf_oauth_minted"); - }); - - test("never triggers a workflow deploy call — the fast half only proves and persists the token", async () => { - let deployPosts = 0; - const hub = mockHub(); - hub.post("/api/tenants/:tenantId/workflows/deployments", (c) => { - deployPosts += 1; - return c.json({ error: "unexpected deploy call" }, 500); - }); - hub.post("/api/tenants/:tenantId/assets", (c) => { - deployPosts += 1; - return c.json({ error: "unexpected asset call" }, 500); - }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const app = connectRoutes({ - hubUrl: `http://localhost:${server.port}`, - huggingfaceConnect: { - exchange: async () => ({ ok: true, accessToken: "hf_oauth_minted" }), - connectCredential: connectCredentialAgainstMockHub, - }, - }); - const { response: started, location } = await startConnect(app); - const state = location.searchParams.get("state") ?? ""; - - const response = await app.request( - `/api/onboarding/oauth/huggingface/callback?code=auth_code_1&state=${encodeURIComponent(state)}`, - { headers: { cookie: stateCookie(started) } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("connected"); - expect(deployPosts).toBe(0); - } finally { - server.stop(true); - } - }); -}); diff --git a/packages/onboarding/test/migrations.test.ts b/packages/onboarding/test/migrations.test.ts deleted file mode 100644 index ace4670f49..0000000000 --- a/packages/onboarding/test/migrations.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring @corbits/insights' migrations -// test. Runs against its own scratch database, never the developer's -// or the walking-skeleton suite's. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import postgres from "postgres"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyOnboardingMigrations } from "../src/migrations"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_onboarding_migrations_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const migrationNames = ["0001_pending_seed"]; - -describeIfDb("applyOnboardingMigrations", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - test("applies the pending_seed table into its own schema and is idempotent on a second run", async () => { - const first = await applyOnboardingMigrations(scratchUrl); - expect(first.applied).toEqual(migrationNames); - - const second = await applyOnboardingMigrations(scratchUrl); - expect(second.applied).toEqual([]); - expect(second.alreadyApplied).toEqual(migrationNames); - - const sql = postgres(scratchUrl, { max: 1, onnotice: () => undefined }); - try { - const tables = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'onboarding' AND table_name = 'pending_seed'`, - ); - expect(tables.map((row) => String(row["table_name"]))).toEqual(["pending_seed"]); - - const inPublic = await sql.unsafe( - `SELECT table_name FROM information_schema.tables ` + - `WHERE table_schema = 'public' AND table_name = 'pending_seed'`, - ); - expect(inPublic).toHaveLength(0); - - const primaryKeyColumns = await sql.unsafe( - `SELECT a.attname FROM pg_index i ` + - `JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) ` + - `WHERE i.indrelid = 'onboarding.pending_seed'::regclass AND i.indisprimary`, - ); - expect(primaryKeyColumns.map((row) => String(row["attname"])).sort()).toEqual([ - "tenant_id", - "user_id", - ]); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/onboarding/test/openrouter-connect-routes.test.ts b/packages/onboarding/test/openrouter-connect-routes.test.ts deleted file mode 100644 index 7fee04d13a..0000000000 --- a/packages/onboarding/test/openrouter-connect-routes.test.ts +++ /dev/null @@ -1,713 +0,0 @@ -// The connect routes' contract with the browser: /start parks a -// single-use state in an HttpOnly cookie and sends the user to -// OpenRouter's consent page with a real S256 challenge over a -// hub-origin callback URL; /callback only ever exchanges a code whose -// state round-tripped intact, and runs only the fast half — -// `connectCredential` proves and persists the key, never deploys a -// workflow — before redirecting. Every ending — connected, state gone -// stale, exchange refused — lands back in the wizard as query -// parameters with no key material in any URL. -import { describe, expect, test } from "bun:test"; -import type { AppEnv } from "@intx/hub-api"; -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; -import { createEnvKeyCredentialCipher, createNoopCredentialCipher } from "@intx/crypto"; -import { createOnboardingRoutes } from "../src/routes"; -import type { CreateOnboardingRoutesDeps } from "../src/routes"; -import { testAndPersistCredential } from "../src/complete-credential"; -import { s256Challenge } from "../src/openrouter-connect"; -import { createInMemoryPendingSeedStore, type PendingSeedStore } from "../src/pending-seed"; - -const MOCK_TIMESTAMP = "2026-01-01T00:00:00.000Z"; - -/** - * A minimal, stateful stand-in for the hub: enough of - * `/api/me/principals`, `/api/tenants/:id`, and the catalog-seed POST - * routes for `testAndPersistCredential`'s real (unmocked) fast half to - * run end to end, tracking the credential it plants so a later - * `GET .../credentials` — the duplicate-callback recovery check — can - * see it. Never wires up a `/workflows/deployments` route at all: a - * test that reaches one fails loudly with a 404, which is exactly the - * proof this suite wants that the fast half never asks for a deploy. - */ -function mockHub(args: { tenantSlug?: string } = {}) { - // `tenantSlug` doubles as the seeded-admin shape: set it to "acme" and - // the only principal is the root bench, whose slug never equals the - // signed-in user's computed personal-bench slug (CL-7506). - const tenantSlug = args.tenantSlug ?? "user-1-user1"; - const tenantDomain = `${tenantSlug}.bench.local`; - const credentials: { - id: string; - tenantId: string; - providerId: string; - name: string; - type: string; - status: string; - metadata: unknown; - createdAt: string; - updatedAt: string; - }[] = []; - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: "prn_1", - tenantId: "ten_1", - tenantName: "Alice's workbench", - tenantSlug, - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - hub.get("/api/tenants/ten_1", (c) => - c.json({ - id: "ten_1", - name: "Alice's workbench", - slug: tenantSlug, - domain: tenantDomain, - parentId: null, - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }), - ); - hub.post("/api/tenants/ten_1/catalog/models", (c) => - c.json( - { - id: "mdl_1", - tenantId: "ten_1", - canonicalName: "anthropic/claude-sonnet-5", - disabled: false, - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }, - 201, - ), - ); - hub.post("/api/tenants/ten_1/providers", (c) => - c.json( - { - id: "prv_1", - tenantId: "ten_1", - name: "openrouter", - plugin: "openai-compatible", - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }, - 201, - ), - ); - hub.post("/api/tenants/ten_1/credentials", (c) => { - const row = { - id: "cre_1", - tenantId: "ten_1", - providerId: "prv_1", - name: "openrouter-default", - type: "api_key", - status: "active", - metadata: null, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - credentials.push(row); - return c.json(row, 201); - }); - hub.get("/api/tenants/ten_1/credentials", (c) => c.json({ data: credentials, nextCursor: null })); - hub.post("/api/tenants/ten_1/catalog/providers", (c) => - c.json( - { - id: "cpv_1", - tenantId: "ten_1", - name: "openrouter", - plugin: "openai-compatible", - baseURL: "https://openrouter.ai/api/v1", - credentialId: "cre_1", - disabled: false, - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }, - 201, - ), - ); - hub.post("/api/tenants/ten_1/catalog/offerings", (c) => - c.json( - { - id: "off_1", - tenantId: "ten_1", - modelId: "mdl_1", - providerId: "cpv_1", - priority: 0, - deploymentTags: [], - capabilities: [], - quirks: null, - disabled: false, - createdAt: MOCK_TIMESTAMP, - updatedAt: MOCK_TIMESTAMP, - }, - 201, - ), - ); - hub.get("/api/tenants/ten_1/catalog/offerings", (c) => c.json({ data: [], nextCursor: null })); - return hub; -} - -/** The fast half, run for real — CL-6123 dropped the probe that used to - * need stubbing out here, so this now just forwards to the real - * `testAndPersistCredential` unchanged. */ -const connectCredentialAgainstMockHub: NonNullable< - NonNullable["connectCredential"] -> = (args) => testAndPersistCredential(args); - -// Stands in for a stable `CREDENTIAL_ENCRYPTION_KEY`: a fresh cipher -// built from these same bytes is indistinguishable, to the state store, -// from the cipher a still-running process already had — which is -// exactly what a restart needs to be true. -const RESTART_STABLE_KEY = Buffer.alloc(32, 3); - -// The signed-in user is read per request from a mutable session, so a -// test can swap identities mid-flow — the cross-user callback guarantee -// has to be pinned at the HTTP layer, not only in the state store. -function asUser(session: { userId: string }): MiddlewareHandler { - return async (c, next) => { - c.set("user", { - id: session.userId, - email: `${session.userId}@example.com`, - } as never); - await next(); - }; -} - -function mountAuthenticated(routes: Hono, session: { userId: string }): Hono { - const app = new Hono(); - app.use("*", asUser(session)); - app.route("/api/onboarding", routes); - return app; -} - -function connectRoutes( - overrides: Partial = {}, - session: { userId: string } = { userId: "user_1" }, -): Hono { - const deps: CreateOnboardingRoutesDeps = { - hubUrl: overrides.hubUrl ?? "https://bench.example.com", - // This suite never exercises the genesis-or-join join path; the - // stub satisfies the required tenancy wiring without a DB. - tenancy: { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("this suite never exercises the join path"); - }, - }, - defaultTenantSlug: "workbench", - pushWorkflow: - overrides.pushWorkflow ?? - (async () => ({ outcome: "pushed" as const, commitSha: "a".repeat(40) })), - log: overrides.log ?? (() => undefined), - pendingSeedStore: - overrides.pendingSeedStore ?? createInMemoryPendingSeedStore(createNoopCredentialCipher()), - }; - if (overrides.openrouterConnect !== undefined) - deps.openrouterConnect = overrides.openrouterConnect; - if (overrides.credentialCipher !== undefined) deps.credentialCipher = overrides.credentialCipher; - return mountAuthenticated(createOnboardingRoutes(deps), session); -} - -function stateCookie(startResponse: Response): string { - const setCookie = startResponse.headers.get("set-cookie") ?? ""; - const match = /workbench_openrouter_connect=([^;]+)/.exec(setCookie); - if (!match?.[1]) throw new Error(`no state cookie in: ${setCookie}`); - return `workbench_openrouter_connect=${match[1]}`; -} - -async function startConnect(app: Hono) { - const response = await app.request("/api/onboarding/oauth/openrouter/start"); - expect(response.status).toBe(302); - const location = new URL(response.headers.get("location") ?? ""); - return { response, location }; -} - -describe("GET /oauth/openrouter/start", () => { - test("redirects to OpenRouter with an S256 challenge and the hub-origin callback", async () => { - const app = connectRoutes({ hubUrl: "https://bench.example.com" }); - - const { response, location } = await startConnect(app); - - expect(location.origin).toBe("https://openrouter.ai"); - expect(location.pathname).toBe("/auth"); - expect(location.searchParams.get("code_challenge_method")).toBe("S256"); - expect(location.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/); - expect(location.searchParams.get("callback_url")).toBe( - "https://bench.example.com/api/onboarding/oauth/openrouter/callback", - ); - const setCookie = response.headers.get("set-cookie") ?? ""; - expect(setCookie).toContain("workbench_openrouter_connect="); - expect(setCookie).toContain("HttpOnly"); - }); - - // CL-6394: onboarding's OAuth mount serves ONLY its own first-login - // providers. A GitHub start here must refuse loudly — before this, - // github fell through onboarding's inference-only persistence and - // crashed AFTER a successful token exchange. The GitHub App connect - // lives on the tenant-scoped `connections/oauth` mount instead. - test("a github start on the onboarding mount is a loud 404, never a fall-through", async () => { - const app = connectRoutes(); - - const response = await app.request("/api/onboarding/oauth/github/start"); - - expect(response.status).toBe(404); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("not_found"); - }); - - test("derives the callback origin from configuration, not the request host", async () => { - const app = connectRoutes({ hubUrl: "http://localhost:3000" }); - - const { location } = await startConnect(app); - - expect(location.searchParams.get("callback_url")).toBe( - "http://localhost:3000/api/onboarding/oauth/openrouter/callback", - ); - }); - - test("rapid connect starts from the same user are rate-limited", async () => { - const app = connectRoutes(); - - await startConnect(app); - const second = await app.request("/api/onboarding/oauth/openrouter/start"); - - expect(second.status).toBe(302); - const redirect = new URL(second.headers.get("location") ?? "", "https://bench.example.com"); - expect(redirect.pathname).toBe("/onboarding"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("rate_limited"); - // A rate-limited start parks nothing: no state cookie is set. - expect(second.headers.get("set-cookie")).toBeNull(); - }); -}); - -describe("GET /oauth/openrouter/callback", () => { - test("happy path: exchanges the code with the verifier behind the challenge, connects, and reports success without deploying anything", async () => { - const exchanges: { code: string; codeVerifier: string }[] = []; - const connections: { - provider: string; - apiKey: string; - userId: string; - }[] = []; - const pendingSeedStore: PendingSeedStore = createInMemoryPendingSeedStore( - createNoopCredentialCipher(), - ); - const app = connectRoutes({ - pendingSeedStore, - openrouterConnect: { - exchange: async ({ code, codeVerifier }) => { - exchanges.push({ code, codeVerifier }); - return { ok: true, key: "sk-or-v1-minted" }; - }, - connectCredential: async (args) => { - connections.push({ - provider: args.provider, - apiKey: args.apiKey, - userId: args.userId, - }); - return { - kind: "connected", - tenantId: "ten_1", - tenantSlug: "alice-user1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - }; - }, - }, - }); - - const { response: started, location } = await startConnect(app); - const cookie = stateCookie(started); - - const response = await app.request( - "/api/onboarding/oauth/openrouter/callback?code=auth_code_1", - { headers: { cookie } }, - ); - - expect(response.status).toBe(302); - const redirect = new URL(response.headers.get("location") ?? "", "https://bench.example.com"); - expect(redirect.pathname).toBe("/onboarding"); - expect(redirect.searchParams.get("connect")).toBe("openrouter"); - expect(redirect.searchParams.get("outcome")).toBe("connected"); - expect(redirect.searchParams.get("tenantSlug")).toBe("alice-user1"); - // The minted key reaches the connect path but never a URL. - expect(redirect.toString()).not.toContain("sk-or-v1-minted"); - - expect(exchanges).toHaveLength(1); - expect(exchanges[0]?.code).toBe("auth_code_1"); - // The verifier handed to the exchange is the one whose S256 was - // sent to OpenRouter at /start — the round trip is real PKCE. - expect(exchanges[0] && (await s256Challenge(exchanges[0].codeVerifier))).toBe( - location.searchParams.get("code_challenge") ?? "", - ); - - // The minted key connects as an ordinary openrouter credential — - // the same generalized path a pasted key takes. - expect(connections).toEqual([ - { provider: "openrouter", apiKey: "sk-or-v1-minted", userId: "user_1" }, - ]); - - // The plaintext key is carried forward for the deferred deploy step - // server-side, in the pending-seed store — never as a cookie (this - // response still clears the connect-state cookies, but never sets - // the pre-CL-6031 `workbench_pending_seed` one) or a redirect query - // parameter. - const setCookie = response.headers.get("set-cookie") ?? ""; - expect(setCookie).not.toContain("workbench_pending_seed="); - const pending = await pendingSeedStore.read({ - userId: "user_1", - tenantId: "ten_1", - }); - expect(pending).toEqual({ - userId: "user_1", - tenantId: "ten_1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - provider: "openrouter", - apiKey: "sk-or-v1-minted", - }); - }); - - test("a callback without the state cookie never exchanges", async () => { - let exchanged = 0; - const app = connectRoutes({ - openrouterConnect: { - exchange: async () => { - exchanged += 1; - return { ok: true, key: "sk-or-v1-minted" }; - }, - }, - }); - await startConnect(app); - - const response = await app.request( - "/api/onboarding/oauth/openrouter/callback?code=auth_code_1", - ); - - expect(response.status).toBe(302); - const redirect = new URL(response.headers.get("location") ?? "", "https://bench.example.com"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("state_expired"); - expect(exchanged).toBe(0); - }); - - test("another user's session cannot redeem a stolen state cookie", async () => { - // Login-CSRF guard: an attacker who lures a victim into finishing - // the attacker's own flow (or replays a leaked cookie) must never - // get a key exchanged or a bench seeded under the wrong session. - // The recovery check (a real credential lookup) is exercised here - // too — for a user that plainly has no such tenant, it comes back - // empty and the honest state_expired ending still wins. - let exchanged = 0; - let connected = 0; - const session = { userId: "user_1" }; - const app = connectRoutes( - { - openrouterConnect: { - exchange: async () => { - exchanged += 1; - return { ok: true, key: "sk-or-v1-minted" }; - }, - connectCredential: async () => { - connected += 1; - return { - kind: "connected", - tenantId: "ten_1", - tenantSlug: "alice-user1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - }; - }, - }, - }, - session, - ); - const { response: started } = await startConnect(app); - const cookie = stateCookie(started); - - session.userId = "user_2"; - const response = await app.request( - "/api/onboarding/oauth/openrouter/callback?code=auth_code_1", - { headers: { cookie } }, - ); - - expect(response.status).toBe(302); - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("state_expired"); - expect(exchanged).toBe(0); - expect(connected).toBe(0); - }); - - test("a duplicate callback with the same code recovers as connected instead of state_expired", async () => { - // The live defect this fix targets: a browser that fires the exact - // same callback twice (one code, two requests) burns the single-use - // state on whichever request wins the race. Before this fix the - // loser reported `state_expired` for a connection that actually - // succeeded. Now it finds the winner's just-persisted credential and - // reports the same `connected` ending. - const server = Bun.serve({ port: 0, fetch: mockHub().fetch }); - try { - const app = connectRoutes({ - hubUrl: `http://localhost:${server.port}`, - openrouterConnect: { - exchange: async () => ({ ok: true, key: "sk-or-v1-minted" }), - connectCredential: connectCredentialAgainstMockHub, - }, - }); - const { response: started } = await startConnect(app); - const cookie = stateCookie(started); - const path = "/api/onboarding/oauth/openrouter/callback?code=auth_code_1"; - - const first = await app.request(path, { headers: { cookie } }); - const second = await app.request(path, { headers: { cookie } }); - - expect( - new URL(first.headers.get("location") ?? "", "https://x").searchParams.get("outcome"), - ).toBe("connected"); - const secondRedirect = new URL(second.headers.get("location") ?? "", "https://x"); - expect(secondRedirect.searchParams.get("outcome")).toBe("connected"); - expect(secondRedirect.searchParams.get("tenantSlug")).toBe("user-1-user1"); - } finally { - server.stop(true); - } - }); - - // CL-7506: the recovery lookup serves the same connect flow, so it - // must resolve through the fallback too — a seeded admin (root bench - // "acme" only, slug never equals the computed "user-1-user1") whose - // browser double-fires the callback gets `connected`, not - // `state_expired`, on the second arrival. - test("a seeded admin's duplicate callback recovers as connected even though only the root bench matches", async () => { - const server = Bun.serve({ - port: 0, - fetch: mockHub({ tenantSlug: "acme" }).fetch, - }); - try { - const app = connectRoutes({ - hubUrl: `http://localhost:${server.port}`, - openrouterConnect: { - exchange: async () => ({ ok: true, key: "sk-or-v1-minted" }), - connectCredential: connectCredentialAgainstMockHub, - }, - }); - const { response: started } = await startConnect(app); - const cookie = stateCookie(started); - const path = "/api/onboarding/oauth/openrouter/callback?code=auth_code_1"; - - await app.request(path, { headers: { cookie } }); - const second = await app.request(path, { headers: { cookie } }); - - const secondRedirect = new URL(second.headers.get("location") ?? "", "https://x"); - expect(secondRedirect.searchParams.get("outcome")).toBe("connected"); - expect(secondRedirect.searchParams.get("tenantSlug")).toBe("acme"); - } finally { - server.stop(true); - } - }); - - test("a genuinely stale state (no matching recent credential) still errors honestly", async () => { - const app = connectRoutes({ - openrouterConnect: { - exchange: async () => ({ ok: true, key: "sk-or-v1-minted" }), - connectCredential: async () => ({ - kind: "connected", - tenantId: "ten_1", - tenantSlug: "alice-user1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - }), - }, - }); - await startConnect(app); - const path = "/api/onboarding/oauth/openrouter/callback?code=auth_code_1"; - - // A replay with no prior successful connect at all (no /start ever - // happened for this exact state before the browser sent it) — the - // recovery check finds no personal bench and no credential, so the - // honest ending still wins. - const replayed = await app.request(path, { - headers: { cookie: "workbench_openrouter_connect=not-a-real-state" }, - }); - - expect( - new URL(replayed.headers.get("location") ?? "", "https://x").searchParams.get("code"), - ).toBe("state_expired"); - }); - - test("survives a hub restart between /start and /callback, and still recovers as connected after it", async () => { - // /start runs against the pre-restart app; a fresh app (a new - // `createOnboardingRoutes` call, a fresh in-memory state store, the - // works) stands in for the process that comes back up after a - // restart. The only thing they share is the cipher key — exactly - // what a stable `CREDENTIAL_ENCRYPTION_KEY` gives a real restart. - const beforeRestart = connectRoutes({ - credentialCipher: createEnvKeyCredentialCipher(RESTART_STABLE_KEY), - }); - const { response: started } = await startConnect(beforeRestart); - const cookie = stateCookie(started); - - const server = Bun.serve({ port: 0, fetch: mockHub().fetch }); - try { - const afterRestart = connectRoutes({ - hubUrl: `http://localhost:${server.port}`, - credentialCipher: createEnvKeyCredentialCipher(RESTART_STABLE_KEY), - openrouterConnect: { - exchange: async () => ({ ok: true, key: "sk-or-v1-minted" }), - connectCredential: connectCredentialAgainstMockHub, - }, - }); - const path = "/api/onboarding/oauth/openrouter/callback?code=auth_code_1"; - - const first = await afterRestart.request(path, { headers: { cookie } }); - expect( - new URL(first.headers.get("location") ?? "", "https://x").searchParams.get("outcome"), - ).toBe("connected"); - - // Replaying the same cookie against the post-restart app — no new - // /start, same state — recovers as connected too, since the first - // request's connect already persisted the credential. - const replay = await afterRestart.request(path, { headers: { cookie } }); - expect( - new URL(replay.headers.get("location") ?? "", "https://x").searchParams.get("outcome"), - ).toBe("connected"); - } finally { - server.stop(true); - } - }); - - test("an exchange failure is reported as such and never reaches connect", async () => { - let connected = 0; - const lines: string[] = []; - const app = connectRoutes({ - log: (line) => lines.push(line), - openrouterConnect: { - exchange: async () => ({ - ok: false, - message: "OpenRouter rejected the code exchange with status 403", - }), - connectCredential: async () => { - connected += 1; - return { - kind: "connected", - tenantId: "ten_1", - tenantSlug: "alice-user1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - }; - }, - }, - }); - const { response: started } = await startConnect(app); - - const response = await app.request( - "/api/onboarding/oauth/openrouter/callback?code=expired_code", - { headers: { cookie: stateCookie(started) } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("exchange_failed"); - expect(connected).toBe(0); - expect(lines.some((line) => line.includes("code exchange failed"))).toBe(true); - }); - - test("a minted key that fails its probe is a key_rejected ending, not a success", async () => { - const app = connectRoutes({ - openrouterConnect: { - exchange: async () => ({ ok: true, key: "sk-or-v1-minted" }), - connectCredential: async () => ({ - kind: "invalid-credential", - message: "invalid api key", - }), - }, - }); - const { response: started } = await startConnect(app); - - const response = await app.request( - "/api/onboarding/oauth/openrouter/callback?code=auth_code_1", - { headers: { cookie: stateCookie(started) } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("key_rejected"); - }); - - test("a thrown connect failure surfaces honestly without leaking the key", async () => { - const lines: string[] = []; - const app = connectRoutes({ - log: (line) => lines.push(line), - openrouterConnect: { - exchange: async () => ({ ok: true, key: "sk-or-v1-minted" }), - connectCredential: async () => { - throw new Error("the hub rejected the credential"); - }, - }, - }); - const { response: started } = await startConnect(app); - - const response = await app.request( - "/api/onboarding/oauth/openrouter/callback?code=auth_code_1", - { headers: { cookie: stateCookie(started) } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("code")).toBe("setup_failed"); - expect(lines.join("\n")).not.toContain("sk-or-v1-minted"); - }); - - test("never triggers a workflow deploy call — the fast half only proves and persists the key", async () => { - // The core of this defect: the callback must return in the time it - // takes to prove and store a key, never the minutes a full workflow - // deploy-and-confirm chain can take. `connectCredential`'s default - // (`testAndPersistCredential`) never calls `seedTenant`, so nothing - // here should ever reach a `/workflows/deployments`-or-`/assets`-shaped - // path — `mockHub()` never wires either up, so reaching one 404s - // loudly instead of silently succeeding. - let deployPosts = 0; - const hub = mockHub(); - hub.post("/api/tenants/:tenantId/workflows/deployments", (c) => { - deployPosts += 1; - return c.json({ error: "unexpected deploy call" }, 500); - }); - hub.post("/api/tenants/:tenantId/assets", (c) => { - deployPosts += 1; - return c.json({ error: "unexpected asset call" }, 500); - }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const app = connectRoutes({ - hubUrl: `http://localhost:${server.port}`, - openrouterConnect: { - exchange: async () => ({ ok: true, key: "sk-or-v1-minted" }), - connectCredential: connectCredentialAgainstMockHub, - }, - }); - const { response: started } = await startConnect(app); - - const response = await app.request( - "/api/onboarding/oauth/openrouter/callback?code=auth_code_1", - { headers: { cookie: stateCookie(started) } }, - ); - - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("connected"); - expect(deployPosts).toBe(0); - } finally { - server.stop(true); - } - }); -}); diff --git a/packages/onboarding/test/pending-seed-store.drizzle.test.ts b/packages/onboarding/test/pending-seed-store.drizzle.test.ts deleted file mode 100644 index 7dbbdb0a72..0000000000 --- a/packages/onboarding/test/pending-seed-store.drizzle.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -// DB-gated: skipped when no DATABASE_URL is reachable (a fresh checkout -// still runs the unit gates), mirroring `@corbits/webhook-triggers`' -// own `store.drizzle.test.ts` and this package's `migrations.test.ts`. -// Runs against its own scratch database, never the developer's or the -// walking-skeleton suite's. -// -// Proves what an in-memory `PendingSeedStore` fake cannot: that -// `createDrizzlePendingSeedStore` actually persists across separate -// connections (surviving a restart by construction — it's a real -// table, not process memory), that a real cipher's ciphertext on disk -// is not the plaintext key, that a fresh connect upserts the single -// active row per (userId, tenantId) rather than accumulating rows, and -// that a row whose stored `provider` column disagrees with the AAD its -// payload was actually sealed under (simulated here via a raw SQL -// UPDATE — the kind of tamper the application layer never produces on -// its own) fails to decrypt and is swept away rather than silently -// misattributed. -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { drizzle } from "drizzle-orm/postgres-js"; -import postgres from "postgres"; -import { createEnvKeyCredentialCipher, createNoopCredentialCipher } from "@intx/crypto"; - -import { e2eDatabaseUrl } from "../../../scripts/e2e/database-url"; -import { applyOnboardingMigrations } from "../src/migrations"; -import { dbGate } from "../../../scripts/e2e/db-gate"; -import { createDrizzlePendingSeedStore, type PendingSeed } from "../src/pending-seed"; - -function scratchUrlFor(e2eUrl: string): string { - const url = new URL(e2eUrl); - const database = url.pathname.replace(/^\//, ""); - url.pathname = `/${database}_onboarding_pending_seed_store_test`; - return url.toString(); -} - -const databaseUrl = e2eDatabaseUrl(); -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const KEY = new Uint8Array(32).fill(9); - -const SEED: PendingSeed = { - userId: "user_1", - tenantId: "ten_1", - principalId: "prn_1", - tenantDomain: "alice-user1.bench.local", - provider: "openrouter", - apiKey: "sk-or-v1-minted", -}; - -describeIfDb("createDrizzlePendingSeedStore", () => { - const scratchUrl = scratchUrlFor(databaseUrl ?? "postgres://localhost:5432/unused"); - const scratchDatabase = new URL(scratchUrl).pathname.replace(/^\//, ""); - - beforeAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - await maintenance.unsafe(`CREATE DATABASE "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - await applyOnboardingMigrations(scratchUrl); - }, 20000); - - afterAll(async () => { - const maintenanceUrl = new URL(scratchUrl); - maintenanceUrl.pathname = "/postgres"; - const maintenance = postgres(maintenanceUrl.toString(), { - max: 1, - onnotice: () => undefined, - }); - try { - await maintenance.unsafe(`DROP DATABASE IF EXISTS "${scratchDatabase}"`); - } finally { - await maintenance.end(); - } - }, 20000); - - test("a real cipher's stored payload is not the plaintext, and round-trips correctly", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const cipher = createEnvKeyCredentialCipher(KEY); - const store = createDrizzlePendingSeedStore(db, cipher); - - await store.put(SEED); - - const [rawRow] = - await sql`select payload from onboarding.pending_seed where user_id = ${SEED.userId} and tenant_id = ${SEED.tenantId}`; - expect(String(rawRow?.["payload"])).not.toContain(SEED.apiKey); - expect(String(rawRow?.["payload"])).toContain("enc:"); - - const read = await store.read({ - userId: SEED.userId, - tenantId: SEED.tenantId, - }); - expect(read).toEqual(SEED); - } finally { - await sql.end(); - } - }); - - test("a noop cipher (dev/test default) round-trips as plaintext identity", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const store = createDrizzlePendingSeedStore(db, createNoopCredentialCipher()); - const seed: PendingSeed = { ...SEED, userId: "user_noop" }; - - await store.put(seed); - - const [rawRow] = - await sql`select payload from onboarding.pending_seed where user_id = ${seed.userId} and tenant_id = ${seed.tenantId}`; - expect(String(rawRow?.["payload"])).toContain(seed.apiKey); - - const read = await store.read({ - userId: seed.userId, - tenantId: seed.tenantId, - }); - expect(read).toEqual(seed); - } finally { - await sql.end(); - } - }); - - test("a fresh connect upserts the single active row per (userId, tenantId) — never a second row", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const cipher = createEnvKeyCredentialCipher(KEY); - const store = createDrizzlePendingSeedStore(db, cipher); - const seed: PendingSeed = { ...SEED, userId: "user_upsert" }; - - await store.put(seed); - const replacement: PendingSeed = { - ...seed, - provider: "huggingface", - apiKey: "hf_replaced", - }; - await store.put(replacement); - - const rows = - await sql`select provider from onboarding.pending_seed where user_id = ${seed.userId} and tenant_id = ${seed.tenantId}`; - expect(rows).toHaveLength(1); - expect(rows[0]?.["provider"]).toBe("huggingface"); - - const read = await store.read({ - userId: seed.userId, - tenantId: seed.tenantId, - }); - expect(read).toEqual(replacement); - } finally { - await sql.end(); - } - }); - - test("survives across separate connections — DB-backed, not process memory", async () => { - const cipher = createEnvKeyCredentialCipher(KEY); - const seed: PendingSeed = { ...SEED, userId: "user_restart" }; - - const writeSql = postgres(scratchUrl, { max: 1 }); - try { - const writeStore = createDrizzlePendingSeedStore(drizzle(writeSql), cipher); - await writeStore.put(seed); - } finally { - await writeSql.end(); - } - - // A brand-new connection and store instance — nothing shared with - // the one above except the database and the cipher key, exactly - // what survives a real hub restart. - const readSql = postgres(scratchUrl, { max: 1 }); - try { - const readStore = createDrizzlePendingSeedStore(drizzle(readSql), cipher); - const read = await readStore.read({ - userId: seed.userId, - tenantId: seed.tenantId, - }); - expect(read).toEqual(seed); - } finally { - await readSql.end(); - } - }); - - test("a row whose provider column disagrees with the AAD its payload was sealed under fails closed and is swept away", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const cipher = createEnvKeyCredentialCipher(KEY); - const store = createDrizzlePendingSeedStore(db, cipher); - const seed: PendingSeed = { ...SEED, userId: "user_tamper" }; - - await store.put(seed); - // Simulate a tamper the application layer never produces: - // relabel the row's provider column without re-sealing the - // payload under the new AAD. - await sql`update onboarding.pending_seed set provider = 'huggingface' where user_id = ${seed.userId} and tenant_id = ${seed.tenantId}`; - - const read = await store.read({ - userId: seed.userId, - tenantId: seed.tenantId, - }); - expect(read).toBeUndefined(); - - const rows = - await sql`select 1 from onboarding.pending_seed where user_id = ${seed.userId} and tenant_id = ${seed.tenantId}`; - expect(rows).toHaveLength(0); - } finally { - await sql.end(); - } - }); - - test("an expired row is deleted at read time, not merely skipped", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const cipher = createEnvKeyCredentialCipher(KEY); - const store = createDrizzlePendingSeedStore(db, cipher); - const seed: PendingSeed = { ...SEED, userId: "user_expired" }; - - await store.put(seed, { ttlMs: -1 }); - - const read = await store.read({ - userId: seed.userId, - tenantId: seed.tenantId, - }); - expect(read).toBeUndefined(); - - const rows = - await sql`select 1 from onboarding.pending_seed where user_id = ${seed.userId} and tenant_id = ${seed.tenantId}`; - expect(rows).toHaveLength(0); - } finally { - await sql.end(); - } - }); - - test("clear deletes the row", async () => { - const sql = postgres(scratchUrl, { max: 1 }); - try { - const db = drizzle(sql); - const cipher = createEnvKeyCredentialCipher(KEY); - const store = createDrizzlePendingSeedStore(db, cipher); - const seed: PendingSeed = { ...SEED, userId: "user_clear" }; - - await store.put(seed); - await store.clear({ userId: seed.userId, tenantId: seed.tenantId }); - - const read = await store.read({ - userId: seed.userId, - tenantId: seed.tenantId, - }); - expect(read).toBeUndefined(); - } finally { - await sql.end(); - } - }); -}); diff --git a/packages/onboarding/test/plant-env-credentials.test.ts b/packages/onboarding/test/plant-env-credentials.test.ts deleted file mode 100644 index 70355ce058..0000000000 --- a/packages/onboarding/test/plant-env-credentials.test.ts +++ /dev/null @@ -1,685 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { SeedCatalogArgs } from "@corbits/connections/seed-catalog"; -import type { ApiCall } from "@corbits/hub-api-client"; -import { - envProviderBaseUrlsFrom, - envProviderKeysFrom, - plantEnvProviderCredentials, -} from "../src/plant-env-credentials"; - -const TENANT_ID = "ten_operator"; - -function collector() { - const lines: string[] = []; - return { lines, log: (line: string) => lines.push(line) }; -} - -/** The curated provider key a fake credential row's name belongs to — - * every fixture in this suite either uses the env-plant's own - * `-default` naming convention or (for the - * Settings-connected-credential tests) supplies its own explicit - * `providerKey`, so a bare fixture name is always `-default`. */ -function providerKeyOf(name: string): string { - return name.replace(/-default$/, ""); -} - -type CredentialFixture = { - readonly name: string; - readonly providerKey?: string; - /** Defaults to "api_key" — the type every fixture in this suite has - * used until the non-inference-type test, which sets this to a type - * `findActiveCredential` must not recognize as the plant. */ - readonly type?: "api_key" | "oauth_token" | "certificate" | "other"; -}; - -function fixture(name: string): CredentialFixture { - return { name }; -} - -function providerRow(providerKey: string) { - return { - id: `prov_${providerKey}`, - tenantId: TENANT_ID, - name: providerKey, - plugin: "http", - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; -} - -function credentialRow(fixture: CredentialFixture, status: "active" | "revoked", idPrefix: string) { - const providerKey = fixture.providerKey ?? providerKeyOf(fixture.name); - return { - id: `${idPrefix}${fixture.name}`, - tenantId: TENANT_ID, - providerId: `prov_${providerKey}`, - name: fixture.name, - type: fixture.type ?? "api_key", - status, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }; -} - -/** A minimal credentials-and-providers-list ApiCall: answers - * `GET /api/tenants/:id/credentials` with whatever `active`/`revoked` - * fixtures hold, `GET /api/tenants/:id/providers` with the provider row - * each fixture's `providerKey` belongs to, and 404s everything else — - * `plantEnvProviderCredentials` never calls anything else through `api` - * itself (the plant/probe indirections are always passed as fakes in - * these tests). */ -function credentialsApi( - active: ReadonlySet | readonly CredentialFixture[], - revoked: ReadonlySet | readonly CredentialFixture[] = [], -): ApiCall { - // Read `active`/`revoked` fresh on every call rather than snapshotting - // once: several tests mutate the `Set` they passed in (mirroring a - // real `seedCatalog`'s side effect) after the fake is built, and rely - // on the next call seeing that mutation. - function currentFixtures( - entries: ReadonlySet | readonly CredentialFixture[], - ): CredentialFixture[] { - return [...entries].map((entry) => (typeof entry === "string" ? fixture(entry) : entry)); - } - return async (method, path) => { - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/credentials`)) { - const rows = [ - ...currentFixtures(active).map((f) => credentialRow(f, "active", "cred_")), - ...currentFixtures(revoked).map((f) => credentialRow(f, "revoked", "cred_revoked_")), - ]; - return { - status: 200, - data: { data: rows, nextCursor: null }, - cookies: [], - }; - } - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/providers`)) { - const providerKeys = new Set( - [...currentFixtures(active), ...currentFixtures(revoked)].map( - (f) => f.providerKey ?? providerKeyOf(f.name), - ), - ); - return { - status: 200, - data: { - data: [...providerKeys].map((key) => providerRow(key)), - nextCursor: null, - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; -} - -/** A paginated credentials-list ApiCall for the follow-nextCursor test: - * the target row lives on page two, so a caller that only reads page - * one would never see it. */ -function paginatedCredentialsApi(pages: string[][]): ApiCall { - const providerKeys = new Set(pages.flat().map(providerKeyOf)); - return async (method, path) => { - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/providers`)) { - return { - status: 200, - data: { - data: [...providerKeys].map((key) => providerRow(key)), - nextCursor: null, - }, - cookies: [], - }; - } - if (!(method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/credentials`))) { - throw new Error(`unexpected call: ${method} ${path}`); - } - const url = new URL(path, "http://hub.test"); - const cursor = url.searchParams.get("cursor"); - const pageIndex = cursor === null ? 0 : Number(cursor); - const names = pages[pageIndex] ?? []; - const nextCursor = pageIndex + 1 < pages.length ? String(pageIndex + 1) : null; - return { - status: 200, - data: { - data: names.map((name) => credentialRow(fixture(name), "active", "cred_")), - nextCursor, - }, - cookies: [], - }; - }; -} - -/** A paginated providers-list ApiCall for the findProviderId - * follow-nextCursor test: the target provider row lives on page two, - * paired with a single active credential already sitting on that - * provider so the test can assert the plant is recognized. */ -function paginatedProvidersApi(pages: string[][], activeCredentialName: string): ApiCall { - return async (method, path) => { - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/providers`)) { - const url = new URL(path, "http://hub.test"); - const cursor = url.searchParams.get("cursor"); - const pageIndex = cursor === null ? 0 : Number(cursor); - const names = pages[pageIndex] ?? []; - const nextCursor = pageIndex + 1 < pages.length ? String(pageIndex + 1) : null; - return { - status: 200, - data: { - data: names.map((key) => providerRow(key)), - nextCursor, - }, - cookies: [], - }; - } - if (method === "GET" && path.startsWith(`/api/tenants/${TENANT_ID}/credentials`)) { - return { - status: 200, - data: { - data: [credentialRow(fixture(activeCredentialName), "active", "cred_")], - nextCursor: null, - }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; -} - -describe("envProviderKeysFrom", () => { - test("reads every curated provider's conventional env var", () => { - const keys = envProviderKeysFrom({ - ANTHROPIC_API_KEY: "sk-ant-1", - OPENAI_API_KEY: "sk-oai-1", - UNRELATED: "ignored", - }); - expect(keys).toEqual({ anthropic: "sk-ant-1", openai: "sk-oai-1" }); - }); - - test("prefers GEMINI_API_KEY over GOOGLE_API_KEY when both are set", () => { - const keys = envProviderKeysFrom({ - GEMINI_API_KEY: "gemini-key", - GOOGLE_API_KEY: "google-key", - }); - expect(keys["google-genai"]).toBe("gemini-key"); - }); - - test("falls back to GOOGLE_API_KEY when GEMINI_API_KEY is absent", () => { - const keys = envProviderKeysFrom({ GOOGLE_API_KEY: "google-key" }); - expect(keys["google-genai"]).toBe("google-key"); - }); - - test("an empty environment yields an empty map", () => { - expect(envProviderKeysFrom({})).toEqual({}); - }); - - test("an empty-string value is treated as unset", () => { - expect(envProviderKeysFrom({ ANTHROPIC_API_KEY: "" })).toEqual({}); - }); - - test("OLLAMA_BASE_URL's presence plants the fixed placeholder secret, never the URL", () => { - const keys = envProviderKeysFrom({ - OLLAMA_BASE_URL: "http://localhost:11434", - }); - expect(keys.ollama).toBe("ollama"); - }); - - test("an empty OLLAMA_BASE_URL is treated as unset", () => { - expect(envProviderKeysFrom({ OLLAMA_BASE_URL: "" })).toEqual({}); - }); -}); - -describe("envProviderBaseUrlsFrom", () => { - test("reads OLLAMA_BASE_URL as ollama's base URL", () => { - expect( - envProviderBaseUrlsFrom({ - OLLAMA_BASE_URL: "https://home-mac.example.ts.net", - }), - ).toEqual({ ollama: "https://home-mac.example.ts.net" }); - }); - - test("an empty environment yields an empty map", () => { - expect(envProviderBaseUrlsFrom({})).toEqual({}); - }); - - test("an empty-string value is treated as unset", () => { - expect(envProviderBaseUrlsFrom({ OLLAMA_BASE_URL: "" })).toEqual({}); - }); -}); - -describe("plantEnvProviderCredentials", () => { - test("no keys present plants nothing and never calls the api", async () => { - const { log, lines } = collector(); - const outcomes = await plantEnvProviderCredentials({ - api: async () => { - throw new Error("api should not be called with no env keys"); - }, - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: {}, - log, - }); - expect(outcomes).toEqual([]); - expect(lines).toEqual([]); - }); - - test("plants a fresh provider: probes then seeds the catalog", async () => { - const { log, lines } = collector(); - const active = new Set(); - const seedCatalogCalls: SeedCatalogArgs[] = []; - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(active), - cookies: ["session=abc"], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-real" }, - log, - testCredential: async (args) => { - expect(args.provider).toBe("anthropic"); - expect(args.apiKey).toBe("sk-ant-real"); - return { ok: true }; - }, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - // Mirrors the real `seedCatalog`'s side effect: a fresh plant - // actually stores an active credential under this name. - active.add("anthropic-default"); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([{ provider: "anthropic", status: "planted" }]); - expect(seedCatalogCalls).toHaveLength(1); - expect(seedCatalogCalls[0]?.provider).toBe("anthropic"); - expect(seedCatalogCalls[0]?.apiKey).toBe("sk-ant-real"); - expect(seedCatalogCalls[0]?.tenantId).toBe(TENANT_ID); - - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("anthropic"); - expect(lines[0]).toContain("planted"); - expect(lines[0]).not.toContain("sk-ant-real"); - }); - - test("an already-active credential skips probe and key rotation, but backfills the curated catalog", async () => { - const { log, lines } = collector(); - let probed = false; - const seedCatalogCalls: SeedCatalogArgs[] = []; - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(new Set(["anthropic-default"])), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-rotated" }, - log, - testCredential: async () => { - probed = true; - return { ok: true }; - }, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([{ provider: "anthropic", status: "skipped" }]); - expect(probed).toBe(false); - expect(seedCatalogCalls).toHaveLength(1); - expect(seedCatalogCalls[0]?.existingCredentialId).toBe("cred_anthropic-default"); - expect(seedCatalogCalls[0]?.apiKey).toBeUndefined(); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("skipped"); - expect(lines[0]).toContain("was not planted"); - expect(lines[0]).toContain("backfilled"); - expect(lines[0]).toContain("rotate"); - expect(lines[0]).toContain("Plugins"); - expect(lines[0]).not.toContain("sk-ant-rotated"); - }); - - test("a Settings-connected credential under the same provider is recognized, so booting with the env key skips the probe and never creates a second credential row", async () => { - const { log, lines } = collector(); - let probed = false; - const seedCatalogCalls: SeedCatalogArgs[] = []; - const outcomes = await plantEnvProviderCredentials({ - // Named "Anthropic" (the connector's displayName, the way - // `persistConnectorCredential` names a Settings-connected - // credential) rather than this module's own "anthropic-default" — - // both belong to the same `prov_anthropic` provider row. - api: credentialsApi([{ name: "Anthropic", providerKey: "anthropic" }]), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-from-env" }, - log, - testCredential: async () => { - probed = true; - return { ok: true }; - }, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([{ provider: "anthropic", status: "skipped" }]); - expect(probed).toBe(false); - expect(seedCatalogCalls).toHaveLength(1); - expect(seedCatalogCalls[0]?.existingCredentialId).toBe("cred_Anthropic"); - expect(seedCatalogCalls[0]?.apiKey).toBeUndefined(); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("Anthropic"); - expect(lines[0]).not.toContain("sk-ant-from-env"); - }); - - test("a catalog backfill failure on an already-active credential is reported, never thrown", async () => { - const { log, lines } = collector(); - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(new Set(["anthropic-default"])), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-real" }, - log, - testCredential: async () => { - throw new Error("must not probe an already-active credential"); - }, - seedCatalogFn: async () => { - throw new Error("catalog POST failed"); - }, - }); - - expect(outcomes).toEqual([ - { - provider: "anthropic", - status: "failed", - message: "catalog POST failed", - }, - ]); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("failed to backfill catalog"); - expect(lines[0]).toContain("catalog POST failed"); - }); - - test("a failed probe is reported, never persisted, and never thrown", async () => { - const { log, lines } = collector(); - let seeded = false; - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(new Set()), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-bad" }, - log, - testCredential: async () => ({ - ok: false, - message: "Anthropic rejected the request with status 401", - }), - seedCatalogFn: async () => { - seeded = true; - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([ - { - provider: "anthropic", - status: "failed", - message: "Anthropic rejected the request with status 401", - }, - ]); - expect(seeded).toBe(false); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("failed"); - expect(lines[0]).not.toContain("sk-ant-bad"); - }); - - test("plants ollama with its configured base URL threaded to both the probe and the seed", async () => { - const { log } = collector(); - const active = new Set(); - const seedCatalogCalls: SeedCatalogArgs[] = []; - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(active), - cookies: ["session=abc"], - tenantId: TENANT_ID, - envProviderKeys: { ollama: "ollama" }, - envProviderBaseUrls: { ollama: "https://home-mac.example.ts.net" }, - log, - testCredential: async (args) => { - expect(args.provider).toBe("ollama"); - expect(args.baseURL).toBe("https://home-mac.example.ts.net"); - return { ok: true }; - }, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - active.add("ollama-default"); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([{ provider: "ollama", status: "planted" }]); - expect(seedCatalogCalls[0]?.baseURLOverride).toBe("https://home-mac.example.ts.net"); - }); - - test("an already-active ollama credential still threads its base URL into the catalog backfill", async () => { - const { log } = collector(); - const seedCatalogCalls: SeedCatalogArgs[] = []; - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(new Set(["ollama-default"])), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { ollama: "ollama" }, - envProviderBaseUrls: { ollama: "https://home-mac.example.ts.net" }, - log, - testCredential: async () => { - throw new Error("must not probe an already-active credential"); - }, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([{ provider: "ollama", status: "skipped" }]); - expect(seedCatalogCalls[0]?.existingCredentialId).toBe("cred_ollama-default"); - expect(seedCatalogCalls[0]?.apiKey).toBeUndefined(); - expect(seedCatalogCalls[0]?.baseURLOverride).toBe("https://home-mac.example.ts.net"); - }); - - test("one provider's failed probe never blocks another provider's plant", async () => { - const { log } = collector(); - const active = new Set(); - const planted: string[] = []; - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(active), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-bad", openai: "sk-oai-good" }, - log, - testCredential: async (args) => ({ - ok: args.provider === "openai", - message: "rejected", - }), - seedCatalogFn: async (args) => { - planted.push(args.provider ?? "unknown"); - active.add("openai-default"); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([ - { provider: "anthropic", status: "failed", message: "rejected" }, - { provider: "openai", status: "planted" }, - ]); - expect(planted).toEqual(["openai"]); - }); - - test("calling twice plants the credential once and backfills the catalog on every boot", async () => { - const { log } = collector(); - const active = new Set(); - const api = credentialsApi(active); - let probeCount = 0; - const seedCalls: SeedCatalogArgs[] = []; - const args = { - api, - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-real" }, - log, - testCredential: async () => { - probeCount += 1; - return { ok: true as const }; - }, - seedCatalogFn: async (seedArgs: SeedCatalogArgs) => { - seedCalls.push(seedArgs); - // A real `seedCatalog` call is what makes the credential active; - // the fake mirrors that side effect so the second call's - // pre-check sees it. - active.add("anthropic-default"); - return { hasCompletionCapableModel: true }; - }, - }; - - const first = await plantEnvProviderCredentials(args); - const second = await plantEnvProviderCredentials(args); - - expect(first).toEqual([{ provider: "anthropic", status: "planted" }]); - expect(second).toEqual([{ provider: "anthropic", status: "skipped" }]); - expect(probeCount).toBe(1); - expect(seedCalls).toHaveLength(2); - expect(seedCalls[0]?.apiKey).toBe("sk-ant-real"); - expect(seedCalls[1]?.existingCredentialId).toBe("cred_anthropic-default"); - expect(seedCalls[1]?.apiKey).toBeUndefined(); - }); - - test("a revoked existing credential of the same name blocks the plant instead of being reported as planted", async () => { - const { log, lines } = collector(); - // No active row (so the pre-check probes), but a revoked row with - // the same name already exists — `seedCatalog`'s `ensureCredential` - // 409-skips an `api_key` row it does not own the rotation of, so the - // proven env key is never actually stored. - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(new Set(), new Set(["anthropic-default"])), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-real" }, - log, - testCredential: async () => ({ ok: true as const }), - seedCatalogFn: async () => { - // Mirrors the real `ensureCredential`'s 409-skip: the revoked - // row is left untouched, so the credentials list still shows no - // active row for this provider. - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([{ provider: "anthropic", status: "blocked" }]); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain("anthropic-default"); - expect(lines[0]).toContain("not active"); - expect(lines[0]).toContain("not stored"); - expect(lines[0]).not.toContain("planted (catalog ready)"); - expect(lines[0]).not.toContain("sk-ant-real"); - }); - - test("a probe-failure message with a token-shaped substring is redacted before logging", async () => { - const { log, lines } = collector(); - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(new Set()), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { openai: "sk-oai-real-secret-value" }, - log, - testCredential: async () => ({ - ok: false, - message: "OpenAI rejected key sk-oai-real-secret-value-echoed-back: invalid_api_key", - }), - }); - - expect(outcomes).toHaveLength(1); - expect(outcomes[0]?.status).toBe("failed"); - expect(outcomes[0]?.message).not.toContain("sk-oai-real-secret-value"); - expect(outcomes[0]?.message).toContain("[redacted]"); - expect(lines[0]).not.toContain("sk-oai-real-secret-value"); - expect(lines[0]).toContain("[redacted]"); - }); - - test("findActiveCredential follows nextCursor instead of reading only page one", async () => { - const { log } = collector(); - let probed = false; - const seedCatalogCalls: SeedCatalogArgs[] = []; - const outcomes = await plantEnvProviderCredentials({ - // The active "anthropic-default" row lives on page two only. - api: paginatedCredentialsApi([["other-provider-default"], ["anthropic-default"]]), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-real" }, - log, - testCredential: async () => { - probed = true; - return { ok: true as const }; - }, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([{ provider: "anthropic", status: "skipped" }]); - expect(probed).toBe(false); - expect(seedCatalogCalls[0]?.existingCredentialId).toBe("cred_anthropic-default"); - }); - - test("findProviderId follows nextCursor instead of reading only page one", async () => { - const { log } = collector(); - let probed = false; - const seedCatalogCalls: SeedCatalogArgs[] = []; - const outcomes = await plantEnvProviderCredentials({ - // The "anthropic" provider row lives on page two only. - api: paginatedProvidersApi([["other-provider"], ["anthropic"]], "anthropic-default"), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-real" }, - log, - testCredential: async () => { - probed = true; - return { ok: true as const }; - }, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(outcomes).toEqual([{ provider: "anthropic", status: "skipped" }]); - expect(probed).toBe(false); - expect(seedCatalogCalls[0]?.existingCredentialId).toBe("cred_anthropic-default"); - }); - - test("a non-inference credential type on the same provider is not recognized as the plant", async () => { - const { log } = collector(); - let probed = false; - const seedCatalogCalls: SeedCatalogArgs[] = []; - // An active "certificate"-typed row already sits on the anthropic - // provider — neither write path plants one of these today, but the - // match must not be fooled by it into skipping the probe. - const active = new Set([ - { - name: "anthropic-legacy-cert", - providerKey: "anthropic", - type: "certificate", - }, - ]); - const outcomes = await plantEnvProviderCredentials({ - api: credentialsApi(active), - cookies: [], - tenantId: TENANT_ID, - envProviderKeys: { anthropic: "sk-ant-real" }, - log, - testCredential: async () => { - probed = true; - return { ok: true as const }; - }, - seedCatalogFn: async (args) => { - seedCatalogCalls.push(args); - // Mirrors the real `seedCatalog`'s side effect: a fresh plant - // actually stores an active `api_key` credential under this name. - active.add({ name: "anthropic-default", providerKey: "anthropic" }); - return { hasCompletionCapableModel: true }; - }, - }); - - expect(probed).toBe(true); - expect(outcomes).toEqual([{ provider: "anthropic", status: "planted" }]); - expect(seedCatalogCalls[0]?.apiKey).toBe("sk-ant-real"); - }); -}); diff --git a/packages/onboarding/test/provision.test.ts b/packages/onboarding/test/provision.test.ts deleted file mode 100644 index c92b5371bd..0000000000 --- a/packages/onboarding/test/provision.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ApiCall } from "@corbits/hub-api-client"; -import type { HubSignupTenancy } from "../src/genesis"; -import { personalTenantSlug, provisionPersonalTenantIfNeeded } from "../src/provision"; - -const TENANT_ID = "ten_new"; -const PRINCIPAL_ID = "prn_new"; -const MEMBER_PRINCIPAL_ID = "prn_member"; -const TENANT_SLUG = "workbench"; - -function collector() { - const lines: string[] = []; - return { lines, log: (line: string) => lines.push(line) }; -} - -function tenancy(state: { - users?: number; - tenants?: number; - root?: { id: string; slug: string } | null; - joins?: { tenantId: string; userId: string; roleName: string }[]; -}): HubSignupTenancy { - const joins = state.joins ?? []; - return { - countUsers: async () => state.users ?? 0, - countTenants: async () => state.tenants ?? 0, - findRootTenant: async () => (state.root === undefined ? null : state.root), - addActiveMember: async (args) => { - joins.push(args); - return { principalId: MEMBER_PRINCIPAL_ID }; - }, - }; -} - -function principalsResponse( - rows: { - principalId: string; - tenantId: string; - tenantSlug: string; - }[], -) { - return { - status: 200, - data: { - data: rows.map((row) => ({ - principalId: row.principalId, - tenantId: row.tenantId, - tenantName: "Workbench", - tenantSlug: row.tenantSlug, - kind: "user", - status: "active", - roles: [], - })), - nextCursor: null, - }, - cookies: [], - }; -} - -function argsFor(partial: { - api: ApiCall; - tenancy: HubSignupTenancy; - displayName?: string; -}): Parameters[0] { - const args: Parameters[0] = { - api: partial.api, - cookies: ["session=abc"], - userId: "user_1", - userEmail: "alice@example.com", - userEmailVerified: true, - defaultTenantSlug: TENANT_SLUG, - tenancy: partial.tenancy, - log: collector().log, - }; - if (partial.displayName !== undefined) args.displayName = partial.displayName; - return args; -} - -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("alice-smith-userid1"); - }); - - test("never produces an empty component", () => { - expect(personalTenantSlug("@example.com", "")).toBe("bench-personal"); - }); -}); - -describe("provisionPersonalTenantIfNeeded", () => { - test("an existing member is left alone: no tenant is created", async () => { - let tenantCreateCalls = 0; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: "ten_existing", - tenantSlug: "existing", - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - tenantCreateCalls += 1; - throw new Error("unexpected tenant creation for an existing member"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded( - argsFor({ - api, - tenancy: tenancy({ users: 2, tenants: 1 }), - }), - ); - - expect(result).toEqual({ kind: "existing-member" }); - expect(tenantCreateCalls).toBe(0); - }); - - test("zero principals without a display name: needs-onboarding, nothing created", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded( - argsFor({ api, tenancy: tenancy({ users: 1, tenants: 0 }) }), - ); - - expect(result).toEqual({ kind: "needs-onboarding" }); - }); - - test("genesis on an empty hub: provisions the root tenant, seeds nothing", async () => { - const bodies: unknown[] = []; - const { lines, log } = collector(); - let principalsCalls = 0; - const api: ApiCall = async (method, path, body) => { - if (path.includes("/assets") || path.includes("/workflows")) { - throw new Error(`signup must not seed: ${method} ${path}`); - } - if (method === "GET" && path === "/api/me/principals") { - principalsCalls += 1; - if (principalsCalls === 1) return principalsResponse([]); - return principalsResponse([ - { - principalId: PRINCIPAL_ID, - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }, - ]); - } - if (method === "POST" && path === "/api/tenants") { - bodies.push(body); - return { - status: 201, - data: { - id: TENANT_ID, - name: "Alice's Lab", - 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}`); - }; - - const result = await provisionPersonalTenantIfNeeded({ - ...argsFor({ api, tenancy: tenancy({ users: 1, tenants: 0 }) }), - displayName: "Alice's Lab", - log, - }); - - expect(result).toEqual({ - kind: "provisioned", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - seeded: false, - }); - expect(bodies).toEqual([{ name: "Alice's Lab", slug: TENANT_SLUG }]); - expect(lines.some((line) => line.includes("genesis"))).toBe(true); - }); - - test("an occupied hub: the caller joins the root as member, never mints", async () => { - const joins: { tenantId: string; userId: string; roleName: string }[] = []; - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - throw new Error("join must never mint a tenant"); - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - const result = await provisionPersonalTenantIfNeeded( - argsFor({ - api, - tenancy: tenancy({ - users: 2, - tenants: 1, - root: { id: TENANT_ID, slug: TENANT_SLUG }, - joins, - }), - }), - ); - - expect(joins).toEqual([{ tenantId: TENANT_ID, userId: "user_1", roleName: "member" }]); - expect(result).toEqual({ - kind: "existing-member", - tenantId: TENANT_ID, - tenantSlug: TENANT_SLUG, - }); - }); - - test("a slug conflict that still leaves the caller benchless is a real failure", async () => { - const api: ApiCall = async (method, path) => { - if (method === "GET" && path === "/api/me/principals") { - return principalsResponse([]); - } - if (method === "POST" && path === "/api/tenants") { - return { - status: 409, - data: { error: { code: "conflict", message: "Slug already taken" } }, - cookies: [], - }; - } - throw new Error(`unexpected call: ${method} ${path}`); - }; - - await expect( - provisionPersonalTenantIfNeeded( - argsFor({ - api, - tenancy: tenancy({ users: 1, tenants: 0 }), - displayName: "Alice's Lab", - }), - ), - ).rejects.toThrow(/slug conflict/); - }); -}); - -// NOTE `isFullySeeded` and `seededWorkflowStatus` lived here until -// CL-7584 collapsed them into the doc-status reader: the same -// workflows-live + registry-seeded semantics now come from -// `readTenantDesiredStateStatus` (./desired-state.ts), covered by -// `./desired-state.test.ts`, and the partial-seed catch path in -// `./complete-credential.test.ts` pins the `seededWorkflowNames` -// deployed/pending report. A second seeded-check must not regrow here. diff --git a/packages/onboarding/test/report-error-routing.test.ts b/packages/onboarding/test/report-error-routing.test.ts deleted file mode 100644 index 3953876968..0000000000 --- a/packages/onboarding/test/report-error-routing.test.ts +++ /dev/null @@ -1,302 +0,0 @@ -// CL-7234: every caught failure in this package's routes, background -// drain, and env-credential-plant paths must reach @corbits/error-sink's -// reportError with the operation/tenant context it expects — the same -// precedent ../src/provision.ts already sets. This mocks -// @corbits/error-sink and dynamically imports each module under test -// afterward (the same recipe packages/workflow-deploy-source and -// packages/webhook-triggers already use for this exact kind of -// assertion), since a static top-level import would bind the real -// module before any mock could apply. -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import type { AppEnv } from "@intx/hub-api"; -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; -import { createNoopCredentialCipher } from "@intx/crypto"; - -let reportErrorCalls: [unknown, Record][] = []; -beforeEach(async () => { - reportErrorCalls = []; - await mock.module("@corbits/error-sink", () => ({ - reportError: (error: unknown, context: Record) => { - reportErrorCalls.push([error, context]); - return "ref_test"; - }, - })); -}); -afterEach(() => { - mock.restore(); -}); - -const { createOnboardingRoutes } = await import("../src/routes"); -const { createInMemoryPendingSeedStore } = await import("../src/pending-seed"); -const { createBenchProvisioner } = await import("../src/bench-provisioning"); -const { plantEnvProviderCredentials } = await import("../src/plant-env-credentials"); - -const pendingSeedStore = createInMemoryPendingSeedStore(createNoopCredentialCipher()); - -// These tests never exercise the genesis-or-join join path — the stub -// satisfies the required tenancy wiring without standing up a DB. -const emptyHubTenancy = { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("these suites never exercise the join path"); - }, -}; - -const asUser: MiddlewareHandler = async (c, next) => { - c.set("user", { id: "user_1", email: "user_1@example.com" } as never); - await next(); -}; - -function mountAuthenticated(routes: Hono): Hono { - const app = new Hono(); - app.use("*", asUser); - app.route("/", routes); - return app; -} - -describe("routes.ts routes caught errors through reportError", () => { - test("a failure with no tenant known yet reports operation + userId, no tenantId", async () => { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { method: "POST" }); - - expect(response.status).toBe(503); - expect(reportErrorCalls).toHaveLength(1); - const [, context] = reportErrorCalls[0] as [unknown, Record]; - expect(context.operation).toBe("onboarding_provision"); - expect(context.tenantId).toBeUndefined(); - expect((context.extra as { userId: string }).userId).toBe("user_1"); - }); - - test("a provisioning-status failure after the tenant is found reports its tenantId", async () => { - // Matches complete-setup-routes.test.ts's fixture shape exactly: - // personalTenantSlug("user_1@example.com", "user_1") === "user-1-user1". - const TENANT_ID = "ten_1"; - const TENANT_SLUG = "user-1-user1"; - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: "prn_1", - tenantId: TENANT_ID, - tenantName: "user_1's workbench", - tenantSlug: TENANT_SLUG, - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - hub.get(`/api/tenants/${TENANT_ID}`, (c) => - c.json({ - id: TENANT_ID, - name: "user_1's workbench", - slug: TENANT_SLUG, - domain: "user-1-user1.bench.local", - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }), - ); - // Malformed on purpose: seededWorkflowNames' own parseAs rejects this, - // throwing well after tenantId is already known. - hub.get(`/api/tenants/${TENANT_ID}/assets`, (c) => c.json({ notAnArray: true })); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request(`/provisioning-status?tenantId=${TENANT_ID}`); - - expect(response.status).toBe(500); - expect(reportErrorCalls).toHaveLength(1); - const [, context] = reportErrorCalls[0] as [unknown, Record]; - expect(context.operation).toBe("onboarding_provisioning_status"); - expect(context.tenantId).toBe(TENANT_ID); - expect((context.extra as { userId: string }).userId).toBe("user_1"); - } finally { - server.stop(true); - } - }); -}); - -describe("bench-provisioning.ts's whole-drain failure reports through reportError", () => { - test("a listDue failure (not scoped to any one bench) reports operation only", async () => { - const provisioner = createBenchProvisioner({ - api: (async () => { - throw new Error("unused"); - }) as unknown as Parameters[0]["api"], - hubUrl: "https://bench.example.com", - store: { - listDue: async () => { - throw new Error("db unreachable"); - }, - read: async () => undefined, - put: async () => undefined, - clear: async () => undefined, - }, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - sessionFor: async () => ["better-auth.session_token=minted"], - log: () => undefined, - }); - - provisioner.wake(); - // wake() is fire-and-forget; give its internal drainOnce().catch a - // turn to run before asserting. - await new Promise((resolve) => setTimeout(resolve, 10)); - - expect(reportErrorCalls).toHaveLength(1); - const [, context] = reportErrorCalls[0] as [unknown, Record]; - expect(context.operation).toBe("bench_provisioning_drain"); - expect(context.tenantId).toBeUndefined(); - }); -}); - -describe("plant-env-credentials.ts catches report through reportError and never leak the raw cause", () => { - // No existing provider row for any credential this test plants — the - // real network calls `findProviderId`/`findActiveCredential` make - // aren't overridable test seams, so the fake `api` has to answer them - // honestly (empty pages) to reach the seedCatalogFn override at all. - const noExistingProvidersApi: Parameters[0]["api"] = async ( - _method, - path, - ) => { - if (path.includes("/providers")) { - return { status: 200, data: { data: [], nextCursor: null }, cookies: [] }; - } - throw new Error(`unexpected call in test fake: ${path}`); - }; - - test("a catalog-plant failure reports tenantId + provider, and sanitizes a secret-shaped cause message", async () => { - const outcomes = await plantEnvProviderCredentials({ - api: noExistingProvidersApi, - cookies: [], - tenantId: "tnt_env", - envProviderKeys: { anthropic: "sk-ant-real-secret-value-123456" }, - log: () => undefined, - testCredential: async () => ({ ok: true }), - seedCatalogFn: async () => { - throw new Error("seed failed for key sk-ant-real-secret-value-123456"); - }, - }); - - expect(outcomes).toEqual([ - { - provider: "anthropic", - status: "failed", - message: expect.stringContaining("[redacted]") as unknown as string, - }, - ]); - expect(outcomes[0]?.message).not.toContain("sk-ant-real-secret-value"); - - expect(reportErrorCalls).toHaveLength(1); - const [cause, context] = reportErrorCalls[0] as [unknown, Record]; - expect(context.operation).toBe("env_credential_plant"); - expect(context.tenantId).toBe("tnt_env"); - expect(context.extra).toEqual({ provider: "anthropic" }); - // reportError itself is the one place the unredacted cause is - // still allowed to travel — error-sink does its own redaction pass - // before anything reaches a log sink. - expect(cause).toBeInstanceOf(Error); - }); -}); - -describe("recentlyConnectedCredential reports through reportError and still finds nothing", () => { - test("a hub failure during duplicate-callback recovery reports operation + userId and still ends as state_expired", async () => { - // A principals page arktype will reject, with the secret in a union - // field so the parse summary echoes it. `not.toContain` is otherwise a - // tautology — a type-mismatch summary is `was string` and never - // mentions the value (CL-7255). - const SECRET = "sk-or-v1-thisisafakesecretvalue"; - const logs: string[] = []; - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: "prn_1", - tenantId: "ten_1", - tenantName: "user_1's workbench", - tenantSlug: "user-1-user1", - kind: SECRET, - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: (line) => { - logs.push(line); - }, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/oauth/openrouter/callback?code=auth_code_1", { - headers: { - cookie: "workbench_openrouter_connect=not-a-real-state", - }, - }); - - expect(response.status).toBe(302); - const redirect = new URL(response.headers.get("location") ?? "", "https://x"); - expect(redirect.searchParams.get("outcome")).toBe("error"); - expect(redirect.searchParams.get("code")).toBe("state_expired"); - - expect(reportErrorCalls).toHaveLength(1); - const [cause, context] = reportErrorCalls[0] as [unknown, Record]; - expect(context.operation).toBe("onboarding_duplicate_callback_recovery"); - expect(context.tenantId).toBeUndefined(); - expect((context.extra as { userId: string }).userId).toBe("user_1"); - // reportError is the one place the unredacted cause may travel — - // error-sink redacts before anything reaches a log sink. - expect(cause).toBeInstanceOf(Error); - const causeMessage = cause instanceof Error ? cause.message : String(cause); - expect(causeMessage).toContain(SECRET); - expect(JSON.stringify({ logs, context })).not.toContain(SECRET); - } finally { - server.stop(true); - } - }); -}); diff --git a/packages/onboarding/test/routes.test.ts b/packages/onboarding/test/routes.test.ts deleted file mode 100644 index 638198da89..0000000000 --- a/packages/onboarding/test/routes.test.ts +++ /dev/null @@ -1,862 +0,0 @@ -// The route's own error handling: a provisioning failure must never -// reach the caller as a bare, unhandled 500 — it should come back as -// the same `{ error: { code, userMessage, refId } }` envelope every other hub -// route uses, so the web layer can tell "nothing to do" apart from -// "this broke" instead of both looking like silence. - -import { describe, expect, test } from "bun:test"; -import type { AppEnv } from "@intx/hub-api"; -import type { MiddlewareHandler } from "hono"; -import { Hono } from "hono"; -import { createNoopCredentialCipher } from "@intx/crypto"; -import { createOnboardingRoutes } from "../src/routes"; -import { testAndPersistCredential } from "../src/complete-credential"; -import { createInMemoryPendingSeedStore } from "../src/pending-seed"; -import { createProviderHealthStore } from "@corbits/connections/provider-health"; -import { HubApiError } from "@corbits/hub-api-client"; - -// These tests never exercise the pending-seed store — it is required -// wiring for `createOnboardingRoutes`, and its dedicated coverage lives -// in `./complete-setup-routes.test.ts`, `./connect-deploys-nothing.test.ts` -// and `../src/pending-seed.test.ts`. -const pendingSeedStore = createInMemoryPendingSeedStore(createNoopCredentialCipher()); - -// These tests never exercise the genesis-or-join join path — the stub -// satisfies the required tenancy wiring without standing up a DB. -const emptyHubTenancy = { - countUsers: async () => 0, - countTenants: async () => 0, - findRootTenant: async () => null, - addActiveMember: async () => { - throw new Error("these suites never exercise the join path"); - }, -}; - -const asUser: MiddlewareHandler = async (c, next) => { - c.set("user", { id: "user_1", email: "alice@example.com" } as never); - await next(); -}; - -function mountAuthenticated(routes: Hono): Hono { - const app = new Hono(); - app.use("*", asUser); - app.route("/", routes); - return app; -} - -describe("POST /provision", () => { - test("an unreachable hub surfaces a transient error envelope (503), not a bare 500 body", async () => { - const lines: string[] = []; - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - // Port 0 on loopback refuses every connection immediately, so the - // underlying fetch throws deterministically without a live hub. - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: (line) => lines.push(line), - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { method: "POST" }); - - // An unrecognized failure (connection refused) is transient: the hub - // may come back, and provisioning is idempotent so retry is safe. - expect(response.status).toBe(503); - const body = (await response.json()) as { - error: { code: string; kind: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("provisioning_failed"); - expect(body.error.kind).toBe("transient"); - expect(typeof body.error.userMessage).toBe("string"); - expect(typeof body.error.refId).toBe("string"); - // The raw detail (here, the connection-refused failure) is logged - // behind the refId — never handed to the client as `userMessage`. - expect(body.error.userMessage).not.toContain("ECONNREFUSED"); - expect(lines.some((line) => line.includes("user_1"))).toBe(true); - }); - - test("a permanent provision failure (slug conflict, no principal) maps to 500 with kind permanent", async () => { - // A slug-conflict where the caller still has no principal anywhere is a - // dead end the client cannot retry out of — it must surface as a - // permanent error so the UI offers "contact support", not "try again". - // Body must include a name so provision enters the create path; without - // a name the route returns needs-onboarding and never hits the hub. - const hub = new Hono(); - hub.get("/api/me/principals", (c) => c.json({ data: [], nextCursor: null })); - hub.post("/api/tenants", (c) => - c.json({ error: { code: "conflict", message: "Slug taken" } }, 409), - ); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Alice's Lab" }), - }); - - expect(response.status).toBe(500); - const body = (await response.json()) as { - error: { code: string; kind: string; message: string }; - }; - expect(body.error.code).toBe("slug_conflict_no_principal"); - expect(body.error.kind).toBe("permanent"); - } finally { - server.stop(true); - } - }); - - test("a nameless membership probe returns needs-onboarding without creating", async () => { - const creates: unknown[] = []; - const hub = new Hono(); - hub.get("/api/me/principals", (c) => c.json({ data: [], nextCursor: null })); - hub.post("/api/tenants", async (c) => { - creates.push(await c.req.json()); - return c.json({ id: "tnt_x" }, 201); - }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { method: "POST" }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { kind: string }; - expect(body.kind).toBe("needs-onboarding"); - expect(creates).toEqual([]); - } finally { - server.stop(true); - } - }); - - test("rapid named create retries from the same user are rate-limited (429)", async () => { - // Rate limit applies only to named creates (the membership probe must not - // burn a slot — otherwise the naming wizard always 429s within 10s of - // first login). - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - const named = { - method: "POST" as const, - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Alice's Lab" }), - }; - - const first = await app.request("/provision", named); - const second = await app.request("/provision", named); - - // The first call runs (and fails transiently against the dead hub). - expect(first.status).toBe(503); - // The second is short-circuited before provisioning runs. - expect(second.status).toBe(429); - const body = (await second.json()) as { - error: { code: string; kind: string; message: string }; - }; - expect(body.error.code).toBe("rate_limited"); - expect(body.error.kind).toBe("transient"); - }); - - test("a membership probe does not rate-limit the following named create", async () => { - // Two-step first-login: shell probe (no name) then naming submit (with name). - // The probe must not consume the create rate-limit slot. - const hub = new Hono(); - hub.get("/api/me/principals", (c) => c.json({ data: [], nextCursor: null })); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const probe = await app.request("/provision", { method: "POST" }); - expect(probe.status).toBe(200); - expect(((await probe.json()) as { kind: string }).kind).toBe("needs-onboarding"); - - // Named create reaches the hub (503/500 from incomplete mock is fine); - // the only failure mode this test forbids is 429 from the probe. - const create = await app.request("/provision", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: "Alice's Lab" }), - }); - expect(create.status).not.toBe(429); - expect([500, 503]).toContain(create.status); - } finally { - server.stop(true); - } - }); - - test("malformed JSON on /provision is 400, not a silent membership probe", async () => { - const creates: unknown[] = []; - const hub = new Hono(); - hub.get("/api/me/principals", (c) => c.json({ data: [], nextCursor: null })); - hub.post("/api/tenants", async (c) => { - creates.push(await c.req.json()); - return c.json({ id: "tnt_x" }, 201); - }); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { - method: "POST", - headers: { "content-type": "application/json" }, - body: "{not-json", - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("bad_request"); - expect(creates).toEqual([]); - } finally { - server.stop(true); - } - }); - - test("schema-invalid provision body is 400, not a silent membership probe", async () => { - const hub = new Hono(); - hub.get("/api/me/principals", (c) => c.json({ data: [], nextCursor: null })); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/provision", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ name: 12 }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("bad_request"); - } finally { - server.stop(true); - } - }); - - test("an anonymous request is rejected before provisioning runs", async () => { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - - const response = await routes.request("/provision", { method: "POST" }); - - expect(response.status).toBe(401); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("unauthorized"); - }); -}); - -describe("POST /complete", () => { - test("an anonymous request is rejected before anything is seeded", async () => { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - - const response = await routes.request("/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - provider: "anthropic", - apiKey: "sk-ant-whatever", - }), - }); - - expect(response.status).toBe(401); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("unauthorized"); - }); - - test("a missing provider is rejected with a specific message, no network call made", async () => { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ apiKey: "sk-ant-whatever" }), - }); - - expect(response.status).toBe(400); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("invalid_request"); - }); - - // CL-6092: onboarding's own credential flow is the zero-provider fix - // path the shell banner routes to ("Fix it" → onboarding when nothing - // is connected yet). Without this wiring, a successful connect through - // *this* route left the stale needs-attention record standing, so the - // banner never went away even though the fix worked. - test("a successful connect clears a stale needs_attention record for the connected provider", async () => { - const providerHealth = createProviderHealthStore(); - providerHealth.report("tnt_own", "anthropic", "credential_failure"); - // A durable credential is the whole trigger for clearing the record, - // so the connect only has to get past the fast half. The bench still - // has every default workflow to deploy — this hub reports none of - // them present — which is exactly the state the clear must survive. - const hub = new Hono(); - hub.get("/api/tenants/:id/assets", (c) => c.json([])); - hub.get("/api/tenants/:id/workflows/deployments", (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - providerHealth, - testAndPersistCredentialFn: async () => ({ - kind: "connected", - tenantId: "tnt_own", - tenantSlug: "alice", - principalId: "prn_own", - tenantDomain: "alice.bench.local", - }), - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-good" }), - }); - - expect(response.status).toBe(200); - expect(providerHealth.get("tnt_own", "anthropic")).toBeUndefined(); - } finally { - server.stop(true); - } - }); - - test("an invalid credential never clears the needs_attention record", async () => { - const providerHealth = createProviderHealthStore(); - providerHealth.report("tnt_own", "anthropic", "credential_failure"); - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - providerHealth, - testAndPersistCredentialFn: async () => ({ - kind: "invalid-credential", - message: "the key was rejected", - }), - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-bad" }), - }); - - expect(response.status).toBe(422); - expect(providerHealth.get("tnt_own", "anthropic")?.status).toBe("needs_attention"); - }); - - // CL-6457 moved partial-deploy convergence off this route entirely: - // `/complete` no longer deploys anything, so there is no half-finished - // deploy for it to report. What the route still owes the drain — a - // durable pending row carrying the key the deploy runs against — is - // covered by `./connect-deploys-nothing.test.ts` ("hands the drain a - // pending row carrying the key it will deploy against"), and the - // convergence that row buys is covered by `./bench-provisioning.test.ts` - // ("a half-provisioned bench keeps its row and converges on a later pass"). - - test("a non-sidecar failure during setup still fails loudly with the existing 500 envelope", async () => { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - testAndPersistCredentialFn: async () => { - throw new Error("the hub rejected deployment with status 500"); - }, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-good" }), - }); - - expect(response.status).toBe(500); - const body = (await response.json()) as { - error: { code: string; message: string }; - }; - expect(body.error.code).toBe("credential_setup_failed"); - }); - - // CL-6360: a `HubApiError` 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 HubApiError that names a path. - test("a HubApiError naming an absolute file path never reaches the client", async () => { - const lines: string[] = []; - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: "http://127.0.0.1:0", - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - logError: (line) => lines.push(line), - pendingSeedStore, - testAndPersistCredentialFn: async () => { - throw new HubApiError( - "publishing the corbits-tools package-registry asset failed: " + - "tool-package freshness: @corbits/capability-tools@1.2.0 changed " + - "src/ without bumping version.\n /Users/alice/abklabs/workbench/tools/capability", - "check the hub logs for the underlying failure, then re-run: workbench seed", - ); - }, - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-good" }), - }); - - expect(response.status).toBe(500); - const body = (await response.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("credential_setup_failed"); - expect(body.error.userMessage).not.toContain("/Users/"); - expect(body.error.userMessage).not.toContain("workbench seed"); - expect(typeof body.error.refId).toBe("string"); - expect(body.error.refId.length).toBeGreaterThan(0); - // The raw detail is still recoverable from the hub log, tagged with - // the same refId the client got back. - expect( - lines.some( - (line) => - line.includes(body.error.refId) && - line.includes("/Users/alice/abklabs/workbench/tools/capability"), - ), - ).toBe(true); - }); -}); - -// CL-7506: the connect route runs the real fast half — which resolves the -// tenant through findPersonalTenant with the fallback flag — so a seeded -// admin whose only membership is the root bench (slug "acme", never the -// computed "alice-user1") must connect, not 409 no_personal_bench. The -// persistence half is stubbed at its own seams; the hub serves the reads -// the fast half itself performs. -describe("POST /complete — seeded-admin fallback", () => { - test("connects onto the root bench when no principal matches the personal slug", async () => { - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: "prn_root", - tenantId: "ten_root", - tenantName: "acme", - tenantSlug: "acme", - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - hub.get("/api/tenants/ten_root", (c) => - c.json({ - id: "ten_root", - name: "acme", - slug: "acme", - domain: "acme.bench.local", - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }), - ); - hub.get("/api/tenants/ten_root/assets", (c) => c.json([])); - hub.get("/api/tenants/ten_root/workflows/deployments", (c) => c.json([])); - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - try { - const routes = createOnboardingRoutes({ - tenancy: emptyHubTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - testAndPersistCredentialFn: (args) => - testAndPersistCredential({ - ...args, - ensureProviderFn: async (_api, _cookies, seedArgs) => `prv_${seedArgs.name}`, - ensureCredentialFn: async (_api, _cookies, persistArgs) => - `cred_${persistArgs.providerId}`, - seedCatalogFn: async () => ({ hasCompletionCapableModel: true }), - }), - }); - const app = mountAuthenticated(routes); - - const response = await app.request("/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ provider: "anthropic", apiKey: "sk-ant-x" }), - }); - - expect(response.status).toBe(200); - const body = (await response.json()) as { - kind: string; - tenantSlug: string; - }; - expect(body.kind).toBe("provisioning"); - expect(body.tenantSlug).toBe("acme"); - } finally { - server.stop(true); - } - }); -}); - -// CL-7584: the revisit kick and the doc-derived step list. Every probe on -// a joined or just-minted tenant fires exactly one fire-and-forget -// reconcile kick — unconditionally, without awaiting the multi-GET status -// read (`reconcileTenantDesiredState` is reads-only when converged, so the -// pending check would only ever delay the response); a repeat probe after -// a killed kick re-fires safely (idempotent, no new queue); -// `/provisioning-status` carries the doc-labeled steps a waiting -// surface renders. -describe("CL-7584 desired-state kicks and steps", () => { - const joinedTenancy = { - countUsers: async () => 1, - countTenants: async () => 1, - findRootTenant: async () => ({ id: "ten_root", slug: "workbench" }), - addActiveMember: async () => ({ principalId: "prn_root" }), - }; - - function assetRow(name: string, kind: string) { - return { - id: `ast_${name}`, - tenantId: "ten_root", - kind, - name, - displayName: null, - creatorPrincipalId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - origin: { tenantId: "ten_root", direct: true }, - }; - } - - function mountHub(state: { seeded: boolean; brokenStatus?: boolean }) { - const hub = new Hono(); - hub.get("/api/me/principals", (c) => - c.json({ - data: [ - { - principalId: "prn_root", - tenantId: "ten_root", - tenantName: "workbench", - tenantSlug: "workbench", - kind: "user", - status: "active", - roles: [], - }, - ], - nextCursor: null, - }), - ); - hub.get("/api/tenants/ten_root", (c) => - c.json({ - id: "ten_root", - name: "workbench", - slug: "workbench", - domain: "workbench.bench.local", - parentId: null, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - }), - ); - hub.get("/api/tenants/ten_root/assets", (c) => { - // The latency probe: the first read the old inline pending-check - // awaited. A 500 here must never cost the kick its fire. - if (state.brokenStatus === true) { - return c.json({ error: { code: "boom" } }, 500); - } - if (c.req.query("kind") === "workflow") { - return c.json(state.seeded ? [assetRow("assistant", "workflow")] : []); - } - if (c.req.query("kind") === "package-registry") { - return c.json(state.seeded ? [assetRow("corbits-tools", "package-registry")] : []); - } - return c.json([]); - }); - hub.get("/api/tenants/ten_root/workflows/deployments", (c) => - c.json(state.seeded ? [{ definitionAssetId: "ast_assistant", status: "deployed" }] : []), - ); - hub.get("/api/tenants/ten_root/assets/ast_corbits-tools/tarballs", (c) => - c.json( - state.seeded - ? [ - { - filename: "corbits-capability-tools-0.0.6.tgz", - size: 1, - integrity: "sha512-x", - }, - ] - : [], - ), - ); - hub.get("/api/tenants/ten_root/skills/:name", (c) => - state.seeded ? c.json({ name: c.req.param("name") }) : c.json({ error: "none" }, 404), - ); - return hub; - } - - function routesWithKick(hub: Hono, kicks: string[]) { - const server = Bun.serve({ port: 0, fetch: hub.fetch }); - const routes = createOnboardingRoutes({ - tenancy: joinedTenancy, - defaultTenantSlug: "workbench", - hubUrl: `http://localhost:${server.port}`, - pushWorkflow: async () => ({ - outcome: "pushed" as const, - commitSha: "a".repeat(40), - }), - log: () => undefined, - pendingSeedStore, - desiredStateKick: (args) => kicks.push(args.tenantId), - }); - return { server, app: mountAuthenticated(routes) }; - } - - // The kick is fire-and-forget behind the response: a probe that must - // first resolve its tenant (a plain existing member carries no id) - // settles the lookup after the 200 is already serialized. Poll — never - // a fixed sleep — so the suite stays green when the whole monorepo - // test leg runs packages in parallel and loopback slows down. - async function awaitKicks(kicks: string[], count: number) { - const deadline = Date.now() + 2000; - while (kicks.length < count) { - if (Date.now() > deadline) break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - } - - test("a joined member's probe fires one kick when pins are pending", async () => { - const hub = mountHub({ seeded: false }); - const kicks: string[] = []; - const { server, app } = routesWithKick(hub, kicks); - try { - const response = await app.request("/provision", { method: "POST" }); - expect(response.status).toBe(200); - await awaitKicks(kicks, 1); - expect(kicks).toEqual(["ten_root"]); - } finally { - server.stop(true); - } - }); - - test("a converged tenant's probe still fires one kick — the reconcile is reads-only when converged", async () => { - const hub = mountHub({ seeded: true }); - const kicks: string[] = []; - const { server, app } = routesWithKick(hub, kicks); - try { - const response = await app.request("/provision", { method: "POST" }); - expect(response.status).toBe(200); - // No pending-check gates the kick: converged or not, the probe - // fires and the reconcile itself no-ops on reads alone. - await awaitKicks(kicks, 1); - expect(kicks).toEqual(["ten_root"]); - } finally { - server.stop(true); - } - }); - - test("a failing status read neither delays nor suppresses the kick", async () => { - const hub = mountHub({ seeded: false, brokenStatus: true }); - // With the pending-check inline, this 500 would swallow the kick - // (kicks == []); with the kick unconditional, the response never - // touches the status endpoints. - const kicks: string[] = []; - const { server, app } = routesWithKick(hub, kicks); - try { - const response = await app.request("/provision", { method: "POST" }); - expect(response.status).toBe(200); - await awaitKicks(kicks, 1); - expect(kicks).toEqual(["ten_root"]); - } finally { - server.stop(true); - } - }); - - test("a revisit after a killed kick re-fires the reconcile — no new queue", async () => { - const hub = mountHub({ seeded: false }); - const kicks: string[] = []; - const { server, app } = routesWithKick(hub, kicks); - try { - // The first kick "dies with the process": the fire is recorded but - // the reconcile never runs, so the pins stay pending — exactly what - // a hub kill between kick and converge leaves behind. - const first = await app.request("/provision", { method: "POST" }); - expect(first.status).toBe(200); - await awaitKicks(kicks, 1); - expect(kicks).toEqual(["ten_root"]); - // The retry is the same idempotent probe, not a new queue table or - // endpoint: the revisit fires again unconditionally, and the - // reconcile itself re-reads the pins. The `pending_seed` row covers - // the credential half; this re-kick covers the desired-state half. - const revisit = await app.request("/provision", { method: "POST" }); - expect(revisit.status).toBe(200); - await awaitKicks(kicks, 2); - expect(kicks).toEqual(["ten_root", "ten_root"]); - } finally { - server.stop(true); - } - }); - - test("GET /provisioning-status carries the doc-derived step list", async () => { - const hub = mountHub({ seeded: false }); - const { server, app } = routesWithKick(hub, []); - try { - const response = await app.request("/provisioning-status?tenantId=ten_root"); - expect(response.status).toBe(200); - const body = (await response.json()) as { - kind: string; - setupAgentReady: boolean; - steps: { name: string; label: string; status: string }[]; - }; - expect(body.kind).toBe("provisioning"); - expect(body.setupAgentReady).toBe(false); - expect(body.steps[0]?.name).toBe("assistant"); - expect(body.steps.every((s) => s.status === "pending")).toBe(true); - expect(body.steps.every((s) => s.label.length > 0)).toBe(true); - } finally { - server.stop(true); - } - }); -}); diff --git a/packages/onboarding/test/tenant-seed-catalog.test.ts b/packages/onboarding/test/tenant-seed-catalog.test.ts deleted file mode 100644 index a801b17b36..0000000000 --- a/packages/onboarding/test/tenant-seed-catalog.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -// CL-7585: the catalog set pin. The seeding package's move into -// `@workbench/onboarding`'s `tenant-seed` stripped every assertion on -// the catalog set itself — and the hub's boot list promptly drifted to -// five names including three phantoms. These tests pin the wiring in -// the open: the names the hub and the Routines Available list consume -// derive from `CATALOG_WORKFLOWS` (never a second handwritten copy), -// every catalog name resolves a `WORKFLOW_CATALOG` entry (the skip -// branch `@corbits/workflows`' `available-catalog` documents), and -// every catalog entry answers a real rendering builder through -// `deployableCatalogWorkflow` — the exact function the hub's -// block-workflow deploy path wires through. -import { describe, expect, test } from "bun:test"; -import { workflowCatalogEntry } from "@corbits/workflows/catalog"; -import { - CATALOG_WORKFLOWS, - CATALOG_WORKFLOW_ASSET_NAMES, - DEFAULT_WORKFLOWS, - deployableCatalogWorkflow, - SETUP_AGENT_ASSET_NAME, -} from "../src/tenant-seed"; - -const FAKE_INFERENCE_PREFERENCES = [{ provider: "ollama", model: "qwen-test" }]; - -describe("catalog workflow names", () => { - test("the exported names derive from CATALOG_WORKFLOWS, never a second list", () => { - expect([...CATALOG_WORKFLOW_ASSET_NAMES]).toEqual( - CATALOG_WORKFLOWS.map((workflow) => workflow.assetName), - ); - }); - - test("the default set is the setup agent only; every other source package is an on-demand catalog entry", () => { - // provisionPersonalTenantIfNeeded deploys DEFAULT_WORKFLOWS for - // every real signup (CL-7074: Myra only). Everything else deploys - // on demand (CL-7073) from CATALOG_WORKFLOWS, never automatically. - expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toEqual([SETUP_AGENT_ASSET_NAME]); - expect([...CATALOG_WORKFLOW_ASSET_NAMES]).toEqual([ - "echo", - "workbench-digest", - "last-30-days-research", - "code-review", - "granola-call", - "morning-brief", - "exa-topic-watch", - "process-granola-call", - "attio-task-agent", - "pain-point-collateral", - "reddit-opportunity-scanner", - "collateral-generation", - "diligence-brief", - ]); - }); - - test("every catalog name resolves a WORKFLOW_CATALOG entry", () => { - // `@corbits/workflows`' `available-catalog` skips a catalog name - // with no entry rather than throwing; this guard keeps that skip - // branch dead by construction. - for (const assetName of CATALOG_WORKFLOW_ASSET_NAMES) { - expect( - workflowCatalogEntry(assetName), - `catalog workflow ${assetName} has no WORKFLOW_CATALOG entry`, - ).toBeDefined(); - } - }); -}); - -describe("deployableCatalogWorkflow", () => { - test("every catalog entry answers with a builder rendering its own definition", () => { - for (const workflow of CATALOG_WORKFLOWS) { - const found = deployableCatalogWorkflow(workflow.assetName); - expect(found, `catalog workflow ${workflow.assetName} answers`).toBeDefined(); - const json = found?.buildJson("acme.example", FAKE_INFERENCE_PREFERENCES); - // A real rendered definition for THIS workflow: it parses, it - // has steps to launch, and it names its own asset. (Not every - // entry is mail-triggered — the scheduled ones stamp no trigger - // address — so the name, not the address, is the per-workflow - // pin.) - const parsed = JSON.parse(json as string) as { - stepOrder?: readonly string[]; - }; - expect(parsed.stepOrder?.length).toBeGreaterThan(0); - expect(json).toContain(workflow.assetName); - } - }); - - test("the seeded agent, the test-only workflow, and unknown names answer undefined", () => { - // `assistant` is seeded, never redeployed through the catalog - // instantiate path; `heartbeat` exercises the platform only. - expect(deployableCatalogWorkflow(SETUP_AGENT_ASSET_NAME)).toBeUndefined(); - expect(deployableCatalogWorkflow("heartbeat")).toBeUndefined(); - expect(deployableCatalogWorkflow("kitchen-sink-loop")).toBeUndefined(); - }); -}); diff --git a/packages/onboarding/tsconfig.json b/packages/onboarding/tsconfig.json deleted file mode 100644 index d7611c1229..0000000000 --- a/packages/onboarding/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true - } -} diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index 797919a3af..b3685f433c 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -33,7 +33,6 @@ import { applyWebhookTriggersMigrations } from "../packages/webhook-triggers/src import { applyNotifyMigrations } from "../packages/notify/src/migrations"; import { applyInboxMigrations, applyMailboxMigrations } from "../packages/inbox/src/migrations"; import { applyAgentDirectoryMigrations } from "../packages/agent-directory/src/migrations"; -import { applyOnboardingMigrations } from "../packages/onboarding/src/migrations"; import { applyCronMigrations } from "../packages/cron/src/migrations"; const repoRoot = path.resolve(import.meta.dir, ".."); @@ -59,7 +58,6 @@ const INSTALLED_PACKAGE_MIGRATIONS: readonly { // Own `inbox` schema; CL-8185 forward-drops the CL-7208 snooze table. { name: "@corbits/inbox", apply: applyInboxMigrations }, { name: "@corbits/agent-directory", apply: applyAgentDirectoryMigrations }, - { name: "@workbench/onboarding", apply: applyOnboardingMigrations }, { name: "@corbits/cron", apply: applyCronMigrations }, ]; @@ -530,7 +528,6 @@ const PACKAGE_SCHEMAS = [ "insights", "notify", "webhook_triggers", - "onboarding", "access_policy", "bench", "preferences", diff --git a/scripts/e2e/browser/walkthrough.ts b/scripts/e2e/browser/walkthrough.ts index 78f16b1272..faa5701ff6 100644 --- a/scripts/e2e/browser/walkthrough.ts +++ b/scripts/e2e/browser/walkthrough.ts @@ -37,12 +37,6 @@ import { type HubHandle, type SpawnedApp, } from "../harness.ts"; -import { createGitWorkflowPusher } from "../../../packages/connections/src/workflow-push.ts"; -import { createHubAPI } from "../../../packages/hub-api-client/src/index.ts"; -import { - ensureSeeded, - testAndPersistCredential, -} from "../../../packages/onboarding/src/complete-credential.ts"; import { OLLAMA_PLACEHOLDER_SECRET } from "../../../packages/connections/src/credential-test.ts"; const REPO_ROOT = path.resolve(import.meta.dir, "..", "..", ".."); @@ -587,89 +581,34 @@ async function run(): Promise { () => page, "03-connect-provider-stub-key", async () => { - // Onboarding's own credential step (CL-6123) stores a pasted key - // immediately, with no live probe of the provider gating it — - // unlike Settings > Connections' connector cards - // (packages/connections/src/routes.ts), which still probe a - // pasted key for real before storing it. This step drives the - // same two halves the onboarding route itself calls - // (`testAndPersistCredential` / `ensureSeeded`, from - // `@workbench/onboarding`) directly rather than through HTTP — - // matching `local-rip.test.ts` (CL-6055)'s pattern of calling a - // package's own functions rather than round-tripping through a - // browser for a step already covered elsewhere — so everything - // (persisting the credential, publishing the tool registry, - // pushing and deploying every default workflow including - // "assistant"/Myra) runs for real, against the real spawned hub, - // using the real session cookie this browser just signed in - // with. The stub key itself is never sent anywhere here — it is - // stored as an unproven model source, exactly as onboarding's - // own key path would store it, so the later chat message is the - // first time it is ever dialed for real. - const cookies = (await page.cookies()).map((c) => `${c.name}=${c.value}`); - const hubApi = createHubAPI(hub.baseUrl); - const session = await hubApi("GET", "/api/auth/get-session", undefined, cookies); - const userId = (session.data as { user?: { id?: string } } | null)?.user?.id; - if (userId === undefined || userId === "") { - throw new Error(`no authenticated session found: ${JSON.stringify(session.data)}`); - } - const pushWorkflow = createGitWorkflowPusher(); - const connectArgs = { - api: hubApi, - cookies, - hubUrl: hub.baseUrl, - userId, - userEmail: email, - provider: connectProvider, - apiKey: stubApiKey, - pushWorkflow, - log: () => undefined, - }; - const connected = await testAndPersistCredential( - useOllama && ollamaBaseUrl !== undefined - ? { ...connectArgs, baseURLOverride: ollamaBaseUrl } - : connectArgs, - ); - if (connected.kind !== "connected") { - throw new Error( - `expected the key-path connect to succeed, got: ${JSON.stringify(connected)}`, - ); - } - const deadline = Date.now() + 60_000; - for (;;) { - if (sidecar.exited()) { - throw new Error( - `sidecar exited before seeding could complete; output:\n${sidecar.output()}`, + // The hub never seeds (CL-8207): the onboarding page's own + // `ProviderConnectStep` is the only path that plants a credential + // and installs Myra, so this step drives that real form — the + // same radiogroup + password input a person fills in — rather + // than calling any package function directly. Selecting `ollama` + // needs no key (the placeholder secret still satisfies the + // form's `required` input). + if (!useOllama) { + const providerLabel = connectProvider === "anthropic" ? "Anthropic" : connectProvider; + await page.evaluate((label: string) => { + const option = Array.from(document.querySelectorAll('[role="radio"]')).find((el) => + el.textContent?.includes(label), ); - } - try { - const seedArgs = { - api: hubApi, - cookies, - hubUrl: hub.baseUrl, - pushWorkflow, - log: () => undefined, - tenant: connected, - provider: connectProvider, - apiKey: stubApiKey, - }; - const seeded = await ensureSeeded( - useOllama && ollamaBaseUrl !== undefined - ? { ...seedArgs, baseURLOverride: ollamaBaseUrl } - : seedArgs, - ); - if (seeded.kind === "seeded-pending-agents") { - throw new Error(seeded.message); - } - return { - status: "pass", - detail: `stub credential stored and every default workflow deployed: ${seeded.workflows.join(", ")}`, - }; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } + (option as HTMLElement | undefined)?.click(); + }, providerLabel); } + await page.type('input[type="password"]', stubApiKey); + await page.click('button[type="submit"]'); + // The form's submit hands off to `runPortableClientBootstrap` + // (tool-registry publish + Myra deploy), which lands on `/` once + // converged. + await page.waitForFunction(() => window.location.pathname === "/", { + timeout: 60_000, + }); + return { + status: "pass", + detail: "stub credential connected through the onboarding UI; Myra installed", + }; }, ); @@ -683,9 +622,9 @@ async function run(): Promise { () => page, "04-bare-root-lands-in-myra-conversation", async () => { - // `testAndPersistCredential`/`ensureSeeded` above deployed the - // default workflows but never opened Myra's DM — this account has - // zero conversations at this point, so `/` (bare root, `HomeRoute`) + // Step 03's onboarding-UI connect deployed the default workflows + // but never opened Myra's DM — this account has zero + // conversations at this point, so `/` (bare root, `HomeRoute`) // must land in Myra's DM rather than stranding the account on a // spinner or bouncing to `/new`. await page.goto(webBaseUrl, { waitUntil: "domcontentloaded" }); diff --git a/scripts/e2e/chat.test.ts b/scripts/e2e/chat.test.ts deleted file mode 100644 index 33e1000d06..0000000000 --- a/scripts/e2e/chat.test.ts +++ /dev/null @@ -1,1004 +0,0 @@ -// The permanent booted-stack chat e2e: two principals per tenant, -// messages fanning in to a single converged timeline through the -// mounted `@corbits/chat` HTTP surface, settings and read-state live, -// and mention fan-out driving a second run off its own mailbox. -// Deterministic — no credentials, no real inference, no API keys. -// -// The path proven: database setup (chat migrations apply) → hub boot -// → sidecar boot → two sign-ups, an owner and an invited-then-activated -// member → an inference catalog chain seeded with a placeholder key → -// the stock assistant default workflow seeded the same way -// `greeting-delivery` proves invitable → a workbench created (data only -// since CL-6330 — no host run, the go/no-go test) → both users posting -// messages and reading back the converged, decoded timeline with sender -// identity → a second message proving the room keeps accepting mail → -// a settings patch that both updates the record and appends an audit -// event to the timeline → independent per-user read-state cursors → -// mentioning an already-resident agent participant twice, fanning each -// mention into its existing run and never minting a sibling (CL-6451) → -// the workbench kind filter. -// -// Stock Interchange cutover: one workbench per conversation tenant — -// `POST .../chat/workbenches` stamps `workbenchId = tenant.id` -// (`packages/chat/src/routes.ts`), so a second creation inside the same -// tenant conflicts on the settings row (500). Every test that mints its -// own workbench therefore builds its own tenant through the -// suite-scoped `setupTenant` fixture below (owner + invited member + -// grants + catalog), and the assistant deploy it needs rides the stock -// `seedTenant` default-workflow path — never the deleted -// `agent-definitions/by-name` surface, and never a second agent chat -// for one definition (the `chat/definitionId` 1:1 in -// `packages/chat/src/agent-dm-mode.ts`). -// -// Structured as one shared-boot stack (`beforeAll`) with a separate -// `test` per capability, rather than one long test: a real defect -// blocking one capability (see the "settings" test) then still lets -// every independent capability report its own true result, instead of -// one thrown error masking everything declared after it. -// -// This is permanent smoke coverage, not a demo script: each run resets -// its own sibling `_e2e` database, failures name the -// capability that broke, and teardown stops every spawned process. - -import { beforeAll, describe, expect, test } from "bun:test"; - -import { seedCatalog } from "../../packages/connections/src/seed-catalog.ts"; -import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; -import { createHubAPI, type ApiCall } from "../../packages/hub-api-client/src/index.ts"; -import type { Part } from "../../packages/chat/src/index.ts"; -import { DEFAULT_WORKFLOWS, seedTenant } from "../../packages/onboarding/src/tenant-seed.ts"; -import { modelSourceFor } from "../../packages/onboarding/src/complete-credential.ts"; -import { publishCorbitsToolsRegistry } from "../../packages/tool-registry-publish/src/publish.ts"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - startHub, - type ApiResult, - type HubHandle, -} from "./harness.ts"; - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "chat e2e: DATABASE_URL is not set; suite skipped. Set DATABASE_URL " + - "(see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` so this skip " + - "can never pass silently there.", - ); -} - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -function objectField(data: unknown, field: string, what: string): Record { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "object" && value !== null) { - return value as Record; - } - } - throw new Error(`${what}: missing object field "${field}": ${JSON.stringify(data)}`); -} - -function arrayField(data: unknown, field: string, what: string): unknown[] { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (Array.isArray(value)) return value; - } - throw new Error(`${what}: missing array field "${field}": ${JSON.stringify(data)}`); -} - -const { tempDir, track } = createCleanupHarness(); - -type SignedUpUser = { userId: string; email: string; cookies: string[] }; - -async function signUp(api: ApiCall, name: string): Promise { - const email = `chat-e2e-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const res = await api("POST", "/api/auth/sign-up/email", { - name, - email, - password, - }); - if (res.status !== 200) { - throw new Error( - `sign-up for ${name} failed: expected 200, got ${res.status}: ${JSON.stringify(res.data)}`, - ); - } - if (res.cookies.length === 0) { - throw new Error(`sign-up for ${name} returned no session cookie`); - } - const userId = stringField( - objectField(res.data, "user", `sign-up response for ${name}`), - "id", - `sign-up user field for ${name}`, - ); - return { userId, email, cookies: res.cookies }; -} - -type ListedMessage = { - id: string; - sender: { name: string | null; address: string }; - parts: Part[]; -}; - -const textPart = (text: string): Part[] => [{ kind: "text", text }]; - -describe.skipIf(databaseUrl === undefined)("chat e2e", () => { - let hub: HubHandle; - let api: ApiCall; - let user1: SignedUpUser; - let user2: SignedUpUser; - let tenantId: string; - let domain: string; - let workbenchId: string; - let pushWorkflow: ReturnType; - let assistantId: string; - - type TenantFixture = { - tenantId: string; - domain: string; - ownerPrincipalId: string; - user2PrincipalId: string; - }; - - // Suite-scoped tenancy fixture: one conversation tenant carrying both - // users — user1 as owner, user2 invited by email and activated by the - // owner, carrying the read/write grants chat's routes gate on (the - // same pair `packages/onboarding/src/tenant-seed.ts`'s `plantGrant` - // plants for a bench's own principal), over a placeholder-credential - // catalog chain. Stock Interchange cutover: `POST .../chat/workbenches` - // stamps `workbenchId = tenant.id`, so a tenant hosts exactly one - // workbench — every test that mints its own workbench builds its own - // fixture rather than sharing the `beforeAll` tenant. - async function setupTenant(): Promise { - const slug = `chate2e${crypto.randomUUID().slice(0, 8)}`; - const created = await api("POST", "/api/tenants", { name: "Chat E2E", slug }, user1.cookies); - expectStatus("create tenant", created, 201); - const fixtureTenantId = stringField(created.data, "id", "create tenant"); - const fixtureDomain = stringField(created.data, "domain", "create tenant"); - - // user2 joins the tenant: invited by email, then activated by the - // owner — an invited principal is refused by tenant middleware - // (403) until its status is "active". Being a non-owner - // principal, user2 carries no grants of its own by default (only - // the tenant creator gets the platform's wildcard owner grant). - const invited = await api( - "POST", - `/api/tenants/${fixtureTenantId}/members/invite`, - { email: user2.email }, - user1.cookies, - ); - expectStatus("invite user2", invited, 201); - const principal2Id = stringField(invited.data, "id", "invite user2"); - expect(stringField(invited.data, "status", "invite user2")).toBe("invited"); - - const activated = await api( - "PATCH", - `/api/tenants/${fixtureTenantId}/principals/${principal2Id}`, - { status: "active" }, - user1.cookies, - ); - expectStatus("activate user2", activated, 200); - expect(stringField(activated.data, "status", "activate user2")).toBe("active"); - - async function plantGrant( - principalId: string, - resource: string, - action: string, - ): Promise { - const res = await api( - "POST", - `/api/tenants/${fixtureTenantId}/grants`, - { principalId, resource, action, effect: "allow", origin: "creator" }, - user1.cookies, - ); - expectStatus(`grant ${resource}/${action} to ${principalId}`, res, 201); - } - await plantGrant(principal2Id, "workflow-run:*", "read"); - await plantGrant(principal2Id, "workflow-run:*", "write"); - // The room routes gate on `room:` since CL-6346, not on the - // run pair above — same two grants `seed.ts` plants for a bench's - // own principal. - await plantGrant(principal2Id, "room:*", "read"); - await plantGrant(principal2Id, "room:*", "write"); - - // A workbench host's folded launch pins a real inference source - // chain against the tenant catalog before it will launch at all, - // even though it never performs inference — the placeholder key - // is never used to call a model. - await seedCatalog({ - api, - cookies: user1.cookies, - tenantId: fixtureTenantId, - placeholderCredential: true, - log: () => undefined, - }); - - // The creator's owner principal, for `seedTenant`'s seed grants: - // resolved through the stock tenant principals listing rather than - // assumed, matching on the signup's user id. The stock route is - // cursor-paginated (`{ data, nextCursor }` — see - // `vendor/intx/hub-api/src/routes/principals.ts`), not an `items` - // envelope. - const principalsListed = await api( - "GET", - `/api/tenants/${fixtureTenantId}/principals?kind=user`, - undefined, - user1.cookies, - ); - expectStatus("list tenant principals", principalsListed, 200); - const owner = arrayField(principalsListed.data, "data", "list tenant principals").find( - (row) => (row as { refId?: unknown }).refId === user1.userId, - ) as { id: string } | undefined; - if (owner === undefined) { - throw new Error( - `no owner principal for user1 in tenant ${fixtureTenantId}: ` + - JSON.stringify(principalsListed.data), - ); - } - return { - tenantId: fixtureTenantId, - domain: fixtureDomain, - ownerPrincipalId: owner.id, - user2PrincipalId: principal2Id, - }; - } - - // The stock `invitable-definitions` listing is the only definition - // source this suite uses: the deleted `agent-definitions/by-name` - // surface resolved the old echo workflow directly by name, and the - // invite dialog's own listing filters non-conversational definitions. - // Polls until the seeded "assistant" appears (the deploy converges - // behind the seed call). - async function assistantDefinitionId(forTenant: string): Promise { - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - "GET", - `/api/tenants/${forTenant}/chat/invitable-definitions`, - undefined, - user1.cookies, - ); - if (res.status === 200) { - const items = arrayField(res.data, "items", "list invitable definitions") as { - id: string; - name: string; - }[]; - const assistant = items.find((item) => item.name === "assistant"); - if (assistant !== undefined) return assistant.id; - } - if (Date.now() > deadline) { - throw new Error(`"assistant" never appeared as invitable: ${JSON.stringify(res.data)}`); - } - await Bun.sleep(1000); - } - } - - // Seeds the stock assistant default workflow onto a fixture tenant — - // the same `seedTenant` path `greeting-delivery` proves leaves - // "assistant" invitable — and returns its listed definition id. The - // deploy needs the sidecar's dial-in to have completed (its call - // throws until it has), and every step is ensure-then-create, so the - // whole call retries safely; deployments are never confirmed here - // (no inference turns — the placeholder key is never dialed). - async function deployAssistant(fixture: TenantFixture): Promise { - const deadline = Date.now() + 120_000; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before assistant deploy; output:\n${hub.output()}`); - } - try { - // CL-7071 cutover: seeding no longer packs, and a launched - // agent's sidecar initialization resolves its `@corbits` scope - // pins against the tenant's own `corbits-tools` package - // registry — without it the allocation fails with - // `sidecar_initialization_failed` and the agent's run never - // commits events. Install the registry the same way the - // connect flow's setup step does (see `local-rip`'s - // publish-tools hop), before seedTenant deploys assistant. - const published = await publishCorbitsToolsRegistry({ - api, - cookies: user1.cookies, - hubUrl: hub.baseUrl, - tenantId: fixture.tenantId, - log: () => undefined, - }); - if (!published.success) { - throw new Error( - `corbits-tools registry publish failed: ${JSON.stringify(published.summaries)}`, - ); - } - await seedTenant({ - api, - cookies: user1.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: fixture.tenantId, - principalId: fixture.ownerPrincipalId, - domain: fixture.domain, - }, - model: await modelSourceFor(api, user1.cookies, fixture.tenantId, "anthropic"), - pushWorkflow, - log: () => undefined, - workflows: DEFAULT_WORKFLOWS, - confirmDeployments: false, - }); - break; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - return assistantDefinitionId(fixture.tenantId); - } - - beforeAll(async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - // The chat package's own migrations apply right after the - // platform's, per scripts/db-setup.ts. - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - expect(report.migrations).toBeGreaterThan(0); - - hub = await startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("e2e-chat-hub-data-"), - }); - track(hub); - - api = createHubAPI(hub.baseUrl); - - // Two independent browser-shaped accounts, each carrying its own - // session cookie for the rest of the suite. - user1 = await signUp(api, "Chat Tester One"); - user2 = await signUp(api, "Chat Tester Two"); - - pushWorkflow = createGitWorkflowPusher(); - // The shared tenant carries the room the timeline/read-state/ - // settings tests share, plus the stock assistant the invite test - // resolves through the `invitable-definitions` listing. - const shared = await setupTenant(); - tenantId = shared.tenantId; - domain = shared.domain; - assistantId = await deployAssistant(shared); - }, 180_000); - - // Launching a workbench is the go/no-go signal for the whole suite: it - // launches the anchor instance in-process, which needs its - // process-provisioner-spawned sidecar's dial-in to have completed. - // `packages/chat/src/routes.ts` has no 502-style retry translation of - // its own (unlike the native workflow deploy route), so a launch - // attempted before the sidecar connects fails with an uncaught 500 — - // retried here directly until the sidecar is ready, exactly as the - // walking skeleton retries a 502 for the native deploy route. - async function createWorkbench( - body: Record, - forTenant: string = tenantId, - ): Promise { - const deadline = Date.now() + 60_000; - let res: ApiResult; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before workbench creation; output:\n${hub.output()}`); - } - res = await api("POST", `/api/tenants/${forTenant}/chat/workbenches`, body, user1.cookies); - if (res.status !== 500) break; - if (Date.now() > deadline) { - throw new Error( - `workbench never became launchable (hub kept answering 500): ` + - `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(1000); - } - return res; - } - - async function postMessage( - cookies: string[], - workbench: string, - text: string, - forTenant: string = tenantId, - ): Promise { - const res = await api( - "POST", - `/api/tenants/${forTenant}/chat/workbenches/${workbench}/messages`, - { parts: textPart(text) }, - cookies, - ); - expectStatus(`post message "${text}"`, res, 201); - return stringField(res.data, "id", `post message "${text}"`); - } - - async function listMessages( - cookies: string[], - workbench: string, - forTenant: string = tenantId, - ): Promise { - const res = await api( - "GET", - `/api/tenants/${forTenant}/chat/workbenches/${workbench}/messages`, - undefined, - cookies, - ); - expectStatus("list messages", res, 200); - return arrayField(res.data, "items", "list messages") as unknown as ListedMessage[]; - } - - type RunEvent = { seq: number; type: string }; - - /** - * A recipient run's own event log. Since CL-6327 a room's timeline is - * `chat.workbench_messages` — workbench data written by whoever posted - * — so a fan-out copy delivered to an agent's mailbox never becomes a - * row under the recipient's id, and `GET .../messages` on the - * recipient can no longer witness the delivery. The run's committed - * event log is what does: mail that reaches a run drives it, and - * `RunStarted`/`StepStarted` commit before any inference is attempted, - * so the placeholder key CI runs with cannot suppress them. - */ - async function runEvents( - cookies: string[], - runId: string, - forTenant: string = tenantId, - ): Promise { - const res = await api( - "GET", - `/api/tenants/${forTenant}/workflows/runs/${runId}/events`, - undefined, - cookies, - ); - expectStatus(`list run events for ${runId}`, res, 200); - return arrayField(res.data, "events", `run events for ${runId}`) as unknown as RunEvent[]; - } - - function highestSeq(events: readonly RunEvent[]): number { - return events.reduce((highest, event) => Math.max(highest, event.seq), -1); - } - - /** - * Polls (bounded) until the run commits an event newer than - * `sinceSeq`. A recipient run is already alive before the message - * under test is posted — its anchor launched at workbench-creation - * time — so "the run has events" proves nothing; only events past the - * pre-post watermark do. - */ - async function waitForRunProgress( - cookies: string[], - runId: string, - sinceSeq: number, - forTenant: string = tenantId, - ): Promise { - const deadline = Date.now() + 60_000; - for (;;) { - const fresh = (await runEvents(cookies, runId, forTenant)).filter( - (event) => event.seq > sinceSeq, - ); - if (fresh.length > 0) return fresh; - if (Date.now() > deadline) return fresh; - await Bun.sleep(1000); - } - } - - test("workbench creation launches the anchor", async () => { - const res = await createWorkbench({ kind: "workbench", name: "demo" }); - expectStatus("create workbench", res, 201); - expect(stringField(res.data, "kind", "create workbench")).toBe("workbench"); - workbenchId = stringField(res.data, "id", "create workbench"); - }, 90_000); - - const firstFromUser1 = `hello from user1 ${crypto.randomUUID()}`; - const firstFromUser2 = `hello from user2 ${crypto.randomUUID()}`; - - test("both users post; the timeline converges with sender identity", async () => { - await postMessage(user1.cookies, workbenchId, firstFromUser1); - await postMessage(user2.cookies, workbenchId, firstFromUser2); - - const items = await listMessages(user1.cookies, workbenchId); - const texts = items.map((item) => ({ - text: ( - item.parts.find((p) => p.kind === "text") as { kind: "text"; text: string } | undefined - )?.text, - senderAddress: item.sender.address, - })); - - const foundUser1 = texts.find((t) => t.text === firstFromUser1); - const foundUser2 = texts.find((t) => t.text === firstFromUser2); - if (foundUser1 === undefined || foundUser2 === undefined) { - throw new Error(`converged timeline missing a message: ${JSON.stringify(items)}`); - } - // Each message carries a sender identity distinct per author — the - // address is the platform's own per-principal mail identity (not - // the better-auth user id), so this asserts the two authors are - // told apart rather than pinning the exact address shape. - expect(foundUser1.senderAddress).toContain("@"); - expect(foundUser2.senderAddress).toContain("@"); - expect(foundUser1.senderAddress).not.toBe(foundUser2.senderAddress); - expect(foundUser1.senderAddress.endsWith(`@${domain}`)).toBe(true); - expect(foundUser2.senderAddress.endsWith(`@${domain}`)).toBe(true); - }); - - test("a second message from user2 proves the anchor keeps accepting mail", async () => { - const secondFromUser2 = `second message from user2 ${crypto.randomUUID()}`; - await Bun.sleep(50); - await postMessage(user2.cookies, workbenchId, secondFromUser2); - const items = await listMessages(user1.cookies, workbenchId); - const texts = items.flatMap((item) => - item.parts - .filter((p): p is Extract => p.kind === "text") - .map((p) => p.text), - ); - expect(texts).toContain(secondFromUser2); - expect(texts).toContain(firstFromUser1); - expect(texts).toContain(firstFromUser2); - }); - - // Read-state is proven ahead of the settings test below: the - // settings PATCH appends a non-text event part to this workbench's - // timeline, and `GET .../messages` on a workbench carrying one - // currently 500s (see that test's note) — read-state's own routes - // read from the room table, so ordering it first keeps this test's - // result honest regardless of that failure. - test("read-state cursors are independent per user", async () => { - const seenId = await postMessage( - user1.cookies, - workbenchId, - `read-state marker ${crypto.randomUUID()}`, - ); - const putUser1 = await api( - "PUT", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/read-state`, - { lastSeenCreatedAt: new Date().toISOString(), lastSeenId: seenId }, - user1.cookies, - ); - expectStatus("put user1 read-state", putUser1, 200); - - const gotUser1 = await api( - "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/read-state`, - undefined, - user1.cookies, - ); - expectStatus("get user1 read-state", gotUser1, 200); - expect(stringField(gotUser1.data, "lastSeenId", "user1 read-state")).toBe(seenId); - - const gotUser2 = await api( - "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/read-state`, - undefined, - user2.cookies, - ); - expectStatus("get user2 read-state", gotUser2, 200); - expect((gotUser2.data as { lastSeenId: string | null }).lastSeenId).toBe(null); - }); - - // Stock Interchange cutover: one workbench per conversation tenant, - // and the shared tenant's room already occupies it — so this test - // builds its own fixture tenant (with its own assistant deploy) and - // threads that tenant through every call below. - // CL-6330 deleted the workbench-anchor machinery: a bare `kind: - // "workbench"` room mints only a child tenant and settings rows — no - // host workflow, no run, nothing `/workflows/runs/:runId/events` can - // ever resolve. Only an invited agent (`kind: "chat"` or `POST - // .../invite`) is backed by a real `workflow_run`. This test used to - // mention a second bare workbench and poll its (nonexistent) run, - // which is exactly the 404 CL-6436 tracks. It now proves the CL-6451 - // contract this test was actually meant to guard: mentioning a - // participant already resident in the room drives that participant's - // existing run — twice, never minting a sibling. - // A dedicated `kind: "workbench"` room, never `kind: "chat"`: an agent - // chat this test minted for itself used to sit in the tenant forever - // as a second, older conversation, and the old `findExistingAgentChat` - // tenant-wide dedup would then have found *this* leftover instead of - // the one the reuse test created and expected back (CL-6481 — this - // collision, introduced when this test was rewritten around a real - // resident agent, was the reuse test's actual failure). The stock - // Interchange cutover deleted that dedup surface entirely — one chat - // per definition 1:1 (`packages/chat/src/agent-dm-mode.ts`) — so a - // plain workbench now doubles as proof the room never enters a - // `kind: "chat"` listing at all. - test("mention fan-out drives the mentioned run", async () => { - const mention = await setupTenant(); - const mentionAssistantId = await deployAssistant(mention); - const mentionRoom = await createWorkbench( - { - kind: "workbench", - name: "mention fan-out room", - }, - mention.tenantId, - ); - expectStatus("create mention fan-out room", mentionRoom, 201); - const mentionRoomId = stringField(mentionRoom.data, "id", "create mention fan-out room"); - - const invited = await api( - "POST", - `/api/tenants/${mention.tenantId}/chat/workbenches/${mentionRoomId}/invite`, - { definitionId: mentionAssistantId }, - user1.cookies, - ); - expectStatus("invite assistant into mention fan-out room", invited, 201); - const assistantAddress = stringField( - invited.data, - "address", - "invite assistant into mention fan-out room", - ); - const assistantLocalPart = assistantAddress.split("@")[0]; - if (assistantLocalPart === undefined || assistantLocalPart === "") { - throw new Error(`malformed assistant address: ${assistantAddress}`); - } - - const beforeFirst = highestSeq( - await runEvents(user1.cookies, assistantLocalPart, mention.tenantId), - ); - await postMessage( - user1.cookies, - mentionRoomId, - `hey @myra take a look ${crypto.randomUUID()}`, - mention.tenantId, - ); - const afterFirst = await waitForRunProgress( - user1.cookies, - assistantLocalPart, - beforeFirst, - mention.tenantId, - ); - expect(afterFirst.length).toBeGreaterThan(0); - - // The resident-reuse claim (CL-6451): a second mention of the same - // already-resident participant drives the SAME run id further — - // never mints a sibling — so this polls that same - // `assistantLocalPart` run again rather than any newly-discovered - // address, and confirms the room still lists exactly the one agent - // participant. - const beforeSecond = highestSeq( - await runEvents(user1.cookies, assistantLocalPart, mention.tenantId), - ); - await postMessage( - user1.cookies, - mentionRoomId, - `hey @myra one more thing ${crypto.randomUUID()}`, - mention.tenantId, - ); - const afterSecond = await waitForRunProgress( - user1.cookies, - assistantLocalPart, - beforeSecond, - mention.tenantId, - ); - expect(afterSecond.length).toBeGreaterThan(0); - - const settingsAfterSecondMention = await api( - "GET", - `/api/tenants/${mention.tenantId}/chat/workbenches/${mentionRoomId}/settings`, - undefined, - user1.cookies, - ); - expectStatus("get settings after second mention", settingsAfterSecondMention, 200); - const participantsAfterSecondMention = arrayField( - settingsAfterSecondMention.data, - "participants", - "get settings after second mention", - ) as { address: string; handle: string }[]; - expect(participantsAfterSecondMention).toHaveLength(1); - expect(participantsAfterSecondMention[0]?.address).toBe(assistantAddress); - }, 90_000); - - test("inviting the assistant launches its own run, joins the workbench, and receives @mentions", async () => { - // The stock assistant deploy the shared fixture seeded in - // `beforeAll` is invitable (conversational, carried in the invite - // dialog's own listing) — this test invites it by the definition id - // that deploy resolved, proving the same path `greeting-delivery` - // covers end to end from a second suite. - const invited = await api( - "POST", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/invite`, - { definitionId: assistantId }, - user1.cookies, - ); - expectStatus("invite assistant", invited, 201); - const invitedAddress = stringField(invited.data, "address", "invite assistant"); - expect(stringField(invited.data, "definitionId", "invite assistant")).toBe(assistantId); - const invitedLocalPart = invitedAddress.split("@")[0]; - if (invitedLocalPart === undefined || invitedLocalPart === "") { - throw new Error(`malformed invited agent address: ${invitedAddress}`); - } - - // The invited agent's own run's address joined this workbench's - // participants — as a record carrying a friendly mention handle - // derived from the invited definition's display name ("Myra"), never - // the unusable raw local part — and the join event landed on this - // workbench's own timeline. - const settingsAfterInvite = await api( - "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/settings`, - undefined, - user1.cookies, - ); - expectStatus("get settings after invite", settingsAfterInvite, 200); - const participantsAfterInvite = arrayField( - settingsAfterInvite.data, - "participants", - "get settings after invite", - ) as { address: string; handle: string }[]; - const invitedParticipant = participantsAfterInvite.find( - (participant) => participant.address === invitedAddress, - ); - if (invitedParticipant === undefined) { - throw new Error( - `invited participant record missing: ${JSON.stringify(participantsAfterInvite)}`, - ); - } - expect(invitedParticipant.handle).toBe("myra"); - - // The join event itself lands on this workbench's timeline as an - // `EventPart` (see `POST /workbenches/:id/invite` in - // packages/chat/src/routes.ts), the same way a settings-changed - // event does — but this suite cannot read it back via - // `GET .../messages` any more than the "settings update" test - // below can: an `EventPart` rides as a lone `application/json` MIME - // attachment, and reading any attachment back hits the same - // pre-existing `@intx/mime` `walkParts` defect that test documents - // (vendor/intx is out of this suite's file scope to fix). Once this - // workbench's timeline carries that attachment, `GET .../messages` - // 500s for it for the rest of the suite's run, which is exactly why - // this test never calls `listMessages` on `workbenchId` again below — - // only on the invited agent's own, still-clean workbench. - - // @mentioning the invited agent's friendly handle fans a copy into - // its own run's mailbox — the same fan-out pattern the earlier - // "mention fan-out" test proves for a workbench-to-workbench mention, - // now proving it reaches an invited agent's run by its handle - // rather than its raw instance-id local part. The invited agent's - // reply is never asserted: its inference source is a placeholder - // key in CI, so its own reply attempt errors, which is expected and - // irrelevant to this assertion — the mail arriving and driving the - // run is the whole claim. - const before = highestSeq(await runEvents(user1.cookies, invitedLocalPart)); - const mentionText = `hey @${invitedParticipant.handle} welcome ${crypto.randomUUID()}`; - await postMessage(user1.cookies, workbenchId, mentionText); - - const fresh = await waitForRunProgress(user1.cookies, invitedLocalPart, before); - expect(fresh.length).toBeGreaterThan(0); - }, 90_000); - - // Stock Interchange cutover: a tenant hosts exactly one workbench - // and the shared tenant's room already occupies it, so this chat - // lives on its own fixture tenant (with its own assistant deploy). - // The old test's second half — inviting the same definition into the - // chat again to launch a second run — is gone with the - // `chat/definitionId` 1:1: a second agent chat for one definition is - // now a 409, and the kind-filter test below proves the reuse side of - // that dedup instead. - test("a chat auto-invites the assistant and delivers un-mentioned messages to it", async () => { - const direct = await setupTenant(); - const directAssistantId = await deployAssistant(direct); - const chatCreated = await createWorkbench( - { - kind: "chat", - definitionId: directAssistantId, - }, - direct.tenantId, - ); - expectStatus("create chat", chatCreated, 201); - expect(stringField(chatCreated.data, "kind", "create chat")).toBe("chat"); - expect(stringField(chatCreated.data, "title", "create chat")).toBe("Myra"); - const chatId = stringField(chatCreated.data, "id", "create chat"); - const chatParticipants = arrayField(chatCreated.data, "participants", "create chat") as { - address: string; - handle: string; - }[]; - const chatAgent = chatParticipants.find((p) => p.handle === "myra"); - if (chatAgent === undefined) { - throw new Error(`chat has no "myra" agent participant: ${JSON.stringify(chatParticipants)}`); - } - const chatAgentLocalPart = chatAgent.address.split("@")[0]; - if (chatAgentLocalPart === undefined || chatAgentLocalPart === "") { - throw new Error(`malformed chat agent address: ${chatAgent.address}`); - } - - // No @mention is needed: a chat delivers every message to its one - // agent unconditionally. The agent's own inference source is a - // placeholder key in CI, so its reply attempt is expected to error - // and is never asserted here — only that the fan-out mail reached - // it. - const before = highestSeq(await runEvents(user1.cookies, chatAgentLocalPart, direct.tenantId)); - const unmentionedText = `no mention needed ${crypto.randomUUID()}`; - await postMessage(user1.cookies, chatId, unmentionedText, direct.tenantId); - - const fresh = await waitForRunProgress( - user1.cookies, - chatAgentLocalPart, - before, - direct.tenantId, - ); - expect(fresh.length).toBeGreaterThan(0); - }, 90_000); - - // Stock Interchange cutover: `workbenchId = tenant.id`, so one - // tenant hosts exactly one workbench — the old test's room-plus-chat - // pair on the shared tenant is now a PK-duplicate 500 on the second - // create, and `reuseExisting` is gone from `CreateWorkbenchBody` - // (arktype silently ignores the unknown key). The kind filter is - // proven over the tenant's single row instead, on its own fixture - // tenant so the shared tenant's room is untouched. - test("kind filter includes the tenant's workbench under its own kind and excludes it under the other", async () => { - const fixture = await setupTenant(); - const room = await createWorkbench( - { - kind: "workbench", - name: "kind filter room", - }, - fixture.tenantId, - ); - expectStatus("create kind filter room", room, 201); - const roomId = stringField(room.data, "id", "create kind filter room"); - - const workbenchKindListed = await api( - "GET", - `/api/tenants/${fixture.tenantId}/chat/workbenches?kind=workbench`, - undefined, - user1.cookies, - ); - expectStatus("list kind=workbench", workbenchKindListed, 200); - const workbenchKindIds = arrayField( - workbenchKindListed.data, - "items", - "list kind=workbench", - ).map((item) => (item as { id: string }).id); - expect(workbenchKindIds).toContain(roomId); - - const chatKindListed = await api( - "GET", - `/api/tenants/${fixture.tenantId}/chat/workbenches?kind=chat`, - undefined, - user1.cookies, - ); - expectStatus("list kind=chat", chatKindListed, 200); - const chatKindIds = arrayField(chatKindListed.data, "items", "list kind=chat").map( - (item) => (item as { id: string }).id, - ); - // The tenant's one row is a room, so the chat listing is empty — - // the filter never leaks a workbench into the other kind's view. - expect(chatKindIds).not.toContain(roomId); - }, 90_000); - - // Stock Interchange cutover: the old test's second half — inviting - // the same definition into its chat again to launch a second run — - // is gone with the chat 1:1 (`launchAndJoinAgent` throws - // `KindIsChatError`, mapped to 409, for any non-mint join into a - // chat). A seeded tenant carries exactly one invitable definition - // (CL-7074), so the enforcement is proven the other way the stock - // surface allows: a person-DM chat already bound to its one - // counterpart refuses an agent invite with a 409, never a sibling - // participant. - test("a chat stays one-counterpart: inviting an agent into a person-DM chat is a 409", async () => { - const fixture = await setupTenant(); - const fixtureAssistantId = await deployAssistant(fixture); - const dm = await createWorkbench( - { - kind: "chat", - principalId: fixture.user2PrincipalId, - }, - fixture.tenantId, - ); - expectStatus("create person-DM chat", dm, 201); - const dmId = stringField(dm.data, "id", "create person-DM chat"); - - const refused = await api( - "POST", - `/api/tenants/${fixture.tenantId}/chat/workbenches/${dmId}/invite`, - { definitionId: fixtureAssistantId }, - user1.cookies, - ); - expectStatus("invite an agent into a person-DM chat", refused, 409); - }, 90_000); - - // Settings is exercised last: `PATCH .../settings` folds the patch - // through `applyControlPayload` and posts each resulting event part - // onto the anchor's own timeline as its audit trail - // (`packages/chat/src/routes.ts`). That event part is not a - // `TextPart`, so `encodeParts` (`packages/chat/src/codec.ts`) rides - // it as a lone `application/json` MIME attachment rather than bare - // `content`. Reading it back — `GET .../messages` → - // `decodeMail` → `fetchBlob` → `extractPartByPath` — hits a real, - // pre-existing defect in the vendored `@intx/mime`'s `walkParts` - // (`@intx/mime/src/mime.ts`): its leaf-depth branch returns - // the attachment's raw MIME slice (headers *and* body) instead of - // the header-stripped body every intermediate depth already - // produces, so `JSON.parse` fails on the leading `Content-Type: ...` - // header text. This reproduces for any multi-part message, not just - // this event — it is out of this suite's file scope (`vendor/intx` - // is vendored and read-only) to fix; this test documents the defect - // precisely rather than working around it, and settings sends last - // so no other test depends on listing this workbench afterward. - test("settings update reflects on GET and events the timeline", async () => { - const newParticipant = `placeholder-participant-${crypto.randomUUID()}`; - const patched = await api( - "PATCH", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/settings`, - { "chat/participants": [newParticipant] }, - user1.cookies, - ); - expectStatus("patch settings", patched, 200); - - const fetched = await api( - "GET", - `/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/settings`, - undefined, - user1.cookies, - ); - expectStatus("get settings", fetched, 200); - const participants = arrayField(fetched.data, "participants", "get settings") as { - address: string; - handle: string; - }[]; - expect(participants.some((p) => p.address === newParticipant)).toBe(true); - - const items = await listMessages(user1.cookies, workbenchId); - const events = items.flatMap((item) => item.parts.filter((p) => p.kind === "event")); - const membershipEvent = events.find( - (e) => (e as { event: string }).event === "workbench.membership-changed", - ); - if (membershipEvent === undefined) { - throw new Error( - `no workbench.membership-changed event on the timeline: ${JSON.stringify(items)}`, - ); - } - }, 30_000); - - // Every chat/folded run above (the workbench anchor, the mentioned - // second workbench, the invited Myra assistant, the auto-invited - // chat agent) writes its own `workflow-run` mail pack over the course of - // this suite. Before CL-6043's self-anchor fix, every one of those - // packs was permanently rejected — `receiveWorkflowRunPack` - // (vendor/intx/hub-sessions/src/hub-session-lookups.ts) requires the - // live run at the source address to satisfy `anchorRunId === id`, - // and a folded run was written with `anchorRunId: null` (see - // `packages/folded-runs/src/launch.ts`). - // - // Asserting on the hub's own log was tried first, as the most direct - // proof, but the hub side is not a clean signal: a brand-new run — - // folded or a plain top-level deployment alike — can lose a benign, - // pre-existing race where its very first pack push reaches the hub - // before that run's own DB row has committed, logging the identical - // "no live deployment anchor" warning; the hub's redelivery retries - // it and it self-heals within a second or two. That race reproduces - // for a plain native deployment too (which has always - // self-anchored), so it is orthogonal to CL-6043 and made a hub-log - // assertion flake (confirmed empirically: 4 such transient - // warnings even with the fix applied and every test green). - // - // The sidecar side is the clean signal instead: a permanently - // rejected pack surfaces there as - // `enqueueInbox failed, withholding ack` (the hub never acks, so it - // keeps redelivering) and, once the run later goes idle and wakes, - // `rejecting inbound mail ...: workflow run ... is terminal` (see - // `vendor/intx/workflow-host/src/supervisor/supervisor.ts`) — never - // logged for a transient, self-healing DB-commit race. Verified by - // temporarily reverting the self-anchor fix locally: the sidecar log - // then carries ten `enqueueInbox failed, withholding ack` lines, one - // per rejected pack, for this same suite; with the fix applied, zero. - // Every process-provisioner-spawned sidecar inherits the hub - // process's own stdio (`process-runner.ts`'s `stdout: "inherit"`), so - // `hub.output()` already carries every sidecar's log lines. - test("no chat/folded run's workflow-run pack was ever permanently rejected", () => { - const hubOutput = hub.output(); - expect(hubOutput).not.toContain("enqueueInbox failed"); - expect(hubOutput).not.toContain("withholding ack"); - expect(hubOutput).not.toContain("rejecting inbound mail"); - }); -}); diff --git a/scripts/e2e/cl-6324-launch-proof.ts b/scripts/e2e/cl-6324-launch-proof.ts deleted file mode 100644 index db945e0784..0000000000 --- a/scripts/e2e/cl-6324-launch-proof.ts +++ /dev/null @@ -1,1180 +0,0 @@ -// The four proofs the workflow.json retirement has to clear, on one -// real stack: scratch database, real signup, real Ollama, nothing -// mocked. Self-contained in the `play.ts` shape (boot, sign up, connect, -// seed, mint, talk) but every step asserts, and it prints the timings -// the milestone asks for. -// -// 1. The seed goes fully green by source-ref — every default workflow -// deploys off a pushed source codebase, no `workflow.json` envelope. -// 2. A workbench mint walks the whole new deploy path: the approval -// probe answers, the closure materializes FOR REAL on the sidecar, -// and the run brackets a real per-message turn. Asserted in BOTH -// deploy shapes, because the bracket is a different durable -// artefact in each — see `PROOF 2` and `PROOF 2b` below. -// 3. A real human message gets a real model reply. -// 4. The sidecar is killed mid-turn and restarted: boot restore -// replays the deployment's pin, the room survives, and the next -// message is answered — in both shapes. -// -// Usage: -// E2E_PROVIDER=ollama OLLAMA_BASE_URL=http://localhost:11434 \ -// DATABASE_URL=postgres://localhost:5432/wb6324proof_e2e \ -// bun run scripts/e2e/cl-6324-launch-proof.ts -import { expect } from "bun:test"; -import { ApprovalResponse, paginatedSchema } from "@intx/types"; -import { type } from "arktype"; - -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join as pathJoin } from "node:path"; - -import { headlineFor } from "../../packages/approvals/src/headline.ts"; -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; -import { DEFAULT_WORKFLOWS, seedTenant } from "../../packages/onboarding/src/tenant-seed.ts"; -import { createHubAPI, type ApiCall } from "../../packages/hub-api-client/src/index.ts"; -import { WORKFLOW_SOURCE_ENTRY } from "../../packages/workflows/src/source.ts"; -import { - agentRuntimeTurnRunId, - buildAgentRuntimeWorkflow, -} from "../../packages/agent-runtime/src/index.ts"; -import { - findPersonalTenant, - testAndPersistCredential, - ensureSeeded, -} from "../../packages/onboarding/src/complete-credential.ts"; -import { - OLLAMA_PLACEHOLDER_SECRET, - ollamaOpenAICompatBaseURL, -} from "../../packages/connections/src/credential-test.ts"; -import { - api, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - provisionSidecar, - startHub, - startSidecar, - type ApiResult, - type HubHandle, - type SpawnedApp, -} from "./harness.ts"; - -const configuredDatabaseUrl = e2eDatabaseUrl(); -if (configuredDatabaseUrl === undefined) { - throw new Error( - "cl-6324-launch-proof: DATABASE_URL is not set. This suite proves a real " + - "boot and has nothing honest to assert without one.", - ); -} -const databaseUrl: string = configuredDatabaseUrl; - -const OLLAMA_BASE_URL = process.env["OLLAMA_BASE_URL"]; -if (process.env["E2E_PROVIDER"] !== "ollama" || OLLAMA_BASE_URL === undefined) { - throw new Error( - "cl-6324-launch-proof: set E2E_PROVIDER=ollama and OLLAMA_BASE_URL. The " + - "proofs require a real completion model actually answering.", - ); -} -const ollamaBaseUrl = OLLAMA_BASE_URL; - -// Named explicitly rather than taken from `CATALOG_SEEDS.ollama`: the -// curated seed lists models a given instance may simply not have pulled, -// and a deploy pinned at a model the instance cannot serve fails as an -// inference error that reads nothing like the thing being proved. -const OLLAMA_MODEL = process.env["E2E_OLLAMA_MODEL"]; -if (OLLAMA_MODEL === undefined || OLLAMA_MODEL === "") { - throw new Error( - "cl-6324-launch-proof: set E2E_OLLAMA_MODEL to a completion model the " + - "instance at OLLAMA_BASE_URL actually serves (see `ollama list`).", - ); -} -/** The model source every deploy in this proof pins, explicitly. */ -const proofModelSource = { - provider: "openai-compatible", - model: OLLAMA_MODEL, - baseURL: ollamaOpenAICompatBaseURL(ollamaBaseUrl), - apiKey: OLLAMA_PLACEHOLDER_SECRET, -} as const; - -const TURN_TIMEOUT_MS = 300_000; - -/** - * Proof 4's mid-turn workload. Deliberately far longer than any model - * can finish inside `MID_TURN_KILL_DELAY_MS`, so the kill lands while - * inference is genuinely running rather than in the quiet gap after a - * short turn already completed — the distinction between proving - * crash-recovery and proving a clean restart. - */ -const MID_TURN_PROMPT = - "Count slowly from one to four hundred, one number per line. " + - "Write every number out; do not stop early and do not summarise."; -const MID_TURN_KILL_DELAY_MS = 3000; - -const tracked: SpawnedApp[] = []; -const tempDir = (prefix: string) => mkdtemp(pathJoin(tmpdir(), prefix)); -const track = (app: SpawnedApp) => { - tracked.push(app); -}; -process.on("exit", () => { - for (const a of tracked) { - try { - void a.stop(); - } catch { - // Best-effort teardown: a child already gone is fine. - } - } -}); - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -function arrayField(data: unknown, field: string, what: string): unknown[] { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (Array.isArray(value)) return value; - } - throw new Error(`${what}: missing array field "${field}": ${JSON.stringify(data)}`); -} - -async function signUp( - baseUrl: string, - name: string, -): Promise<{ userId: string; email: string; cookies: string[] }> { - const email = `cl6324-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { - name, - email, - password, - }); - expectStatus(`sign-up for ${name}`, res, 200); - if (res.cookies.length === 0) { - throw new Error(`sign-up for ${name} returned no session cookie`); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - `sign-up user field for ${name}`, - ); - return { userId, email, cookies: res.cookies }; -} - -const timings: { label: string; ms: number }[] = []; -async function timed(label: string, run: () => Promise): Promise { - const t0 = Date.now(); - const value = await run(); - const ms = Date.now() - t0; - timings.push({ label, ms }); - // Printed as it lands, not only in the summary: a run that fails at a - // later proof still has to report what the earlier ones cost. - console.log(` TIMING — ${label}: ${(ms / 1000).toFixed(1)}s`); - return value; -} - -async function main(): Promise { - const url = databaseUrl; - - await hop("database setup", async () => { - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - }); - - const sidecarId = "cl6324-sidecar"; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, sidecarToken); - - const hub: HubHandle = await hop("hub boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("cl6324-hub-data-"), - }), - ); - track(hub); - const hubPort = Number(new URL(hub.baseUrl).port); - - // The sidecar's data dir is reused verbatim across the restart in - // proof 4: boot restore reads the deployments it left behind there. - const sidecarDataDir = await tempDir("cl6324-sidecar-data-"); - let sidecar: SpawnedApp = startSidecar({ - hubPort, - sidecarId, - token: sidecarToken, - dataDir: sidecarDataDir, - }); - track(sidecar); - - const hubApi: ApiCall = createHubAPI(hub.baseUrl); - const pushWorkflow = createGitWorkflowPusher(); - - const admin = await hop("alice genesis sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - expectStatus("alice sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("alice sign-up returned no session cookie"); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - "alice sign-up user field", - ); - const probe = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - res.cookies, - ); - expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); - const minted = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "Workbench" }, - res.cookies, - ); - expectStatus("alice genesis provision", minted, 200); - expect((minted.data as { kind: string }).kind).toBe("provisioned"); - return { cookies: res.cookies, userId }; - }); - - const user = await hop("sign up", async () => signUp(hub.baseUrl, "CL-6324 Proof")); - - const provisioned = await hop("a membership probe joins the genesis root", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - user.cookies, - ); - expectStatus("provision probe", res, 200); - const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("existing-member"); - return data; - }); - - // A joined member is read-only by design, so every owner-level leg - // below runs as alice, the genesis owner. - const tenant = await hop("joined root resolves", async () => { - const found = await findPersonalTenant(hubApi, admin.cookies, provisioned.tenantSlug); - if (found === undefined) { - throw new Error(`findPersonalTenant found nothing for slug ${provisioned.tenantSlug}`); - } - return found; - }); - - const connected = await hop("connect the local Ollama", async () => { - const result = await testAndPersistCredential({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - userId: admin.userId, - userEmail: "alice@example.com", - provider: "ollama", - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURLOverride: ollamaBaseUrl, - pushWorkflow, - log: () => undefined, - }); - if (result.kind !== "connected") { - throw new Error(`expected the key-path connect to succeed, got: ${JSON.stringify(result)}`); - } - return result; - }); - - // ---- proof 1: the seed goes fully green by source-ref ------------- - await hop("PROOF 1 — the credential's own seed completes", async () => { - const deadline = Date.now() + 180_000; - for (;;) { - try { - await ensureSeeded({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - pushWorkflow, - log: () => undefined, - tenant: connected, - provider: "ollama", - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURLOverride: ollamaBaseUrl, - }); - return; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - }); - - await timed("proof 1: every default workflow deploys by source-ref", () => - hop("PROOF 1 — every default workflow deploys by source-ref", async () => { - const deadline = Date.now() + 180_000; - for (;;) { - if (sidecar.exited()) { - throw new Error(`sidecar exited before the seed finished; output:\n${sidecar.output()}`); - } - try { - await seedTenant({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: tenant.tenantId, - principalId: tenant.principalId, - domain: tenant.tenantDomain, - }, - model: proofModelSource, - pushWorkflow, - log: () => undefined, - workflows: DEFAULT_WORKFLOWS, - confirmDeployments: false, - }); - return; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - }), - ); - - async function plantGrant(resource: string, action: string): Promise { - const granted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/grants`, - { - principalId: tenant.principalId, - resource, - action, - effect: "allow", - origin: "system", - }, - admin.cookies, - ); - if (granted.status !== 201 && granted.status !== 409) { - throw new Error( - `could not plant the ${resource}/${action} grant: ` + - `${String(granted.status)} ${JSON.stringify(granted.data)}`, - ); - } - } - - // A live Ollama connect seeds one catalog offering per pulled model, - // embedding models included, with no capability metadata to tell them - // apart (CL-6351). Default-model resolution then breaks the tie - // alphabetically, so `all-minilm` wins the bench default and every - // chat turn dies before it reaches a model. This proof pins ONE model - // on purpose, so it narrows the bench's own catalog to that model - // through the catalog API rather than leaving the turn's model to a - // coin flip the proof is not about. - const pinnedOfferingId = await hop("narrow the bench catalog to the pinned model", async () => { - await plantGrant("model-offering:*", "read"); - await plantGrant("model-offering:*", "manage"); - await plantGrant("model:*", "read"); - - const models = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/catalog/models?limit=200`, - undefined, - admin.cookies, - ); - expectStatus("list the bench catalog models", models, 200); - const modelRows = arrayField(models.data, "data", "catalog models") as { - id: string; - canonicalName: string; - }[]; - const pinned = modelRows.find((row) => row.canonicalName === proofModelSource.model); - if (pinned === undefined) { - throw new Error( - `the bench catalog carries no model named ${proofModelSource.model}; ` + - `it has ${JSON.stringify(modelRows.map((m) => m.canonicalName))}`, - ); - } - - const offerings = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/catalog/offerings?limit=200`, - undefined, - admin.cookies, - ); - expectStatus("list the bench model offerings", offerings, 200); - const offeringRows = arrayField(offerings.data, "data", "model offerings") as { - id: string; - modelId: string; - disabled: boolean; - }[]; - let pinnedOffering: { id: string; modelId: string } | undefined; - for (const offering of offeringRows) { - if (offering.modelId === pinned.id) { - pinnedOffering = offering; - continue; - } - if (offering.disabled) continue; - const patched = await api( - hub.baseUrl, - "PATCH", - `/api/tenants/${tenant.tenantId}/catalog/offerings/${offering.id}`, - { disabled: true }, - admin.cookies, - ); - expectStatus(`disable offering ${offering.id}`, patched, 200); - } - if (pinnedOffering === undefined) { - throw new Error( - `no catalog offering resolves the pinned model ${proofModelSource.model} ` + - `(id ${pinned.id})`, - ); - } - console.log( - ` TRANSCRIPT — bench catalog narrowed to ${proofModelSource.model} ` + - `(${String(offeringRows.length - 1)} other offerings disabled)`, - ); - return pinnedOffering.id; - }); - - const assistantDefinitionId = await hop( - "PROOF 1 — 'assistant' is invitable tenant-wide", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, - undefined, - admin.cookies, - ); - if (res.status === 200) { - const items = arrayField(res.data, "items", "invitable") as { - id: string; - name: string; - }[]; - const assistant = items.find((item) => item.name === "assistant"); - if (assistant !== undefined) return assistant.id; - } - if (Date.now() > deadline) { - throw new Error(`"assistant" never became invitable: ${JSON.stringify(res.data)}`); - } - await Bun.sleep(1000); - } - }, - ); - - // ---- proof 2: mint walks the whole new deploy path ---------------- - const { chatId, agentAddress, agentRunId } = await timed( - "proof 2: mint → probe → closure materialization", - () => - hop("PROOF 2 — POST /workbenches mints a chat with its agent joined", async () => { - const deadline = Date.now() + 120_000; - let res: ApiResult; - for (;;) { - if (sidecar.exited()) { - throw new Error(`sidecar exited before chat creation; output:\n${sidecar.output()}`); - } - res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches`, - { kind: "chat", definitionId: assistantDefinitionId }, - admin.cookies, - ); - if (res.status !== 500) break; - if (Date.now() > deadline) { - throw new Error( - `chat never became mintable: ${JSON.stringify(res.data)}\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(1000); - } - expectStatus("create chat", res, 201); - const id = stringField(res.data, "id", "create chat"); - const participants = arrayField(res.data, "participants", "create chat") as { - address: string; - handle: string; - }[]; - const agent = participants.find((p) => p.handle === "myra"); - if (agent === undefined) { - throw new Error(`chat has no "myra" participant: ${JSON.stringify(participants)}`); - } - const [runId] = agent.address.split("@"); - if (runId === undefined) { - throw new Error(`agent address is not a run address: ${agent.address}`); - } - return { chatId: id, agentAddress: agent.address, agentRunId: runId }; - }), - ); - - /** - * A folded run is self-anchored — its run id IS its deployment id — so - * the deployment and run path segments are the same value. - */ - async function readRunEvents(): Promise<{ seq: number; type: string }[]> { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/workflows/${agentRunId}/runs/${agentRunId}/events`, - undefined, - admin.cookies, - ); - if (res.status !== 200) return []; - const raw = res.data; - if ( - typeof raw !== "object" || - raw === null || - !Array.isArray((raw as Record)["events"]) - ) { - return []; - } - return (raw as { events: { seq: number; type: string }[] }).events; - } - - /** - * The run ids present in a deployment's workflow-run event repo — the - * anchor plus every child run that has committed an event. A - * section-mode occurrence shows up here as `turn__` the moment its - * first event lands. - */ - async function listDeploymentRunIds(anchorRunId: string): Promise { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/workflows/${anchorRunId}/runs`, - undefined, - admin.cookies, - ); - if (res.status !== 200) return []; - const raw = res.data; - if ( - typeof raw !== "object" || - raw === null || - !Array.isArray((raw as Record)["runIds"]) - ) { - return []; - } - return (raw as { runIds: string[] }).runIds; - } - - async function listAgentMessages(): Promise<{ id: string; text: string }[]> { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - admin.cookies, - ); - expectStatus("list chat messages", res, 200); - const items = arrayField(res.data, "items", "list chat messages") as { - id: string; - sender: { address: string }; - parts: { kind: string; text?: string }[]; - }[]; - return items - .filter((i) => i.sender.address === agentAddress && i.parts.some((p) => p.kind === "text")) - .map((i) => ({ - id: i.id, - text: i.parts.map((p) => p.text ?? "").join(""), - })); - } - - const seenIds = new Set(); - - // The joining greeting is CANNED copy `@corbits/chat` posts from the - // agent's address (`workbench-service.ts`'s welcome line), not a model - // turn — so it times the room and the participant join, and nothing - // about inference. The deploy path's first real token is proof 3's. - await timed("proof 2: canned join greeting lands (room + participant)", () => - hop("PROOF 2 — the minted chat carries its agent and its canned greeting", async () => { - const deadline = Date.now() + TURN_TIMEOUT_MS; - for (;;) { - const messages = await listAgentMessages(); - const greeting = messages.find((m) => m.text.trim().length > 0); - if (greeting !== undefined) { - for (const m of messages) seenIds.add(m.id); - console.log(` TRANSCRIPT — greeting: ${greeting.text}`); - return; - } - if (Date.now() > deadline) { - throw new Error( - `no agent greeting landed in chat ${chatId}\n` + `sidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(1000); - } - }), - ); - - // A folded `step`-mode run is ONE unbounded step servicing every - // inbound mail, so its per-message bracket is the `message.run.started` - // AGENT event (`packages/folded-runs/src/agent-events.ts`) — an - // in-process sidecar frame the hub consumes and never commits to the - // run's durable workflow event log. Asserting a workflow-host - // `RunStarted` per message here would be asserting the section shape's - // contract against the step shape's run, which is why the earlier - // revision of this proof hung on it. - // - // What IS durable and HTTP-observable for a step-mode turn is the reply - // row the completed bracket wrote, already asserted above. What this - // step adds is the shape fact itself, stated as a falsifiable - // assertion rather than a footnote: a step-mode run brackets NO - // per-occurrence child run. `PROOF 2b` asserts the opposite for the - // section shape, and the two together are the real evidence. - await hop( - "PROOF 2 — a step-mode run's durable bracket is its reply row, and it starts no per-occurrence child run", - async () => { - const events = await readRunEvents(); - const childRunIds = await listDeploymentRunIds(agentRunId); - console.log( - ` TRANSCRIPT — step-mode run ${agentRunId} workflow events: ` + - JSON.stringify(events.map((e) => `${String(e.seq)}:${e.type}`)), - ); - console.log( - ` TRANSCRIPT — step-mode run ${agentRunId} child run ids: ` + JSON.stringify(childRunIds), - ); - const perOccurrence = childRunIds.filter((id) => /__\d+$/.test(id)); - if (perOccurrence.length > 0) { - throw new Error( - `a step-mode folded run started per-occurrence child runs ` + - `${JSON.stringify(perOccurrence)}; the folded shape is one ` + - `unbounded step and must not fan out per message`, - ); - } - }, - ); - - // ---- proof 2b: the section shape's true per-occurrence child run --- - // - // Also CL-6329's first live validation: `mode: "section"` has existed - // as a config argument since the agent-runtime cutover, but nothing had - // ever deployed or run one. Here one is deployed for real — rendered - // into its own source package, pushed as a workflow-kind asset, - // deployed by source-ref, and driven with real mail — and every message - // becomes an `onTrigger` occurrence with its own child run id and its - // own durable event log, which is the artefact the milestone's - // `RunStarted` assertion was always about. - const SECTION_ASSET_NAME = "cl6324-section"; - const SECTION_TURN_TIMEOUT_MS = 180_000; - const sectionAddress = `${SECTION_ASSET_NAME}@${tenant.tenantDomain}`; - - const sectionDeploymentId = await timed( - "proof 2b: section-mode deploy (push + probe + freeze + deploy)", - () => - hop("PROOF 2b — a section-mode agent deploys from its own rendered source", async () => { - const created = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/assets`, - { - kind: "workflow", - name: SECTION_ASSET_NAME, - displayName: "CL-6324 section-mode proof", - }, - admin.cookies, - ); - expectStatus("create the section-mode workflow asset", created, 201); - const assetId = stringField(created.data, "id", "section-mode asset response"); - - const minted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/git-tokens`, - { - name: `cl6324-section-push-${crypto.randomUUID().slice(0, 8)}`, - resource: "asset:*", - refPattern: "**", - actions: ["can_read", "can_push"], - expiresAt: new Date(Date.now() + 600_000).toISOString(), - }, - admin.cookies, - ); - expectStatus("mint the section-mode push token", minted, 201); - const tokenSecret = stringField(minted.data, "secret", "git token response"); - - const model = proofModelSource; - // The same config object every folded launch renders, with the - // one field that selects the shape flipped to `section`. No - // tool pins and no credential bindings: this proof is about the - // occurrence shape, and an empty pin set keeps the deploy off - // the tool-manifest and MCP-handle surfaces entirely. - const definition = buildAgentRuntimeWorkflow({ - workflowId: "wf_cl6324_section", - agentId: "cl6324-section-agent", - triggerAddress: sectionAddress, - systemPrompt: "You are a terse assistant. Answer in one short sentence.", - inferencePreferences: [{ provider: model.provider, model: model.model }], - toolPackagePins: [], - credentialBindings: [], - mode: { kind: "section", turnTimeoutMs: SECTION_TURN_TIMEOUT_MS }, - }); - - const pushed = await pushWorkflow({ - remoteUrl: `${hub.baseUrl}/api/tenants/${tenant.tenantId}/assets/workflow/${SECTION_ASSET_NAME}.git`, - tokenSecret, - workflowJson: JSON.stringify(definition, null, 2), - packageName: SECTION_ASSET_NAME, - }); - - const deployed = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/workflows/deployments`, - { - source: { - kind: "asset", - assetId, - package: { format: "source", commitSha: pushed.commitSha }, - }, - entry: WORKFLOW_SOURCE_ENTRY, - sourceOfferingIds: [pinnedOfferingId], - defaultSourceOfferingId: pinnedOfferingId, - }, - admin.cookies, - ); - expectStatus("deploy the section-mode workflow", deployed, 201); - return stringField(deployed.data, "id", "section-mode deployment response"); - }), - ); - - /** Occurrence run ids the section has already produced. */ - const seenOccurrences = new Set(); - - const isOccurrenceRunId = (id: string) => /^turn__\d+$/.test(id); - - /** - * Drives one section occurrence: send real mail, then wait for a - * NEW child run to record `RunStarted` in its OWN event log. The - * runtime names an occurrence `__` - * (`agentRuntimeTurnRunId`), and the occurrence is discovered rather - * than assumed so a turn that died in the sidecar kill cannot shift - * every later index and turn a real pass into a false failure. - */ - async function driveSectionOccurrence(text: string, label: string): Promise { - const t0 = Date.now(); - // A 409 here means the deployment's address is not routable yet — the - // state a restart leaves behind while the sidecar's reclaim settles. - // Retrying the trigger is the same bounded wait a caller would do. - const triggerDeadline = Date.now() + 60_000; - for (;;) { - const triggered = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/workflows/${sectionDeploymentId}/mail`, - { content: text }, - admin.cookies, - ); - if (triggered.status === 202) break; - if (triggered.status !== 409 || Date.now() > triggerDeadline) { - expectStatus(`${label}: trigger the section`, triggered, 202); - } - await Bun.sleep(2000); - } - - const deadline = Date.now() + TURN_TIMEOUT_MS; - for (;;) { - if (sidecar.exited()) { - throw new Error( - `${label}: the sidecar exited while the section ran; output:\n${sidecar.output()}`, - ); - } - const runIds = await listDeploymentRunIds(sectionDeploymentId); - for (const turnRunId of runIds) { - if (!isOccurrenceRunId(turnRunId)) continue; - if (seenOccurrences.has(turnRunId)) continue; - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/workflows/${sectionDeploymentId}/runs/${turnRunId}/events`, - undefined, - admin.cookies, - ); - if (res.status !== 200) continue; - const events = arrayField(res.data, "events", `${label}: section occurrence events`) as { - seq: number; - type: string; - }[]; - if (!events.some((e) => e.type === "RunStarted")) continue; - seenOccurrences.add(turnRunId); - timings.push({ label: `${label}: RunStarted`, ms: Date.now() - t0 }); - console.log(` TRANSCRIPT — >>> (section) ${text}`); - console.log( - ` TRANSCRIPT — section occurrence ${turnRunId} events: ` + - JSON.stringify(events.map((e) => `${String(e.seq)}:${e.type}`)), - ); - return turnRunId; - } - if (Date.now() > deadline) { - throw new Error( - `${label}: no new section occurrence recorded RunStarted; ` + - `run ids present: ${JSON.stringify(runIds)}\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(1000); - } - } - - const firstOccurrence = await timed("proof 2b: first section occurrence to RunStarted", () => - hop("PROOF 2b — every message is an onTrigger occurrence with its own child run", () => - driveSectionOccurrence("In one short sentence, what can you help me with?", "proof 2b"), - ), - ); - - await hop( - "PROOF 2b — the first occurrence is the one the runtime's naming predicts", - async () => { - const expected = agentRuntimeTurnRunId(0); - if (firstOccurrence !== expected) { - throw new Error( - `the first section occurrence is ${firstOccurrence}, not the ` + - `${expected} the section's own id derivation predicts`, - ); - } - const parentEvents = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/workflows/${sectionDeploymentId}/runs/${sectionDeploymentId}/events`, - undefined, - admin.cookies, - ); - expectStatus("read the section's parent run log", parentEvents, 200); - const events = arrayField(parentEvents.data, "events", "section parent events") as { - seq: number; - type: string; - body?: { childRunId?: unknown }; - }[]; - console.log( - ` TRANSCRIPT — section parent run events: ` + - JSON.stringify(events.map((e) => `${String(e.seq)}:${e.type}`)), - ); - if (!events.some((e) => e.type === "ChildSpawned" && e.body?.childRunId === expected)) { - throw new Error( - `the section's parent run log records no ChildSpawned for ` + - `${expected}: ${JSON.stringify(events)}`, - ); - } - }, - ); - - // ---- proof 3: a real message gets a real reply -------------------- - // Pages through the platform's native `GET .../approvals` pending list - // and approves every pending row with scope "once", printing what it - // approved. Without this, a turn that needs an approval-gated tool - // never finishes and this proof times out waiting for a reply that - // can never come. - const TenantApprovalsSchema = paginatedSchema(ApprovalResponse); - async function autoApproveAll(): Promise { - let cursor: string | undefined; - for (;;) { - const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""; - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/approvals${query}`, - undefined, - admin.cookies, - ); - if (res.status !== 200) { - throw new Error( - `auto-approve: listing pending approvals failed with HTTP ` + - `${String(res.status)}: ${JSON.stringify(res.data)}`, - ); - } - const page = TenantApprovalsSchema(res.data); - if (page instanceof type.errors) { - throw new Error(`auto-approve: malformed pending approvals list: ${page.summary}`); - } - for (const item of page.data) { - const approved = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/approvals/${item.id}/approve`, - { scope: "once" }, - admin.cookies, - ); - const headline = headlineFor(item.toolDefinition, item.toolArguments); - if (approved.status === 200) { - console.log(` [auto-approved] ${headline}`); - } else { - console.log(` [auto-approve FAILED ${String(approved.status)}] ${headline}`); - } - } - if (page.nextCursor === null) break; - cursor = page.nextCursor; - } - } - - /** - * Sends one message and waits for the agent's answer. - * - * `resendsAllowed` is the number of times the RETRYABLE undelivered - * notice ("send it again") may be answered by actually sending it - * again. It is the product's own instruction to the reader, so - * honouring it is the honest reading of "the next message is - * answered" — but only where a wake is genuinely racing (the - * post-restart send in proof 4). Every other caller passes zero, so a - * notice there is the failure it looks like. The credential notice is - * never retried: resending can never fix it. - */ - async function sendAndAwaitReply(text: string, label: string, resendsAllowed = 0): Promise { - const t0 = Date.now(); - let resendsLeft = resendsAllowed; - for (;;) { - const sent = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - { parts: [{ kind: "text", text }] }, - admin.cookies, - ); - expectStatus("send message", sent, 201); - const reply = await awaitFreshReply(label); - console.log(` TRANSCRIPT — >>> ${text}`); - console.log(` TRANSCRIPT — <<< ${reply}`); - const retryable = /didn't get that one/i.test(reply); - if (retryable && resendsLeft > 0) { - resendsLeft -= 1; - console.log( - ` TRANSCRIPT — retryable notice; resending (${String(resendsLeft)} resend(s) left)`, - ); - continue; - } - // Both undelivered notices are agent-authored room messages from - // the agent's own address, so they look exactly like a reply to - // the reader above. Neither is a turn. - if (/^\s*$/.test(reply) || retryable || /can't reach a model right now/i.test(reply)) { - throw new Error( - `${label}: the agent answered with the undelivered notice, not a ` + - `real turn: ${JSON.stringify(reply)}\n` + - `hub output:\n${hub.output()}\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - timings.push({ label: `${label}: first reply`, ms: Date.now() - t0 }); - return; - } - } - - /** Waits for the next agent-authored message this proof has not seen. */ - async function awaitFreshReply(label: string): Promise { - const deadline = Date.now() + TURN_TIMEOUT_MS; - for (;;) { - await autoApproveAll(); - const fresh = (await listAgentMessages()).filter((m) => !seenIds.has(m.id)); - if (fresh.length > 0) { - for (const m of fresh) seenIds.add(m.id); - return fresh.map((m) => m.text).join(" "); - } - if (Date.now() > deadline) { - throw new Error( - `${label}: no reply within ${TURN_TIMEOUT_MS / 1000}s\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(2000); - } - } - - await hop("PROOF 3 — a real message gets a real model reply", () => - sendAndAwaitReply("In one short sentence, what can you help me with?", "proof 3"), - ); - - // ---- proof 4: kill the sidecar mid-turn, restart, keep talking ---- - await hop("PROOF 4 — kill the sidecar mid-turn", async () => { - const sent = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - { - parts: [ - { - kind: "text", - text: MID_TURN_PROMPT, - }, - ], - }, - admin.cookies, - ); - expectStatus("send the mid-turn message", sent, 201); - // The section deployment takes the same kill mid-occurrence, so the - // restart has to prove `onBodyFailure: "tolerate"` too: a section - // that retired on the dead body would never answer again. - const sectionSent = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/workflows/${sectionDeploymentId}/mail`, - { content: MID_TURN_PROMPT }, - admin.cookies, - ); - expectStatus("send the mid-turn section message", sectionSent, 202); - // Long enough that the turn is genuinely in flight — the child has - // the mail and inference is running — but well short of a reply. - await Bun.sleep(MID_TURN_KILL_DELAY_MS); - // "Mid-turn" is asserted, not assumed: the prompt above cannot be - // answered in the kill window on any model, so an answer already - // sitting in the room would mean the kill landed BETWEEN turns and - // the proof would be testing the easy case. - const answeredEarly = (await listAgentMessages()).filter((m) => !seenIds.has(m.id)); - if (answeredEarly.length > 0) { - throw new Error( - `the mid-turn kill was not mid-turn: the agent already answered ` + - `within ${String(MID_TURN_KILL_DELAY_MS)}ms — ` + - `${JSON.stringify(answeredEarly.map((m) => m.text))}`, - ); - } - await sidecar.stop(); - }); - - await timed("proof 4: sidecar restart + boot restore", () => - hop("PROOF 4 — the sidecar restarts and boot restore replays the pin", async () => { - sidecar = startSidecar({ - hubPort, - sidecarId, - token: sidecarToken, - dataDir: sidecarDataDir, - }); - track(sidecar); - const deadline = Date.now() + 120_000; - for (;;) { - if (sidecar.exited()) { - throw new Error(`the restarted sidecar exited; output:\n${sidecar.output()}`); - } - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - admin.cookies, - ); - // The room surviving is a hub-only read and says nothing about - // the execution plane. The run's own health does: `liveness` - // is "ok" exactly when the sidecar has re-announced the - // address, which is what boot restore replaying the pin - // produces. Sending before that is sending into a window the - // product itself answers with "send it again". - const health = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/workflows/runs/${agentRunId}/health`, - undefined, - admin.cookies, - ); - const liveness = (health.data as { liveness?: unknown }).liveness; - if (res.status === 200 && liveness === "ok") return; - if (Date.now() > deadline) { - throw new Error( - `the room or its run did not come back after the restart ` + - `(messages ${String(res.status)}, liveness ${JSON.stringify(liveness)})\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(1000); - } - }), - ); - - // Same rule for the section: the occurrence that died in the kill may - // already have committed its `RunStarted`, so it is not evidence that - // the section still answers. Only an occurrence started AFTER the - // restore counts. - for (const id of await listDeploymentRunIds(sectionDeploymentId)) { - if (isOccurrenceRunId(id)) seenOccurrences.add(id); - } - - // The section deployment rode the same kill. Boot restore replays its - // pin from the sidecar data dir exactly as it does the folded run's, - // and `onBodyFailure: "tolerate"` is what keeps the section subscribed - // when the killed occurrence died mid-body: a section that retired on - // that failure would never produce a second occurrence at all. - // - // Asserted BEFORE the step shape's half deliberately: the two shapes - // answer this question differently, and the section's answer must be - // on the record whatever the step shape does. - await timed("proof 4: section occurrence after the restart", () => - hop("PROOF 4 — the section survives the restart and runs its next occurrence", async () => { - const occurrence = await driveSectionOccurrence( - "Are you still there? One sentence.", - "proof 4 (section)", - ); - console.log(` TRANSCRIPT — post-restart section occurrence: ${occurrence}`); - }), - ); - - // Whatever the killed turn produced is not the proof that the agent - // still works — that is the NEXT message's job. But it must not have - // produced NOTHING: a turn that died with the sidecar has to reach - // the reader as a partial answer or as the product's own visible - // notice ("I didn't get that one — send it again"), never as a - // message that was accepted and then silently swallowed. - await hop("PROOF 4 — the turn the kill interrupted surfaces visibly", async () => { - const deadline = Date.now() + 120_000; - for (;;) { - const fresh = (await listAgentMessages()).filter((m) => !seenIds.has(m.id)); - if (fresh.length > 0) { - console.log( - ` TRANSCRIPT — the interrupted turn surfaced as: ` + - JSON.stringify(fresh.map((m) => m.text)), - ); - for (const m of fresh) seenIds.add(m.id); - return; - } - if (Date.now() > deadline) { - throw new Error( - "the turn the sidecar kill interrupted left NOTHING in the " + - "room: no partial answer and no undelivered notice, so the " + - "reader's message was accepted and silently dropped", - ); - } - await Bun.sleep(2000); - } - }); - await hop("PROOF 4 — the next message is answered after the restart", () => - sendAndAwaitReply("Are you still there? One sentence.", "proof 4", 2), - ); - - // The audit trail is the whole reason a relaunch mints a fresh run - // instead of reclaiming the dead one's address: the run that died - // mid-turn keeps its own durable log, under its own address, readable - // through the ordinary run routes — after the run that replaced it is - // already answering (the hop above). - await hop("PROOF 4 — the replaced run's log is still readable", async () => { - const events = await readRunEvents(); - if (events.length === 0) { - throw new Error( - `the replaced run ${agentRunId}'s durable event log is unreadable ` + - `after its replacement went live: the relaunch reclaimed the ` + - `audit trail it exists to preserve`, - ); - } - console.log( - ` TRANSCRIPT — replaced run ${agentRunId} still readable: ` + - `${String(events.length)} events, last ${events.at(-1)?.type ?? "?"}`, - ); - }); - - console.log("\n=== TIMINGS ==="); - for (const t of timings) { - console.log(` ${t.label}: ${(t.ms / 1000).toFixed(1)}s`); - } - console.log("\nAll four proofs passed."); -} - -await main(); -process.exit(0); diff --git a/scripts/e2e/cl-6329-turn-swap-proof.ts b/scripts/e2e/cl-6329-turn-swap-proof.ts deleted file mode 100644 index 660805ae7a..0000000000 --- a/scripts/e2e/cl-6329-turn-swap-proof.ts +++ /dev/null @@ -1,887 +0,0 @@ -// CL-6329's live proof, on one real stack: scratch database, real -// signup, real Ollama, nothing mocked. Every agent invited into a room -// now deploys as an `onTrigger` section, so this asserts the three -// things that swap has to make true: -// -// 1. Two agents reply in ONE room, and each reply carries its own -// occurrence's child run id (`turn__`) — distinct per agent, -// readable back through the room's own turns routes. -// 2. Three rapid messages serialize into ordered turns rather than -// racing: occurrences 0,1,2 in arrival order. -// 3. A turn killed mid-occurrence leaves BOTH the room and the section -// alive: `onBodyFailure: "tolerate"` keeps the section subscribed, -// the failed turn is visible rather than silent, and the next -// message is still answered. -// -// Shares the boot/seed/mint scaffold with `cl-6329-turn-swap-proof.ts`. -// -// Usage: -// E2E_PROVIDER=ollama OLLAMA_BASE_URL=http://localhost:11434 \ -// E2E_OLLAMA_MODEL=llama3.2:latest \ -// DATABASE_URL=postgres://localhost:5432/wb6329proof_e2e \ -// bun run scripts/e2e/cl-6329-turn-swap-proof.ts -import { expect } from "bun:test"; - -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join as pathJoin } from "node:path"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; -import { DEFAULT_WORKFLOWS, seedTenant } from "../../packages/onboarding/src/tenant-seed.ts"; -import { createHubAPI, type ApiCall } from "../../packages/hub-api-client/src/index.ts"; -import { - findPersonalTenant, - testAndPersistCredential, - ensureSeeded, -} from "../../packages/onboarding/src/complete-credential.ts"; -import { - OLLAMA_PLACEHOLDER_SECRET, - ollamaOpenAICompatBaseURL, -} from "../../packages/connections/src/credential-test.ts"; -import { - api, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - provisionSidecar, - startHub, - startSidecar, - type ApiResult, - type HubHandle, - type SpawnedApp, -} from "./harness.ts"; - -const configuredDatabaseUrl = e2eDatabaseUrl(); -if (configuredDatabaseUrl === undefined) { - throw new Error( - "cl-6329-turn-swap-proof: DATABASE_URL is not set. This suite proves a real " + - "boot and has nothing honest to assert without one.", - ); -} -const databaseUrl: string = configuredDatabaseUrl; - -const OLLAMA_BASE_URL = process.env["OLLAMA_BASE_URL"]; -if (process.env["E2E_PROVIDER"] !== "ollama" || OLLAMA_BASE_URL === undefined) { - throw new Error( - "cl-6329-turn-swap-proof: set E2E_PROVIDER=ollama and OLLAMA_BASE_URL. The " + - "proofs require a real completion model actually answering.", - ); -} -const ollamaBaseUrl = OLLAMA_BASE_URL; - -// Named explicitly rather than taken from `CATALOG_SEEDS.ollama`: the -// curated seed lists models a given instance may simply not have pulled, -// and a deploy pinned at a model the instance cannot serve fails as an -// inference error that reads nothing like the thing being proved. -const OLLAMA_MODEL = process.env["E2E_OLLAMA_MODEL"]; -if (OLLAMA_MODEL === undefined || OLLAMA_MODEL === "") { - throw new Error( - "cl-6329-turn-swap-proof: set E2E_OLLAMA_MODEL to a completion model the " + - "instance at OLLAMA_BASE_URL actually serves (see `ollama list`).", - ); -} -/** The model source every deploy in this proof pins, explicitly. */ -const proofModelSource = { - provider: "openai-compatible", - model: OLLAMA_MODEL, - baseURL: ollamaOpenAICompatBaseURL(ollamaBaseUrl), - apiKey: OLLAMA_PLACEHOLDER_SECRET, -} as const; - -const TURN_TIMEOUT_MS = 300_000; - -/** - * Proof 4's mid-turn workload. Deliberately far longer than any model - * can finish inside `MID_TURN_KILL_DELAY_MS`, so the kill lands while - * inference is genuinely running rather than in the quiet gap after a - * short turn already completed — the distinction between proving - * crash-recovery and proving a clean restart. - */ -const MID_TURN_PROMPT = - "Count slowly from one to four hundred, one number per line. " + - "Write every number out; do not stop early and do not summarise."; -const MID_TURN_KILL_DELAY_MS = 3000; - -const tracked: SpawnedApp[] = []; -const tempDir = (prefix: string) => mkdtemp(pathJoin(tmpdir(), prefix)); -const track = (app: SpawnedApp) => { - tracked.push(app); -}; -process.on("exit", () => { - for (const a of tracked) { - try { - void a.stop(); - } catch { - // Best-effort teardown: a child already gone is fine. - } - } -}); - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -function arrayField(data: unknown, field: string, what: string): unknown[] { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (Array.isArray(value)) return value; - } - throw new Error(`${what}: missing array field "${field}": ${JSON.stringify(data)}`); -} - -async function signUp( - baseUrl: string, - name: string, -): Promise<{ userId: string; email: string; cookies: string[] }> { - const email = `cl6329-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { - name, - email, - password, - }); - expectStatus(`sign-up for ${name}`, res, 200); - if (res.cookies.length === 0) { - throw new Error(`sign-up for ${name} returned no session cookie`); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - `sign-up user field for ${name}`, - ); - return { userId, email, cookies: res.cookies }; -} - -const timings: { label: string; ms: number }[] = []; -async function timed(label: string, run: () => Promise): Promise { - const t0 = Date.now(); - const value = await run(); - const ms = Date.now() - t0; - timings.push({ label, ms }); - // Printed as it lands, not only in the summary: a run that fails at a - // later proof still has to report what the earlier ones cost. - console.log(` TIMING — ${label}: ${(ms / 1000).toFixed(1)}s`); - return value; -} - -async function main(): Promise { - const url = databaseUrl; - - await hop("database setup", async () => { - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - }); - - const sidecarId = "cl6329-sidecar"; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, sidecarToken); - - const hub: HubHandle = await hop("hub boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("cl6329-hub-data-"), - }), - ); - track(hub); - const hubPort = Number(new URL(hub.baseUrl).port); - - // The sidecar's data dir is reused verbatim across the restart in - // proof 4: boot restore reads the deployments it left behind there. - const sidecarDataDir = await tempDir("cl6329-sidecar-data-"); - let sidecar: SpawnedApp = startSidecar({ - hubPort, - sidecarId, - token: sidecarToken, - dataDir: sidecarDataDir, - }); - track(sidecar); - - const hubApi: ApiCall = createHubAPI(hub.baseUrl); - const pushWorkflow = createGitWorkflowPusher(); - - const admin = await hop("alice genesis sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - expectStatus("alice sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("alice sign-up returned no session cookie"); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - "alice sign-up user field", - ); - const probe = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - res.cookies, - ); - expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); - const minted = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "Workbench" }, - res.cookies, - ); - expectStatus("alice genesis provision", minted, 200); - expect((minted.data as { kind: string }).kind).toBe("provisioned"); - return { cookies: res.cookies, userId }; - }); - - const user = await hop("sign up", async () => signUp(hub.baseUrl, "CL-6329 Proof")); - - const provisioned = await hop("a membership probe joins the genesis root", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - user.cookies, - ); - expectStatus("provision probe", res, 200); - const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("existing-member"); - return data; - }); - - // A joined member is read-only by design, so every owner-level leg - // below runs as alice, the genesis owner. - const tenant = await hop("joined root resolves", async () => { - const found = await findPersonalTenant(hubApi, admin.cookies, provisioned.tenantSlug); - if (found === undefined) { - throw new Error(`findPersonalTenant found nothing for slug ${provisioned.tenantSlug}`); - } - return found; - }); - - const connected = await hop("connect the local Ollama", async () => { - const result = await testAndPersistCredential({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - userId: admin.userId, - userEmail: "alice@example.com", - provider: "ollama", - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURLOverride: ollamaBaseUrl, - pushWorkflow, - log: () => undefined, - }); - if (result.kind !== "connected") { - throw new Error(`expected the key-path connect to succeed, got: ${JSON.stringify(result)}`); - } - return result; - }); - - // ---- proof 1: the seed goes fully green by source-ref ------------- - await hop("SETUP — the credential's own seed completes", async () => { - const deadline = Date.now() + 180_000; - for (;;) { - try { - await ensureSeeded({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - pushWorkflow, - log: () => undefined, - tenant: connected, - provider: "ollama", - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURLOverride: ollamaBaseUrl, - }); - return; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - }); - - await timed("setup: every default workflow deploys by source-ref", () => - hop("SETUP — every default workflow deploys by source-ref", async () => { - const deadline = Date.now() + 180_000; - for (;;) { - if (sidecar.exited()) { - throw new Error(`sidecar exited before the seed finished; output:\n${sidecar.output()}`); - } - try { - await seedTenant({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: tenant.tenantId, - principalId: tenant.principalId, - domain: tenant.tenantDomain, - }, - model: proofModelSource, - pushWorkflow, - log: () => undefined, - workflows: DEFAULT_WORKFLOWS, - confirmDeployments: false, - }); - return; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - }), - ); - - async function plantGrant(resource: string, action: string): Promise { - const granted = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/grants`, - { - principalId: tenant.principalId, - resource, - action, - effect: "allow", - origin: "system", - }, - admin.cookies, - ); - if (granted.status !== 201 && granted.status !== 409) { - throw new Error( - `could not plant the ${resource}/${action} grant: ` + - `${String(granted.status)} ${JSON.stringify(granted.data)}`, - ); - } - } - - // A live Ollama connect seeds one catalog offering per pulled model, - // embedding models included, with no capability metadata to tell them - // apart (CL-6351). Default-model resolution then breaks the tie - // alphabetically, so `all-minilm` wins the bench default and every - // chat turn dies before it reaches a model. This proof pins ONE model - // on purpose, so it narrows the bench's own catalog to that model - // through the catalog API rather than leaving the turn's model to a - // coin flip the proof is not about. - await hop("narrow the bench catalog to the pinned model", async () => { - await plantGrant("model-offering:*", "read"); - await plantGrant("model-offering:*", "manage"); - await plantGrant("model:*", "read"); - - const models = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/catalog/models?limit=200`, - undefined, - admin.cookies, - ); - expectStatus("list the bench catalog models", models, 200); - const modelRows = arrayField(models.data, "data", "catalog models") as { - id: string; - canonicalName: string; - }[]; - const pinned = modelRows.find((row) => row.canonicalName === proofModelSource.model); - if (pinned === undefined) { - throw new Error( - `the bench catalog carries no model named ${proofModelSource.model}; ` + - `it has ${JSON.stringify(modelRows.map((m) => m.canonicalName))}`, - ); - } - - const offerings = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/catalog/offerings?limit=200`, - undefined, - admin.cookies, - ); - expectStatus("list the bench model offerings", offerings, 200); - const offeringRows = arrayField(offerings.data, "data", "model offerings") as { - id: string; - modelId: string; - disabled: boolean; - }[]; - for (const offering of offeringRows) { - if (offering.modelId === pinned.id || offering.disabled) continue; - const patched = await api( - hub.baseUrl, - "PATCH", - `/api/tenants/${tenant.tenantId}/catalog/offerings/${offering.id}`, - { disabled: true }, - admin.cookies, - ); - expectStatus(`disable offering ${offering.id}`, patched, 200); - } - console.log( - ` TRANSCRIPT — bench catalog narrowed to ${proofModelSource.model} ` + - `(${String(offeringRows.length - 1)} other offerings disabled)`, - ); - }); - - const assistantDefinitionId = await hop( - "SETUP — 'assistant' is invitable tenant-wide", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, - undefined, - admin.cookies, - ); - if (res.status === 200) { - const items = arrayField(res.data, "items", "invitable") as { - id: string; - name: string; - }[]; - const assistant = items.find((item) => item.name === "assistant"); - if (assistant !== undefined) return assistant.id; - } - if (Date.now() > deadline) { - throw new Error(`"assistant" never became invitable: ${JSON.stringify(res.data)}`); - } - await Bun.sleep(1000); - } - }, - ); - - // ---- proof 2: mint walks the whole new deploy path ---------------- - const { chatId, agentAddress, agentRunId } = await timed( - "setup: mint → probe → closure materialization", - () => - hop("SETUP — POST /workbenches mints a chat with its agent joined", async () => { - const deadline = Date.now() + 120_000; - let res: ApiResult; - for (;;) { - if (sidecar.exited()) { - throw new Error(`sidecar exited before chat creation; output:\n${sidecar.output()}`); - } - res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches`, - { kind: "chat", definitionId: assistantDefinitionId }, - admin.cookies, - ); - if (res.status !== 500) break; - if (Date.now() > deadline) { - throw new Error( - `chat never became mintable: ${JSON.stringify(res.data)}\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(1000); - } - expectStatus("create chat", res, 201); - const id = stringField(res.data, "id", "create chat"); - const participants = arrayField(res.data, "participants", "create chat") as { - address: string; - handle: string; - }[]; - const agent = participants.find((p) => p.handle === "myra"); - if (agent === undefined) { - throw new Error(`chat has no "myra" participant: ${JSON.stringify(participants)}`); - } - const [runId] = agent.address.split("@"); - if (runId === undefined) { - throw new Error(`agent address is not a run address: ${agent.address}`); - } - return { chatId: id, agentAddress: agent.address, agentRunId: runId }; - }), - ); - - type Turn = { - id: string; - agentAddress: string; - childRunId: string; - occurrence: number; - status: string; - replyMessageId: string | null; - error: string | null; - }; - - /** - * The run ids present in a deployment's workflow-run event repo — the - * anchor plus every child run that has committed an event. A - * section-mode occurrence shows up here as `turn__` the moment its - * first event lands. - */ - async function listDeploymentRunIds(anchorRunId: string): Promise { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/workflows/${anchorRunId}/runs`, - undefined, - admin.cookies, - ); - if (res.status !== 200) return []; - const raw = res.data; - if ( - typeof raw !== "object" || - raw === null || - !Array.isArray((raw as Record)["runIds"]) - ) { - return []; - } - return (raw as { runIds: string[] }).runIds; - } - - /** The room's own turn projection — CL-6329's traceability surface. */ - async function listTurns(): Promise { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/turns`, - undefined, - admin.cookies, - ); - expectStatus("list the room's turns", res, 200); - return arrayField(res.data, "items", "list turns") as Turn[]; - } - - /** Every message in the room, whoever sent it, with its run id. */ - async function listRoomMessages(): Promise<{ id: string; address: string; text: string }[]> { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - admin.cookies, - ); - expectStatus("list chat messages", res, 200); - const items = arrayField(res.data, "items", "list chat messages") as { - id: string; - sender: { address: string }; - parts: { kind: string; text?: string }[]; - }[]; - return items.map((i) => ({ - id: i.id, - address: i.sender.address, - text: i.parts.map((p) => p.text ?? "").join(""), - })); - } - - async function sendRoomMessage(text: string): Promise { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - { parts: [{ kind: "text", text }] }, - admin.cookies, - ); - expectStatus(`send "${text.slice(0, 40)}"`, res, 201); - } - - const isOccurrenceRunId = (id: string): boolean => /^turn__\d+$/.test(id); - - /** A participant's `@handle` in this room, read from the room itself. */ - async function handleOf(address: string): Promise { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/agents`, - undefined, - admin.cookies, - ); - expectStatus("read the room's agents", res, 200); - const participants = arrayField(res.data, "items", "room participants") as { - address: string; - handle: string; - }[]; - const found = participants.find((p) => p.address === address); - if (found === undefined) { - throw new Error( - `the room has no participant at ${address}: ` + - JSON.stringify(participants.map((p) => p.address)), - ); - } - return found.handle; - } - - // ---- proof 1: two agents, one room, one occurrence each ----------- - const secondAgent = await timed("proof 1: invite a second agent", () => - hop("PROOF 1 — a second agent joins the same room", async () => { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/invite`, - { definitionId: assistantDefinitionId }, - admin.cookies, - ); - expectStatus("invite the second agent", res, 201); - const address = stringField(res.data, "address", "invite"); - const handle = await handleOf(address); - if (address === agentAddress) { - throw new Error( - "the invite returned the first agent's own address; the room has " + - "one participant, not two", - ); - } - console.log(` TRANSCRIPT — second agent @${handle} at ${address}`); - return { address, handle }; - }), - ); - - const firstHandle = await handleOf(agentAddress); - - await timed("proof 1: both agents answer one message", () => - hop("PROOF 1 — two agents reply in one room, each under its own turn", async () => { - const before = new Set((await listRoomMessages()).map((m) => m.id)); - await sendRoomMessage(`@${firstHandle} @${secondAgent.handle} say hi in one short sentence.`); - - const deadline = Date.now() + TURN_TIMEOUT_MS; - for (;;) { - const turns = await listTurns(); - const settled = turns.filter((t) => t.status === "completed" && t.replyMessageId !== null); - const addresses = new Set(settled.map((t) => t.agentAddress)); - if (addresses.size >= 2) { - const replies = (await listRoomMessages()).filter((m) => !before.has(m.id)); - for (const turn of settled) { - if (!isOccurrenceRunId(turn.childRunId)) { - throw new Error( - `turn ${turn.id} carries child run id ${turn.childRunId}, ` + - `which is not an occurrence id — the reply is not ` + - `traceable to a section occurrence`, - ); - } - const reply = replies.find((m) => m.id === turn.replyMessageId); - console.log( - ` TRANSCRIPT — ${turn.agentAddress} turn ${turn.childRunId}: ` + - `${reply?.text.slice(0, 120) ?? "(reply row not listed)"}`, - ); - } - // Distinct per agent is the point: two agents in one room - // must not share an occurrence id. - const byAgent = new Map(); - for (const turn of settled) byAgent.set(turn.agentAddress, turn.id); - expect(byAgent.size).toBeGreaterThanOrEqual(2); - return settled; - } - if (Date.now() > deadline) { - throw new Error( - `only ${String(addresses.size)} of 2 agents produced a settled ` + - `turn: ${JSON.stringify(turns)}\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(2000); - } - }), - ); - - // The occurrence ids the projection allocated must be the ones the - // runtime actually ran — read straight off the deployment's own - // workflow-run repo, not off our own rows. - await hop("PROOF 1 — the projection's occurrence ids are the runtime's own", async () => { - const runIds = await listDeploymentRunIds(agentRunId); - const occurrences = runIds.filter(isOccurrenceRunId); - console.log(` TRANSCRIPT — run ${agentRunId} child runs: ${JSON.stringify(runIds)}`); - if (occurrences.length === 0) { - throw new Error( - `the room agent's deployment started NO per-occurrence child run ` + - `(${JSON.stringify(runIds)}); it is still deploying as a folded ` + - `step rather than an onTrigger section`, - ); - } - const projected = new Set( - (await listTurns()).filter((t) => t.agentAddress === agentAddress).map((t) => t.childRunId), - ); - for (const id of occurrences) { - if (!projected.has(id)) { - throw new Error( - `the runtime ran occurrence ${id}, which the turn projection ` + - `never allocated: ${JSON.stringify([...projected])}`, - ); - } - } - }); - - // ---- proof 2: three rapid messages serialize into ordered turns --- - await timed("proof 2: three rapid messages serialize", () => - hop("PROOF 2 — a burst of three messages becomes ordered turns", async () => { - const before = (await listTurns()).filter((t) => t.agentAddress === agentAddress).length; - - // Sent back to back with no wait between: the queue, not the - // sender, is what has to order them. - await sendRoomMessage(`@${firstHandle} one`); - await sendRoomMessage(`@${firstHandle} two`); - await sendRoomMessage(`@${firstHandle} three`); - - const deadline = Date.now() + TURN_TIMEOUT_MS; - for (;;) { - const mine = (await listTurns()) - .filter((t) => t.agentAddress === agentAddress) - .sort((a, b) => a.occurrence - b.occurrence); - if (mine.length >= before + 3) { - const occurrences = mine.map((t) => t.occurrence); - console.log(` TRANSCRIPT — occurrences after the burst: ` + JSON.stringify(occurrences)); - // Contiguous and ascending from zero: no gap, no duplicate, - // no two turns sharing a child run id. - expect(occurrences).toEqual(occurrences.map((_, i) => i)); - expect(new Set(mine.map((t) => t.childRunId)).size).toBe(mine.length); - return; - } - if (Date.now() > deadline) { - throw new Error( - `the burst produced ${String(mine.length)} turns, expected more ` + - `than ${String(before)}: ${JSON.stringify(mine)}`, - ); - } - await Bun.sleep(2000); - } - }), - ); - - // ---- proof 3: a failed turn kills neither the room nor the section - - const seenIds = new Set((await listRoomMessages()).map((m) => m.id)); - - await timed("proof 3: kill the sidecar mid-occurrence", () => - hop("PROOF 3 — the sidecar dies while an occurrence is running", async () => { - await sendRoomMessage(`@${firstHandle} ${MID_TURN_PROMPT}`); - await Bun.sleep(MID_TURN_KILL_DELAY_MS); - await sidecar.stop(); - console.log(" TRANSCRIPT — sidecar killed mid-occurrence"); - }), - ); - - await timed("proof 3: restart and restore", () => - hop("PROOF 3 — the sidecar restarts and restores its deployments", async () => { - sidecar = startSidecar({ - hubPort, - sidecarId, - token: sidecarToken, - dataDir: sidecarDataDir, - }); - track(sidecar); - const deadline = Date.now() + 120_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/turns`, - undefined, - admin.cookies, - ); - if (res.status === 200) return; - if (Date.now() > deadline) { - throw new Error( - `the room never came back after the restart: ${JSON.stringify(res.data)}`, - ); - } - await Bun.sleep(2000); - } - }), - ); - - await hop("PROOF 3 — the interrupted turn surfaces visibly rather than silently", async () => { - const deadline = Date.now() + 180_000; - for (;;) { - const fresh = (await listRoomMessages()).filter( - (m) => !seenIds.has(m.id) && m.address !== `${tenant.principalId}`, - ); - const agentFresh = fresh.filter((m) => m.text.trim().length > 0); - if (agentFresh.length > 0) { - console.log( - ` TRANSCRIPT — the interrupted turn surfaced as: ` + - JSON.stringify(agentFresh.map((m) => m.text.slice(0, 160))), - ); - for (const m of fresh) seenIds.add(m.id); - return; - } - if (Date.now() > deadline) { - throw new Error( - "the turn the kill interrupted left NOTHING in the room: no " + - "partial answer and no notice, so the reader's message was " + - "accepted and silently dropped", - ); - } - await Bun.sleep(3000); - } - }); - - await timed("proof 3: the room still answers after the failed turn", () => - hop("PROOF 3 — the section is still subscribed and answers the next message", async () => { - const before = new Set((await listRoomMessages()).map((m) => m.id)); - const beforeTurns = (await listTurns()).filter((t) => t.agentAddress === agentAddress).length; - // Resent on a loop rather than once: the room's own rows come - // back the moment the hub is up, but the section's run only - // becomes routable again once the restarted sidecar has - // re-registered it, and a send that lands in that window fails - // as "no sidecar available" — a failed turn, correctly recorded, - // that says nothing about whether the section survived. - let nextAsk = 0; - const deadline = Date.now() + TURN_TIMEOUT_MS; - for (;;) { - if (Date.now() >= nextAsk) { - await sendRoomMessage(`@${firstHandle} are you still there? One word.`); - nextAsk = Date.now() + 20_000; - } - const mine = (await listTurns()).filter((t) => t.agentAddress === agentAddress); - const settled = mine.find( - (t) => - t.status === "completed" && t.replyMessageId !== null && !before.has(t.replyMessageId), - ); - if (settled !== undefined) { - const reply = (await listRoomMessages()).find((m) => m.id === settled.replyMessageId); - console.log( - ` TRANSCRIPT — post-failure occurrence ${settled.childRunId}: ` + - `${reply?.text.slice(0, 160) ?? "(row not listed)"}`, - ); - expect(mine.length).toBeGreaterThan(beforeTurns); - return; - } - if (Date.now() > deadline) { - throw new Error( - `the room never answered after the failed turn — the section ` + - `did not survive it: ${JSON.stringify(mine)}\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(2000); - } - }), - ); - - await hop("PROOF 3 — the failed turn is on the record", async () => { - const turns = await listTurns(); - console.log( - ` TRANSCRIPT — the room's turns: ` + - JSON.stringify( - turns.map((t) => ({ - agent: t.agentAddress, - run: t.childRunId, - status: t.status, - })), - ), - ); - const unsettled = turns.filter((t) => t.status !== "completed"); - if (unsettled.length === 0) { - console.log( - " TRANSCRIPT — no turn is recorded failed; the interrupted " + - "occurrence settled some other way (see the notice above)", - ); - } - }); - - console.log("\n=== TIMINGS ==="); - for (const t of timings) { - console.log(` ${t.label}: ${(t.ms / 1000).toFixed(1)}s`); - } - console.log("\nAll three CL-6329 proofs passed."); -} - -await main(); -process.exit(0); diff --git a/scripts/e2e/cl-6451-single-run-proof.ts b/scripts/e2e/cl-6451-single-run-proof.ts deleted file mode 100644 index 6c40bbdbb5..0000000000 --- a/scripts/e2e/cl-6451-single-run-proof.ts +++ /dev/null @@ -1,549 +0,0 @@ -// CL-6451's live proof, on one real stack: scratch database, real -// signup, real Ollama, nothing mocked. The bug it proves fixed: a -// participant's mention handle derives from its definition's display -// name ("myra"), while the workflow command registrar names commands -// after the definition's wire name ("assistant") — so `@assistant` -// used to fall past the known-handle guard into the command path and -// mint a SECOND run for an agent already in the room. Messages then -// fanned out to both runs, the original never parked, and the dispatch -// died at the supervisor's terminal-or-park backstop with the turn row -// stuck `running`. -// -// 1. Invite `assistant` into a workbench (run A, handle "myra"), -// then send `@assistant ...` — the message posts as an ordinary -// message (no command result), the room still has exactly ONE -// agent participant, and the reply arrives as occurrence -// `turn__0` of run A. -// 2. (CL-6453) A second `@assistant` message answers from the SAME -// run as `turn__1` and recalls a fact stated in turn 1 — the -// bootstrap exchange lives in the same stepId-keyed durable -// history every later turn restores. -// -// Shares the boot/seed/mint scaffold with `cl-6329-turn-swap-proof.ts`. -// -// Usage: -// E2E_PROVIDER=ollama OLLAMA_BASE_URL=http://localhost:11434 \ -// E2E_OLLAMA_MODEL=llama3.1:8b \ -// DATABASE_URL=postgres://localhost:5432/wb6451proof_e2e \ -// bun run scripts/e2e/cl-6451-single-run-proof.ts -import { expect } from "bun:test"; - -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join as pathJoin } from "node:path"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; -import { DEFAULT_WORKFLOWS, seedTenant } from "../../packages/onboarding/src/tenant-seed.ts"; -import { createHubAPI, type ApiCall } from "../../packages/hub-api-client/src/index.ts"; -import { - findPersonalTenant, - testAndPersistCredential, - ensureSeeded, -} from "../../packages/onboarding/src/complete-credential.ts"; -import { - OLLAMA_PLACEHOLDER_SECRET, - ollamaOpenAICompatBaseURL, -} from "../../packages/connections/src/credential-test.ts"; -import { - api, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - provisionSidecar, - startHub, - startSidecar, - type HubHandle, - type SpawnedApp, -} from "./harness.ts"; - -const configuredDatabaseUrl = e2eDatabaseUrl(); -if (configuredDatabaseUrl === undefined) { - throw new Error( - "cl-6451-single-run-proof: DATABASE_URL is not set. This suite proves a " + - "real boot and has nothing honest to assert without one.", - ); -} -const databaseUrl: string = configuredDatabaseUrl; - -const OLLAMA_BASE_URL = process.env["OLLAMA_BASE_URL"]; -if (process.env["E2E_PROVIDER"] !== "ollama" || OLLAMA_BASE_URL === undefined) { - throw new Error( - "cl-6451-single-run-proof: set E2E_PROVIDER=ollama and OLLAMA_BASE_URL. " + - "The proof requires a real completion model actually answering.", - ); -} -const ollamaBaseUrl = OLLAMA_BASE_URL; - -const OLLAMA_MODEL = process.env["E2E_OLLAMA_MODEL"]; -if (OLLAMA_MODEL === undefined || OLLAMA_MODEL === "") { - throw new Error( - "cl-6451-single-run-proof: set E2E_OLLAMA_MODEL to a completion model " + - "the instance at OLLAMA_BASE_URL actually serves (see `ollama list`).", - ); -} -const proofModelSource = { - provider: "openai-compatible", - model: OLLAMA_MODEL, - baseURL: ollamaOpenAICompatBaseURL(ollamaBaseUrl), - apiKey: OLLAMA_PLACEHOLDER_SECRET, -} as const; - -const TURN_TIMEOUT_MS = 300_000; - -const tracked: SpawnedApp[] = []; -const tempDir = (prefix: string) => mkdtemp(pathJoin(tmpdir(), prefix)); -const track = (app: SpawnedApp) => { - tracked.push(app); -}; -process.on("exit", () => { - for (const a of tracked) { - try { - void a.stop(); - } catch { - // Best-effort teardown: a child already gone is fine. - } - } -}); - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -function arrayField(data: unknown, field: string, what: string): unknown[] { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (Array.isArray(value)) return value; - } - throw new Error(`${what}: missing array field "${field}": ${JSON.stringify(data)}`); -} - -async function signUp( - baseUrl: string, - name: string, -): Promise<{ userId: string; email: string; cookies: string[] }> { - const email = `cl6451-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { - name, - email, - password, - }); - expectStatus(`sign-up for ${name}`, res, 200); - if (res.cookies.length === 0) { - throw new Error(`sign-up for ${name} returned no session cookie`); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - `sign-up user field for ${name}`, - ); - return { userId, email, cookies: res.cookies }; -} - -async function main(): Promise { - const url = databaseUrl; - - await hop("database setup", async () => { - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - }); - - const sidecarId = "cl6451-sidecar"; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, sidecarToken); - - const hub: HubHandle = await hop("hub boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("cl6451-hub-data-"), - }), - ); - track(hub); - const hubPort = Number(new URL(hub.baseUrl).port); - - const sidecar: SpawnedApp = startSidecar({ - hubPort, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("cl6451-sidecar-data-"), - }); - track(sidecar); - - const hubApi: ApiCall = createHubAPI(hub.baseUrl); - const pushWorkflow = createGitWorkflowPusher(); - - const admin = await hop("alice genesis sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - expectStatus("alice sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("alice sign-up returned no session cookie"); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - "alice sign-up user field", - ); - const probe = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - res.cookies, - ); - expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); - const minted = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "Workbench" }, - res.cookies, - ); - expectStatus("alice genesis provision", minted, 200); - expect((minted.data as { kind: string }).kind).toBe("provisioned"); - return { cookies: res.cookies, userId }; - }); - - const user = await hop("sign up", async () => signUp(hub.baseUrl, "CL-6451 Proof")); - - const provisioned = await hop("a membership probe joins the genesis root", async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - user.cookies, - ); - expectStatus("provision probe", res, 200); - const data = res.data as { kind: string; tenantSlug: string }; - expect(data.kind).toBe("existing-member"); - return data; - }); - - // A joined member is read-only by design, so every owner-level leg - // below runs as alice, the genesis owner. - const tenant = await hop("joined root resolves", async () => { - const found = await findPersonalTenant(hubApi, admin.cookies, provisioned.tenantSlug); - if (found === undefined) { - throw new Error(`findPersonalTenant found nothing for slug ${provisioned.tenantSlug}`); - } - return found; - }); - - const connected = await hop("connect Ollama", async () => { - const result = await testAndPersistCredential({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - userId: admin.userId, - userEmail: "alice@example.com", - provider: "ollama", - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURLOverride: ollamaBaseUrl, - pushWorkflow, - log: () => undefined, - }); - if (result.kind !== "connected") { - throw new Error(`expected the key-path connect to succeed, got: ${JSON.stringify(result)}`); - } - return result; - }); - - await hop("SETUP — the credential's own seed completes", async () => { - const deadline = Date.now() + 180_000; - for (;;) { - try { - await ensureSeeded({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - pushWorkflow, - log: () => undefined, - tenant: connected, - provider: "ollama", - apiKey: OLLAMA_PLACEHOLDER_SECRET, - baseURLOverride: ollamaBaseUrl, - }); - return; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - }); - - await hop("SETUP — every default workflow deploys by source-ref", async () => { - const deadline = Date.now() + 180_000; - for (;;) { - if (sidecar.exited()) { - throw new Error(`sidecar exited before the seed finished; output:\n${sidecar.output()}`); - } - try { - await seedTenant({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: tenant.tenantId, - principalId: tenant.principalId, - domain: tenant.tenantDomain, - }, - model: proofModelSource, - pushWorkflow, - log: () => undefined, - workflows: DEFAULT_WORKFLOWS, - confirmDeployments: false, - }); - return; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - }); - - // No catalog narrowing here, unlike the CL-6329 proof: the seeded - // `assistant` definition pins its own model (the connect flow's - // choice), and disabling the other offerings can disable exactly the - // model that pin names — this proof is about run identity, not model - // selection, so every seeded offering stays launchable. - - const assistant = await hop("SETUP — 'assistant' is invitable tenant-wide", async () => { - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, - undefined, - admin.cookies, - ); - if (res.status === 200) { - const items = arrayField(res.data, "items", "invitable") as { - id: string; - name: string; - }[]; - const found = items.find((item) => item.name === "assistant"); - if (found !== undefined) return found; - } - if (Date.now() > deadline) { - throw new Error(`"assistant" never became invitable: ${JSON.stringify(res.data)}`); - } - await Bun.sleep(1000); - } - }); - - // A WORKBENCH, not a chat: the live bug fired in a room whose agent - // arrived through the invite affordance and was then @named by its - // definition's wire name. - const workbenchId = await hop("SETUP — create a workbench", async () => { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches`, - { kind: "workbench", name: "CL-6451 proof room" }, - admin.cookies, - ); - expectStatus("create workbench", res, 201); - return stringField(res.data, "id", "create workbench"); - }); - - async function listAgents(): Promise<{ address: string; handle: string }[]> { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/agents`, - undefined, - admin.cookies, - ); - expectStatus("read the room's agents", res, 200); - return arrayField(res.data, "items", "room participants") as { - address: string; - handle: string; - }[]; - } - - const agentAddress = await hop("SETUP — invite `assistant` (run A)", async () => { - const deadline = Date.now() + 120_000; - for (;;) { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/invite`, - { definitionId: assistant.id }, - admin.cookies, - ); - if (res.status === 201) { - return stringField(res.data, "address", "invite"); - } - if (Date.now() > deadline) { - throw new Error(`invite never landed: ${JSON.stringify(res.data)}`); - } - await Bun.sleep(1000); - } - }); - const [agentRunId] = agentAddress.split("@"); - if (agentRunId === undefined) { - throw new Error(`agent address is not a run address: ${agentAddress}`); - } - - // The precondition that made the bug reachable: the participant's - // handle is NOT the definition's wire name, so the known-handle guard - // alone cannot recognize `@assistant` as this participant. - const handle = (await listAgents()).find((p) => p.address === agentAddress)?.handle; - if (handle === undefined) { - throw new Error(`the invited agent is not on the room's participant list`); - } - if (handle === assistant.name) { - throw new Error( - `precondition broken: the handle (${handle}) equals the definition's ` + - `wire name — this stack cannot reproduce the guard miss`, - ); - } - console.log( - ` TRANSCRIPT — invited ${agentAddress} as @${handle}; ` + - `the command name is "${assistant.name}"`, - ); - - type Turn = { - id: string; - agentAddress: string; - childRunId: string; - status: string; - replyMessageId: string | null; - error: string | null; - }; - - async function listTurns(): Promise { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/turns`, - undefined, - admin.cookies, - ); - expectStatus("list the room's turns", res, 200); - return arrayField(res.data, "items", "list turns") as Turn[]; - } - - async function listRoomMessages(): Promise<{ id: string; address: string; text: string }[]> { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/messages`, - undefined, - admin.cookies, - ); - expectStatus("list room messages", res, 200); - const items = arrayField(res.data, "items", "list room messages") as { - id: string; - sender: { address: string }; - parts: { kind: string; text?: string }[]; - }[]; - return items.map((i) => ({ - id: i.id, - address: i.sender.address, - text: i.parts.map((p) => p.text ?? "").join(""), - })); - } - - async function sendAndAwaitTurn( - text: string, - expectedChildRunId: string, - ): Promise<{ replyText: string }> { - const res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${workbenchId}/messages`, - { parts: [{ kind: "text", text }] }, - admin.cookies, - ); - expectStatus(`send "${text.slice(0, 40)}"`, res, 201); - // The heart of CL-6451: the `@assistant` message must NOT have been - // intercepted as a workflow command. - if (typeof res.data === "object" && res.data !== null && "command" in res.data) { - throw new Error( - `the @${assistant.name} message was dispatched as a command — a ` + - `second run was started: ${JSON.stringify(res.data)}`, - ); - } - - const deadline = Date.now() + TURN_TIMEOUT_MS; - for (;;) { - const turns = await listTurns(); - const settled = turns.find( - (t) => - t.childRunId === expectedChildRunId && - t.status === "completed" && - t.replyMessageId !== null, - ); - if (settled !== undefined) { - expect(settled.agentAddress).toBe(agentAddress); - const reply = (await listRoomMessages()).find((m) => m.id === settled.replyMessageId); - const replyText = reply?.text ?? ""; - console.log(` TRANSCRIPT — ${settled.childRunId}: ${replyText.slice(0, 160)}`); - return { replyText }; - } - const failed = turns.find((t) => t.status === "failed"); - if (failed !== undefined) { - throw new Error( - `a turn failed instead of completing: ${JSON.stringify(failed)}\n` + - `sidecar output:\n${sidecar.output()}`, - ); - } - if (Date.now() > deadline) { - throw new Error( - `no completed ${expectedChildRunId} turn within the budget: ` + - `${JSON.stringify(turns)}\nsidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(2000); - } - } - - await hop("PROOF 1 — @assistant routes into run A, never a second run", async () => { - await sendAndAwaitTurn( - `@${assistant.name} My favorite color is teal. ` + `Acknowledge in one short sentence.`, - "turn__0", - ); - const agents = await listAgents(); - if (agents.length !== 1) { - throw new Error(`the room grew a second agent participant: ` + JSON.stringify(agents)); - } - expect(agents[0]?.address).toBe(agentAddress); - }); - - await hop("PROOF 2 (CL-6453) — turn__1 rides the same run and remembers turn__0", async () => { - const { replyText } = await sendAndAwaitTurn( - `@${assistant.name} What is my favorite color? ` + `Answer with just the color name.`, - "turn__1", - ); - if (!/teal/i.test(replyText)) { - throw new Error( - `turn__1's reply does not recall turn__0's fact ("teal"): ` + - `"${replyText}" — the bootstrap exchange is missing from the ` + - `durable history`, - ); - } - const agents = await listAgents(); - expect(agents).toHaveLength(1); - }); - - console.log("\nBoth CL-6451/CL-6453 proofs passed."); -} - -await main(); -process.exit(0); diff --git a/scripts/e2e/greeting-delivery.test.ts b/scripts/e2e/greeting-delivery.test.ts deleted file mode 100644 index 949b702f49..0000000000 --- a/scripts/e2e/greeting-delivery.test.ts +++ /dev/null @@ -1,554 +0,0 @@ -// CL-6137: proves the unprompted greeting (CL-6126) actually lands in -// a chat's timeline on a real mint — not merely that the mint request -// itself succeeds. `postCannedGreeting` fires fire-and-forget right -// after `launchAndJoinAgent` in `POST /workbenches` -// (`packages/chat/src/routes.ts`), posting a canned opener onto the -// chat's timeline under the agent's own run with no inference turn; -// this suite is the only coverage that proves that post actually -// turns into an agent-authored workbench message on real machinery, -// not merely that the route returns 201. -// -// Mirrors `local-rip.test.ts`'s phase A (alice signs up first as -// genesis owner; a tester then joins as a member; the owner connects -// a credential through the key path) -// rather than `chat.test.ts`'s zero-credential `seedCatalog` setup: -// the greeting mail rides the same host-session delivery path as every -// real reply, so it landing with zero user messages sent — on a mint -// wired to a genuine (stub) credential — proves the whole delivery -// chain without a paid key. - -import { describe, expect, test } from "bun:test"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; -import { DEFAULT_WORKFLOWS, seedTenant } from "../../packages/onboarding/src/tenant-seed.ts"; -import { createHubAPI, type ApiCall } from "../../packages/hub-api-client/src/index.ts"; -import { - findPersonalTenant, - testAndPersistCredential, - ensureSeeded, - modelSourceFor, -} from "../../packages/onboarding/src/complete-credential.ts"; -import { publishCorbitsToolsRegistry } from "../../packages/tool-registry-publish/src/publish.ts"; -import { OLLAMA_PLACEHOLDER_SECRET } from "../../packages/connections/src/credential-test.ts"; -import { - api, - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - startHub, - type ApiResult, - type HubHandle, -} from "./harness.ts"; - -const { tempDir, track } = createCleanupHarness(); - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "greeting-delivery: DATABASE_URL is not set; suite skipped. Set " + - "DATABASE_URL (see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` " + - "so this skip can never pass silently there.", - ); -} - -// Never sent anywhere for real: onboarding never probes it (CL-6123), -// the deployments it seeds carry it as a stored, never-triggered -// source, and the canned opening greeting never dials a model — only -// the Ollama rapid-turn leg ever runs real inference. -const STUB_API_KEY = "e2e-greeting-delivery-stub-key-not-real"; - -// E2E_PROVIDER=ollama + OLLAMA_BASE_URL turns this suite into a live -// acceptance gate the same way `walkthrough.ts`'s E2E_PROVIDER_API_KEY -// does for Anthropic: the rapid-turn leg below then exercises real -// inference. The opening greeting itself is canned either way — it -// never dials a model. -const OLLAMA_BASE_URL = process.env["OLLAMA_BASE_URL"]; -const USE_OLLAMA = process.env["E2E_PROVIDER"] === "ollama" && OLLAMA_BASE_URL !== undefined; -const CONNECT_PROVIDER = USE_OLLAMA ? ("ollama" as const) : ("anthropic" as const); -const CONNECT_API_KEY = USE_OLLAMA ? OLLAMA_PLACEHOLDER_SECRET : STUB_API_KEY; - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -function arrayField(data: unknown, field: string, what: string): unknown[] { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (Array.isArray(value)) return value; - } - throw new Error(`${what}: missing array field "${field}": ${JSON.stringify(data)}`); -} - -async function signUp( - baseUrl: string, - name: string, -): Promise<{ userId: string; email: string; cookies: string[] }> { - const email = `greeting-delivery-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { - name, - email, - password, - }); - expectStatus(`sign-up for ${name}`, res, 200); - if (res.cookies.length === 0) { - throw new Error(`sign-up for ${name} returned no session cookie`); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - `sign-up user field for ${name}`, - ); - return { userId, email, cookies: res.cookies }; -} - -describe.skipIf(databaseUrl === undefined)( - "greeting delivery: a real mint's agent speaks first with no human message", - () => { - test("POST /workbenches kind=chat delivers an agent-authored message with zero user sends", async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await hop("database setup", async () => { - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - }); - - const hub: HubHandle = await hop("hub boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("e2e-greeting-delivery-hub-data-"), - }), - ); - track(hub); - - const hubApi: ApiCall = createHubAPI(hub.baseUrl); - - const admin = await hop("alice genesis sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - expectStatus("alice sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("alice sign-up returned no session cookie"); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - "alice sign-up user field", - ); - // Stock cutover: composition mounts no provisioning hook — - // signup mints nothing — so the genesis root comes from an - // ordinary `POST /api/tenants` with a caller-chosen slug, and - // its creator is the native owner (same shape `local-rip` - // proves). - const probe = await api(hub.baseUrl, "GET", "/api/me/principals", undefined, res.cookies); - expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { data: unknown[] }).data).toEqual([]); - const minted = await api( - hub.baseUrl, - "POST", - "/api/tenants", - { slug: "workbench", name: "Workbench" }, - res.cookies, - ); - expectStatus("alice genesis tenant create", minted, 201); - const mintedBody = minted.data as { id?: string }; - const tenantId = mintedBody.id; - if (tenantId === undefined) { - throw new Error(`create tenant answered no id: ${JSON.stringify(minted.data)}`); - } - return { cookies: res.cookies, userId, tenantId }; - }); - - const user = await hop("sign-up", () => signUp(hub.baseUrl, "Greeting Delivery Tester")); - - // Stock composition auto-joins nobody: a second signup only - // registers an account. Alice brings the tester onto the root - // through the native invite route (member role, so read-only by - // design) and activates the membership. Every owner-level leg - // below — seeding, chat mint, turns — still runs as alice. - const provisioned = await hop( - "the owner invites the tester onto the root bench as a member", - async () => { - const roles = await api( - hub.baseUrl, - "GET", - `/api/tenants/${admin.tenantId}/roles`, - undefined, - admin.cookies, - ); - expectStatus("list root roles", roles, 200); - const memberRole = (roles.data as { data: { id: string; name: string }[] }).data.find( - (role) => role.name === "member", - ); - if (memberRole === undefined) { - throw new Error(`no member role on the root tenant: ${JSON.stringify(roles.data)}`); - } - const invited = await api( - hub.baseUrl, - "POST", - `/api/tenants/${admin.tenantId}/members/invite`, - { email: user.email, roleId: memberRole.id }, - admin.cookies, - ); - expectStatus("invite tester", invited, 201); - const principalId = stringField(invited.data, "id", "invite result"); - const activated = await api( - hub.baseUrl, - "PATCH", - `/api/tenants/${admin.tenantId}/principals/${principalId}`, - { status: "active" }, - admin.cookies, - ); - expectStatus("activate tester", activated, 200); - return { tenantSlug: "workbench" }; - }, - ); - - // A joined member is read-only by design, so every owner-level - // leg below — seeding, chat mint, turns — runs as alice, the - // genesis owner. - const tenant = await hop("the joined root resolves through findPersonalTenant", async () => { - const found = await findPersonalTenant(hubApi, admin.cookies, provisioned.tenantSlug); - if (found === undefined) { - throw new Error(`findPersonalTenant found nothing for slug ${provisioned.tenantSlug}`); - } - return found; - }); - - const pushWorkflow = createGitWorkflowPusher(); - - const connected = await hop( - "connecting a real inference credential via the key path (no provider probe — CL-6123)", - async () => { - const testArgs = { - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - userId: admin.userId, - userEmail: "alice@example.com", - provider: CONNECT_PROVIDER, - apiKey: CONNECT_API_KEY, - pushWorkflow, - log: () => undefined, - }; - const result = await testAndPersistCredential( - USE_OLLAMA && OLLAMA_BASE_URL !== undefined - ? { ...testArgs, baseURLOverride: OLLAMA_BASE_URL } - : testArgs, - ); - if (result.kind !== "connected") { - throw new Error( - `expected the key-path connect to succeed, got: ${JSON.stringify(result)}`, - ); - } - return result; - }, - ); - - await hop( - "the real, unmodified connect flow fully seeds every default workflow, including 'assistant'", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before ensureSeeded could run; output:\n${hub.output()}`); - } - try { - const seedArgs = { - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - pushWorkflow, - log: () => undefined, - tenant: connected, - provider: CONNECT_PROVIDER, - apiKey: CONNECT_API_KEY, - }; - await ensureSeeded( - USE_OLLAMA && OLLAMA_BASE_URL !== undefined - ? { ...seedArgs, baseURLOverride: OLLAMA_BASE_URL } - : seedArgs, - ); - break; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(200); - } - } - }, - ); - - async function deploySeededWorkflows(): Promise { - const deadline = Date.now() + 60_000; - for (;;) { - if (hub.exited()) { - throw new Error( - `hub exited before default workflows could deploy; output:\n${hub.output()}`, - ); - } - try { - // CL-7071 cutover: the chat mint below launches an agent - // whose sidecar initialization resolves its `@corbits` - // scope pins against the tenant's own `corbits-tools` - // package registry — without it the allocation fails with - // `sidecar_initialization_failed` and the greeting never - // rides a live instance. Install the registry the same way - // the connect flow's setup step does (see `local-rip`'s - // publish-tools hop). - const published = await publishCorbitsToolsRegistry({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenantId: tenant.tenantId, - log: () => undefined, - }); - if (!published.success) { - throw new Error( - `corbits-tools registry publish failed: ${JSON.stringify(published.summaries)}`, - ); - } - await seedTenant({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: tenant.tenantId, - principalId: tenant.principalId, - domain: tenant.tenantDomain, - }, - model: await modelSourceFor(hubApi, admin.cookies, tenant.tenantId, CONNECT_PROVIDER), - pushWorkflow, - log: () => undefined, - workflows: DEFAULT_WORKFLOWS, - confirmDeployments: false, - }); - return; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(200); - } - } - } - - await hop("every default workflow deploys and goes live", deploySeededWorkflows); - - const assistantDefinitionId = await hop( - "the 'assistant' default workflow is invitable tenant-wide", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, - undefined, - admin.cookies, - ); - if (res.status === 200) { - const items = arrayField(res.data, "items", "list invitable definitions") as { - id: string; - name: string; - }[]; - const assistant = items.find((item) => item.name === "assistant"); - if (assistant !== undefined) return assistant.id; - } - if (Date.now() > deadline) { - throw new Error( - `"assistant" never appeared as invitable: ${JSON.stringify(res.data)}`, - ); - } - await Bun.sleep(200); - } - }, - ); - - const { chatId, agentAddress } = await hop( - "POST /workbenches kind=chat definitionId=assistant mints a chat with its one agent already joined", - async () => { - const deadline = Date.now() + 60_000; - let res: ApiResult; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before chat creation; output:\n${hub.output()}`); - } - res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches`, - { kind: "chat", definitionId: assistantDefinitionId }, - admin.cookies, - ); - if (res.status !== 500) break; - if (Date.now() > deadline) { - throw new Error( - `chat never became mintable (hub kept answering 500): ` + - `${JSON.stringify(res.data)}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(200); - } - expectStatus("create chat", res, 201); - const id = stringField(res.data, "id", "create chat"); - const participants = arrayField(res.data, "participants", "create chat") as { - address: string; - handle: string; - }[]; - const agent = participants.find((p) => p.handle === "myra"); - if (agent === undefined) { - throw new Error( - `chat has no "myra" agent participant: ${JSON.stringify(participants)}`, - ); - } - return { chatId: id, agentAddress: agent.address }; - }, - ); - - // The proof: poll the freshly minted chat's own timeline for an - // agent-authored message, sending no user message at any point. - // The greeting is canned (`packages/chat`'s `GREETING_VARIATIONS`) - // and posts with no inference turn, so in both provider modes the - // first agent-authored message is deterministic prose naming the - // agent and asking a question — never a credential error. - await hop( - "an agent-authored message lands in the chat with no user message ever sent", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - admin.cookies, - ); - expectStatus("list chat messages", res, 200); - const items = arrayField(res.data, "items", "list chat messages") as { - sender: { address: string }; - parts: { kind: string; text?: string }[]; - }[]; - const agentMessage = items.find( - (item) => - item.sender.address === agentAddress && item.parts.some((p) => p.kind === "text"), - ); - if (agentMessage !== undefined) { - const text = agentMessage.parts - .filter((p) => p.kind === "text") - .map((p) => p.text ?? "") - .join(""); - if ( - !/Myra/.test(text) || - !text.trimEnd().endsWith("?") || - /credential error/i.test(text) - ) { - throw new Error( - `expected the canned opener naming Myra and asking a ` + - `question, got: ${JSON.stringify(agentMessage)}`, - ); - } - console.log(` Myra's canned opener: ${text}`); - return; - } - if (Date.now() > deadline) { - throw new Error( - `no agent-authored message landed in chat ${chatId} within ` + - `60s of mint with zero user messages sent; messages seen: ` + - `${JSON.stringify(items)}\nhub output:\n${hub.output()}`, - ); - } - await Bun.sleep(200); - } - }, - ); - - if (USE_OLLAMA) { - // The conversation must continue past the greeting, across - // several rapid-fire turns: N=6 sequential messages, each sent - // the instant the previous reply lands (zero settle delay, - // overridable only for local debugging via - // E2E_TURN2_DELAY_MS) — the real user pattern that a run - // settling to "completed" between turns (see - // `@corbits/folded-runs`' `isFoldedRunSettled`) and a chat - // orchestrator that only re-arms its silent-turn notice on a - // real reply (see `chat-orchestrator.ts`'s - // `notifiedDropAddresses`) can both go silently wrong under. - // This is where a run that ends after its opening turn, a - // mail rejected as terminal, or two consecutive silent turns - // swallowing each other's notice, all show. - const RAPID_TURN_COUNT = 6; - for (let turn = 1; turn <= RAPID_TURN_COUNT; turn++) { - await hop(`rapid turn ${turn} gets a real answer`, async () => { - const settleMs = Number(process.env["E2E_TURN2_DELAY_MS"] ?? "0"); - if (settleMs > 0) await Bun.sleep(settleMs); - const sent = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - { - parts: [ - { - kind: "text", - text: `turn ${turn}: say the number ${turn}`, - }, - ], - }, - admin.cookies, - ); - expectStatus(`send turn ${turn}`, sent, 201); - const sentAt = Date.now(); - const deadline = Date.now() + 180_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - admin.cookies, - ); - const items = arrayField(res.data, "items", "list chat messages") as { - sender: { address: string }; - createdAt: string; - parts: { kind: string; text?: string }[]; - }[]; - const agentTexts = items - .filter( - (item) => - item.sender.address === agentAddress && - item.parts.some((p) => p.kind === "text") && - new Date(item.createdAt).getTime() > sentAt, - ) - .map((item) => item.parts.map((p) => p.text ?? "").join("")); - if (agentTexts.length >= 1) { - console.log( - ` Myra's answer to turn ${turn} (ollama): ${agentTexts.join(" ||| ")}`, - ); - return; - } - if (Date.now() > deadline) { - throw new Error( - `no answer to turn ${turn} within 180s; agent messages: ` + - `${JSON.stringify(agentTexts)}\nhub output (tail):\n` + - `${hub.output().slice(-60000)}`, - ); - } - await Bun.sleep(2000); - } - }); - } - } - }, 900_000); - }, -); diff --git a/scripts/e2e/local-rip.test.ts b/scripts/e2e/local-rip.test.ts deleted file mode 100644 index a483f54050..0000000000 --- a/scripts/e2e/local-rip.test.ts +++ /dev/null @@ -1,718 +0,0 @@ -// CL-6055: the scripted local-rip proof — everything a brand-new -// person does from sign-up through connecting their first real -// provider. One sequential scenario against a real hub, a real -// sidecar, and a real Postgres. -// -// Phase A (onboard → connect): alice signs up first as genesis owner, -// then mints the root bench herself through the stock tenant route -// (stock composition mounts no provisioning hook — signup mints -// nothing, so the first tenant comes from an ordinary -// `POST /api/tenants` with a caller-chosen slug, and its creator is -// the native owner) → a tester signs up as a plain account and the -// owner invites them onto the root as a member (the stock -// `members/invite` route plus activation) → occupied signup stays -// ungated → the root's owner (alice) connects a real inference -// credential through the key path (the connect flow's own machinery, -// called directly — see the stubbing note below), which fully seeds every default -// workflow, including "assistant" → the Connections surface (the -// tenant's own credentials list, the same route `connectorStatus` in -// `@workbench/settings-ui` reads) honestly reflects the connected -// credential. A joined member is read-only by design, so every -// owner-level leg runs as alice. -// -// Until CL-6057, this suite documented a real platform gap instead of -// 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. CL-7071 moved that publish off `seedTenant` -// onto `workbench setup` (the root tenant; descendants inherit). The -// connect flow runs on the genesis root itself, so an explicit -// `runPublishTools` hop onto the root stands in for -// setup, then `ensureSeeded` deploys without packing. -// -// Stubbing note: the key-path connect flow's own machinery -// (`testAndPersistCredential`, from `@workbench/onboarding`'s -// `complete-credential.ts`) stores a pasted key immediately, with no -// live probe of the provider gating it (CL-6123) — so there is nothing -// left to stub there. This suite still drives the same two halves the -// connect flow itself calls (`testAndPersistCredential`/`ensureSeeded`) -// directly rather than through HTTP, the same way `chat.test.ts` drives -// `seedCatalog` directly rather than going through an HTTP surface that -// has no test seam. Every call these two halves make is real HTTP -// against the spawned hub, and no provider is ever dialed during phase -// A: the resulting deployments carry the stub key as a stored, -// never-triggered source (`confirmDeployments: false`, matching -// onboarding's own connect flow — see `ensureSeeded`'s doc comment), so -// a made-up key is exactly as good as a real one for proving that leg. -// -// The deployed sources' `baseURL` is the real Anthropic host -// (`CATALOG_SEEDS`), which is the honest key-path behavior — phase A -// deliberately does not run those sources through -// `assertNeverRealProvider`, since flagging a real provider host here -// would be a false positive: it is never called, only stored. -// -// This suite once continued with a Phase B (task dispatch) and Phase C -// (trace and "Let Myra choose" planner legs) proving the tasks -// primitive end to end. That primitive was removed in favor of -// workflows/routines (owner ruling); this suite now covers onboard → -// connect only. - -import { describe, expect, test } from "bun:test"; - -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; -import { - CATALOG_WORKFLOWS, - DEFAULT_WORKFLOWS, - isLiveDeploymentStatus, - seedTenant, -} from "../../packages/onboarding/src/tenant-seed.ts"; -import { runPublishTools } from "../publish-tools.ts"; -import { createHubAPI, parseAs, type ApiCall } from "../../packages/hub-api-client/src/index.ts"; -import { - findPersonalTenant, - testAndPersistCredential, - ensureSeeded, - modelSourceFor, -} from "../../packages/onboarding/src/complete-credential.ts"; -import { - readTenantDesiredStateStatus, - reconcileTenantDesiredState, - resolveTenantDeployer, - resolveTenantModelSource, -} from "../../packages/onboarding/src/desired-state.ts"; -import { CredentialResponse, paginatedSchema } from "@intx/types"; -import { - api, - createCleanupHarness, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - startHub, - type HubHandle, -} from "./harness.ts"; - -const { tempDir, track } = createCleanupHarness(); - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "local-rip: DATABASE_URL is not set; suite skipped. Set DATABASE_URL " + - "(see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` so this skip " + - "can never pass silently there.", - ); -} - -// A key that is never sent anywhere: onboarding never probes it (see -// the stubbing note above), and the deployments it seeds are never -// triggered. Named so it can never be mistaken for a real secret if it -// leaks into a log line or a bug report. -const STUB_API_KEY = "e2e-local-rip-stub-key-not-real"; - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -async function signUp( - baseUrl: string, - name: string, -): Promise<{ userId: string; email: string; cookies: string[] }> { - const email = `local-rip-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { - name, - email, - password, - }); - expectStatus(`sign-up for ${name}`, res, 200); - if (res.cookies.length === 0) { - throw new Error(`sign-up for ${name} returned no session cookie`); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - `sign-up user field for ${name}`, - ); - return { userId, email, cookies: res.cookies }; -} - -describe.skipIf(databaseUrl === undefined)("local-rip: onboard → connect", () => { - test("a brand-new person signs up, joins the root as a member, and the owner connects a real provider through the key path", async () => { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await hop("database setup", async () => { - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - }); - - const hub: HubHandle = await hop("hub boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("e2e-local-rip-hub-data-"), - // Deliberately no ANTHROPIC_API_KEY: like `smoke-onboarding`, - // this hub carries no hub-owned seed model credential — this - // scenario's own connect step is what finishes seeding it. - }), - ); - track(hub); - - const hubApi: ApiCall = createHubAPI(hub.baseUrl); - - const admin = await hop("alice genesis sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - expectStatus("alice sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("alice sign-up returned no session cookie"); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - "alice sign-up user field", - ); - const probe = await api(hub.baseUrl, "GET", "/api/me/principals", undefined, res.cookies); - expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { data: unknown[] }).data).toEqual([]); - // Stock composition mounts no provisioning hook — signup mints - // nothing — so the first tenant comes from an ordinary - // `POST /api/tenants` with a caller-chosen slug, and its - // creator is the native owner. - const minted = await api( - hub.baseUrl, - "POST", - "/api/tenants", - { slug: "workbench", name: "Workbench" }, - res.cookies, - ); - expectStatus("alice genesis tenant create", minted, 201); - const mintedBody = minted.data as { - id?: string; - tenantId?: string; - }; - const tenantId = - typeof mintedBody.id === "string" - ? mintedBody.id - : typeof mintedBody.tenantId === "string" - ? mintedBody.tenantId - : undefined; - if (tenantId === undefined) { - throw new Error(`create tenant answered no id: ${JSON.stringify(minted.data)}`); - } - return { - cookies: res.cookies, - userId, - tenantId, - tenantSlug: "workbench", - }; - }); - - // Stock composition leaves self-serve signup ungated: a brand-new - // person can still sign up once the hub is occupied (there is no - // signup mode left to close). Proven against the main hub itself, - // so no second boot is needed. - await hop("signup stays ungated once the hub is occupied", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Open Signup Tester", - email: `local-rip-open-${crypto.randomUUID()}@example.invalid`, - password: `pw-${crypto.randomUUID()}`, - }); - expectStatus("open-signup sign-up attempt", res, 200); - if (res.cookies.length === 0) { - throw new Error("open-signup sign-up returned no session cookie"); - } - }); - - const user = await hop("sign-up", () => signUp(hub.baseUrl, "Local Rip Tester")); - - // Stock composition auto-joins nobody: a second signup only - // registers an account. The owner brings the tester onto the - // root through the native invite route (member role, so read-only - // by design) and activates the membership. - await hop("the owner invites the tester onto the root bench as a member", async () => { - const rolesRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${admin.tenantId}/roles`, - undefined, - admin.cookies, - ); - expectStatus("list root roles", rolesRes, 200); - const roles = (rolesRes.data as { data: { id: string; name: string }[] }).data; - const memberRole = roles.find((role) => role.name === "member"); - if (memberRole === undefined) { - throw new Error(`no member role on the root tenant: ${JSON.stringify(roles)}`); - } - const invited = await api( - hub.baseUrl, - "POST", - `/api/tenants/${admin.tenantId}/members/invite`, - { email: user.email, roleId: memberRole.id }, - admin.cookies, - ); - expectStatus("invite tester", invited, 201); - const principalId = stringField(invited.data, "id", "invite result"); - const activated = await api( - hub.baseUrl, - "PATCH", - `/api/tenants/${admin.tenantId}/principals/${principalId}`, - { status: "active" }, - admin.cookies, - ); - expectStatus("activate tester", activated, 200); - // Inviting the same account again mints nothing: stock answers - // 409, the surviving idempotency half of the removed - // re-provision hop. - const repeat = await api( - hub.baseUrl, - "POST", - `/api/tenants/${admin.tenantId}/members/invite`, - { email: user.email, roleId: memberRole.id }, - admin.cookies, - ); - expectStatus("re-invite", repeat, 409); - }); - - await hop("the invited bench is a real tenant membership", async () => { - const res = await api(hub.baseUrl, "GET", "/api/me/principals", undefined, user.cookies); - expectStatus("list principals", res, 200); - const rows = (res.data as { data: { tenantId: string }[] }).data; - const own = rows.find((row) => row.tenantId === admin.tenantId); - if (own === undefined) { - throw new Error( - `invited tenant ${admin.tenantId} is missing from the caller's own principals: ${JSON.stringify(rows)}`, - ); - } - }); - - const tenant = await hop( - "the freshly created bench resolves through findPersonalTenant", - async () => { - const found = await findPersonalTenant(hubApi, admin.cookies, admin.tenantSlug); - if (found === undefined) { - throw new Error(`findPersonalTenant found nothing for slug ${admin.tenantSlug}`); - } - expect(found.tenantId).toBe(admin.tenantId); - return found; - }, - ); - - // CL-7584: the desired-state document. A fresh hub boots seedless — - // nothing grants the root its assistant until someone connects — - // the doc-driven reconcile converges without reinstalling, a - // second member never mints a second Myra, and a second tenant - // created through the native route converges onto its own. - await hop( - "CL-7584: the fresh hub boots seedless — the root has no assistant before anyone connects", - async () => { - const assetsRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/assets?kind=workflow&inherited=false`, - undefined, - admin.cookies, - ); - expectStatus("list root workflow assets", assetsRes, 200); - const assets = assetsRes.data as { name: string }[]; - if (assets.some((asset) => asset.name === "assistant")) { - throw new Error( - "the genesis root already carries an assistant on a seedless boot — hub boot seeded product state", - ); - } - }, - ); - - const pushWorkflow = createGitWorkflowPusher(); - - const connected = await hop( - "connecting a real inference credential via the key path (no provider probe — CL-6123)", - async () => { - const result = await testAndPersistCredential({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - userId: admin.userId, - userEmail: "alice@example.com", - provider: "anthropic", - apiKey: STUB_API_KEY, - pushWorkflow, - log: () => undefined, - }); - if (result.kind !== "connected") { - throw new Error( - `expected the key-path connect to succeed, got: ${JSON.stringify(result)}`, - ); - } - expect(result.tenantId).toBe(tenant.tenantId); - return result; - }, - ); - - // The deploy step needs the sidecar's dial-in to have completed — - // ensureSeeded's own deployment call answers 502 until it has, - // and (unlike the native workflow-deploy route the walking - // skeleton retries directly) that 502 surfaces as a thrown - // CliError rather than a response this suite can branch on. Every - // step ensureSeeded/seedTenant takes is itself ensure-then-create, - // so retrying the whole call is safe. - async function deploySeededWorkflows( - workflows: typeof DEFAULT_WORKFLOWS, - ): Promise>> { - const deadline = Date.now() + 60_000; - for (;;) { - if (hub.exited()) { - throw new Error( - `hub exited before default workflows could deploy; output:\n${hub.output()}`, - ); - } - try { - return await seedTenant({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: tenant.tenantId, - principalId: tenant.principalId, - domain: tenant.tenantDomain, - }, - model: await modelSourceFor(hubApi, admin.cookies, tenant.tenantId, "anthropic"), - pushWorkflow, - log: () => undefined, - workflows, - confirmDeployments: false, - }); - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(200); - } - } - } - - // CL-7071: seedTenant/ensureSeeded no longer pack. The connect - // flow runs on the genesis root itself, so install `corbits-tools` - // onto that already-existing tenant with the publish-tools CLI — - // the same sign-in → tenant resolve → publish path an operator - // runs (`bun run publish-tools`) — rather than calling the - // publisher with a privileged cookie jar directly. Then - // ensureSeeded deploys assistant without packing. - await hop( - "publish-tools installs corbits-tools onto the created root bench (setup's job, not seed's)", - async () => { - const result = await runPublishTools({ - hubUrl: hub.baseUrl, - email: "alice@example.com", - password: "password123", - tenant: admin.tenantSlug, - log: () => undefined, - }); - expect(result.success).toBe(true); - expect(result.summaries.length).toBeGreaterThan(0); - }, - ); - - await hop( - "the real, unmodified connect flow fully seeds every default workflow, including 'assistant'", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - if (hub.exited()) { - throw new Error(`hub exited before ensureSeeded could run; output:\n${hub.output()}`); - } - try { - await ensureSeeded({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - pushWorkflow, - log: () => undefined, - tenant: connected, - provider: "anthropic", - apiKey: STUB_API_KEY, - }); - break; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(200); - } - } - }, - ); - - await hop( - "the default workflow (assistant) plus the on-demand catalog deploy and go live (CL-7074: only assistant is seeded automatically; the rest deploy here via the same seeding-library path a real on-demand deploy would use)", - async () => { - const workflows = [...DEFAULT_WORKFLOWS, ...CATALOG_WORKFLOWS]; - await deploySeededWorkflows(workflows); - for (const workflow of workflows) { - const assetsRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/assets?kind=workflow&inherited=false`, - undefined, - user.cookies, - ); - expectStatus(`list assets for ${workflow.assetName}`, assetsRes, 200); - const assets = assetsRes.data as { id: string; name: string }[]; - const asset = assets.find((a) => a.name === workflow.assetName); - if (asset === undefined) { - throw new Error(`no asset named ${workflow.assetName}`); - } - const deploymentsRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/workflows/deployments`, - undefined, - user.cookies, - ); - expectStatus(`list deployments for ${workflow.assetName}`, deploymentsRes, 200); - const deployments = deploymentsRes.data as { - definitionAssetId: string; - status: string; - }[]; - const live = deployments.find( - (d) => d.definitionAssetId === asset.id && isLiveDeploymentStatus(d.status), - ); - if (live === undefined) { - throw new Error( - `no live deployment for ${workflow.assetName}: ${JSON.stringify(deployments)}`, - ); - } - } - }, - ); - - await hop("the Connections surface reflects the connected credential", async () => { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/credentials`, - undefined, - user.cookies, - ); - expectStatus("list tenant credentials", res, 200); - const credentials = parseAs( - paginatedSchema(CredentialResponse), - res.data, - "credentials response", - ).data; - // The self-served connect flow names the credential by the - // connector's display name ("Anthropic") — see - // `credentialDisplayName` in `@workbench/onboarding`'s - // `complete-credential.ts`: the Plugins gallery resolver looks - // a credential up by that exact name, so seeding under the - // old `inferenceCredentialName` convention would leave it - // invisible to the gallery. - const planted = credentials.find((credential) => credential.name === "Anthropic"); - if (planted === undefined) { - throw new Error( - `no "Anthropic" credential on the tenant's Connections surface: ${JSON.stringify(credentials)}`, - ); - } - expect(planted.status).toBe("active"); - expect(planted.type).toBe("api_key"); - }); - - // CL-7584: the doc-driven reconcile converges without reinstalling, - // a second member never mints a second Myra, and a second tenant - // created through the native route converges onto its own. - // - // Stock Interchange cutover (hub fd3a43e2): the skills mount is - // gone, so the plant writes nothing and the reconcile re-read pins - // every skill "blocked" — the report is never ready, and a second - // pass re-enters the seed half. The old zero-non-GET idempotence - // assertion is unachievable against that steady state; it now lives - // in the `desired-state-reconcile` unit test, whose faithful mount - // still converges. This hop proves the stock-reality steady state - // instead: workflows installed and present, skills pending-but-absent. - await hop( - "CL-7584: reconcile converges workflows while skills stay pending (stock cutover)", - async () => { - const args = { - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: tenant.tenantId, - principalId: tenant.principalId, - domain: tenant.tenantDomain, - }, - model: await modelSourceFor(hubApi, admin.cookies, tenant.tenantId, "anthropic"), - pushWorkflow, - log: () => undefined, - }; - const first = await reconcileTenantDesiredState(args); - expect(first.ready).toBe(false); - expect( - first.pins - .filter((pin) => pin.kind === "workflow") - .every((pin) => pin.status === "installed"), - ).toBe(true); - expect( - first.pins.filter((pin) => pin.kind === "skill").every((pin) => pin.status === "blocked"), - ).toBe(true); - const status = await readTenantDesiredStateStatus(hubApi, admin.cookies, tenant.tenantId); - expect(Object.values(status.workflows).every((s) => s === "present")).toBe(true); - expect(Object.values(status.skills).every((s) => s === "pending")).toBe(true); - }, - ); - - await hop("CL-7584: a joined member's root still carries exactly one assistant", async () => { - const assetsRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/assets?kind=workflow&inherited=false`, - undefined, - user.cookies, - ); - expectStatus("list root workflow assets", assetsRes, 200); - const assets = assetsRes.data as { name: string }[]; - expect(assets.filter((asset) => asset.name === "assistant").length).toBe(1); - }); - - await hop( - "CL-7584: a second tenant created through the native route converges onto its own assistant", - async () => { - const stamp = Date.now(); - const createRes = await api( - hub.baseUrl, - "POST", - "/api/tenants", - { - name: `Local Rip Second ${stamp}`, - // `slug` is required by the native route's `CreateTenant` - // validator, and `parentId` makes the new tenant a child of - // the root tenant: catalog visibility walks the ancestor - // chain (`listVisibleOfferings`), so only a child inherits - // the root's seeded offerings — a sibling root would resolve - // no models and the kick would report the workflow pins - // blocked forever. The child still gets its OWN assistant - // assets and deployments; only the catalog is inherited. - slug: `local-rip-second-${stamp}`, - parentId: tenant.tenantId, - }, - admin.cookies, - ); - expectStatus("create second tenant", createRes, 201); - const body = createRes.data as { id?: string; tenantId?: string }; - const secondTenantId = - typeof body.id === "string" - ? body.id - : typeof body.tenantId === "string" - ? body.tenantId - : undefined; - if (secondTenantId === undefined) { - throw new Error(`create tenant answered no id: ${JSON.stringify(body)}`); - } - // Pre-kick guard through the same `resolveTenantModelSource` - // path the reconcile below deploys with: the child must resolve - // the root's inherited offerings before anything can deploy. - // If this is undefined the poll below can only time out as - // blocked, so fail fast with the reason. - const model = await resolveTenantModelSource(hubApi, admin.cookies, secondTenantId); - if (model === undefined) { - throw new Error( - "the second tenant resolves no catalog models (expected the root's inherited offerings)", - ); - } - // Stock Interchange cutover (hub fd3a43e2): the tenant-create - // observer is deleted, so no server kick converges this tenant. - // The suite drives the same `reconcileTenantDesiredState` the - // observer used to call, under the creator's owner principal, - // then polls for the live assistant exactly as before. - const deployer = await resolveTenantDeployer(hubApi, admin.cookies, secondTenantId); - if (deployer === undefined) { - throw new Error("the second tenant resolves no deployable owner principal"); - } - const kick = await reconcileTenantDesiredState({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: secondTenantId, - principalId: deployer.principalId, - domain: deployer.tenantDomain, - }, - model, - pushWorkflow, - log: () => undefined, - }); - // Workflows install; skills stay pending (stock cutover — the - // skills mount is gone), so the report is never ready. - expect(kick.ready).toBe(false); - expect( - kick.pins - .filter((pin) => pin.kind === "workflow") - .every((pin) => pin.status === "installed"), - ).toBe(true); - // Poll the new tenant's own assets and deployments for its - // live assistant. - const deadline = Date.now() + 90_000; - let lastSeen: unknown; - for (;;) { - if (hub.exited()) { - throw new Error( - `hub exited before the second tenant converged; output:\n${hub.output()}`, - ); - } - const assetsRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${secondTenantId}/assets?kind=workflow&inherited=false`, - undefined, - admin.cookies, - ); - expectStatus("list second-tenant assets", assetsRes, 200); - const assets = assetsRes.data as { id: string; name: string }[]; - lastSeen = assets.map((a) => a.name); - const assistant = assets.find((a) => a.name === "assistant"); - if (assistant !== undefined) { - const deploymentsRes = await api( - hub.baseUrl, - "GET", - `/api/tenants/${secondTenantId}/workflows/deployments`, - undefined, - admin.cookies, - ); - expectStatus("list second-tenant deployments", deploymentsRes, 200); - const deployments = deploymentsRes.data as { - definitionAssetId: string; - status: string; - }[]; - if ( - deployments.some( - (d) => d.definitionAssetId === assistant.id && isLiveDeploymentStatus(d.status), - ) - ) { - break; - } - } - if (Date.now() > deadline) { - throw new Error( - `the second tenant never converged onto its own assistant; last asset names: ${JSON.stringify(lastSeen)}`, - ); - } - await Bun.sleep(500); - } - // The converged second tenant carries its own live assistant - // while its skills stay pending (stock cutover). - const secondStatus = await readTenantDesiredStateStatus( - hubApi, - admin.cookies, - secondTenantId, - ); - expect(Object.values(secondStatus.skills).every((s) => s === "pending")).toBe(true); - }, - ); - }, 180_000); -}); diff --git a/scripts/e2e/play.ts b/scripts/e2e/play.ts deleted file mode 100644 index 55b3509ab3..0000000000 --- a/scripts/e2e/play.ts +++ /dev/null @@ -1,637 +0,0 @@ -// Live play harness: boots the real stack (scratch database, hub, -// sidecar), signs up, connects a provider, mints Myra's workbench, then -// plays a scripted list of human turns and prints every agent reply — -// the fastest way to sit in a real workbench and push Myra with a real -// model. Not a test; nothing here asserts. Usage: -// -// E2E_PROVIDER=ollama OLLAMA_BASE_URL=http://localhost:11434 \ -// DATABASE_URL=postgres://localhost:5432/workbench_play \ -// bun run scripts/e2e/play.ts '["Do research on AI daily for me", "..."]' -// -// PLAY_DUMP=1 also prints filtered sidecar output at the end. -import { expect } from "bun:test"; -import { ApprovalResponse, paginatedSchema } from "@intx/types"; -import { type } from "arktype"; - -import { headlineFor } from "../../packages/approvals/src/headline.ts"; -import { resetSchema, setupDatabase } from "../db-setup.ts"; -import { createGitWorkflowPusher } from "../../packages/connections/src/workflow-push.ts"; -import { DEFAULT_WORKFLOWS, seedTenant } from "../../packages/onboarding/src/tenant-seed.ts"; -import { createHubAPI, type ApiCall } from "../../packages/hub-api-client/src/index.ts"; -import { - findPersonalTenant, - testAndPersistCredential, - ensureSeeded, - modelSourceFor, -} from "../../packages/onboarding/src/complete-credential.ts"; -import { OLLAMA_PLACEHOLDER_SECRET } from "../../packages/connections/src/credential-test.ts"; -import { - api, - e2eDatabaseUrl, - expectStatus, - freePort, - hop, - provisionSidecar, - startHub, - startSidecar, - type ApiResult, - type HubHandle, - type SpawnedApp, -} from "./harness.ts"; - -import { mkdtemp } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join as pathJoin } from "node:path"; -// ANSI color sequences the hub and sidecar wrap their dev output in; -// stripped before any of it is filtered or printed here. -const ESC = "\u001b"; -const ANSI_COLOR = new RegExp(`${ESC}\\[[0-9;]*m`, "g"); -const strip = (text: string) => text.replace(ANSI_COLOR, ""); - -const tracked: SpawnedApp[] = []; -const tempDir = (prefix: string) => mkdtemp(pathJoin(tmpdir(), prefix)); -const track = (app: SpawnedApp) => { - tracked.push(app); -}; -process.on("exit", () => { - for (const a of tracked) { - try { - void a.stop(); - } catch { - // Best-effort teardown on exit: a child already gone is fine. - } - } -}); - -const databaseUrl = e2eDatabaseUrl(); -if (databaseUrl === undefined) { - console.warn( - "greeting-delivery: DATABASE_URL is not set; suite skipped. Set " + - "DATABASE_URL (see .env.example) to run it; start Postgres with `docker compose -f compose.test.yml up -d` " + - "so this skip can never pass silently there.", - ); -} - -// Never sent anywhere for real: onboarding never probes it (CL-6123), -// and the deployments it seeds carry it as a stored, never-triggered -// source until the assistant's own opening turn genuinely dials it. -const STUB_API_KEY = "e2e-greeting-delivery-stub-key-not-real"; - -// E2E_PROVIDER=ollama + OLLAMA_BASE_URL turns this suite into a live -// acceptance gate the same way `walkthrough.ts`'s E2E_PROVIDER_API_KEY -// does for Anthropic: a real Ollama instance actually answers the -// greeting kickoff, so the final assertion below expects real prose -// instead of the stub key's credential-error report. -const OLLAMA_BASE_URL = process.env["OLLAMA_BASE_URL"]; -const USE_OLLAMA = process.env["E2E_PROVIDER"] === "ollama" && OLLAMA_BASE_URL !== undefined; -const CONNECT_PROVIDER = USE_OLLAMA ? ("ollama" as const) : ("anthropic" as const); -const CONNECT_API_KEY = USE_OLLAMA ? OLLAMA_PLACEHOLDER_SECRET : STUB_API_KEY; - -function stringField(data: unknown, field: string, what: string): string { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (typeof value === "string" && value !== "") return value; - } - throw new Error(`${what}: missing string field "${field}": ${JSON.stringify(data)}`); -} - -function arrayField(data: unknown, field: string, what: string): unknown[] { - if (typeof data === "object" && data !== null && field in data) { - const value = (data as Record)[field]; - if (Array.isArray(value)) return value; - } - throw new Error(`${what}: missing array field "${field}": ${JSON.stringify(data)}`); -} - -async function signUp( - baseUrl: string, - name: string, -): Promise<{ userId: string; email: string; cookies: string[] }> { - const email = `greeting-delivery-${crypto.randomUUID()}@example.invalid`; - const password = `pw-${crypto.randomUUID()}`; - const res = await api(baseUrl, "POST", "/api/auth/sign-up/email", { - name, - email, - password, - }); - expectStatus(`sign-up for ${name}`, res, 200); - if (res.cookies.length === 0) { - throw new Error(`sign-up for ${name} returned no session cookie`); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - `sign-up user field for ${name}`, - ); - return { userId, email, cookies: res.cookies }; -} - -const SCRIPT: string[] = JSON.parse(process.argv[2] ?? "[]"); -const TURN_TIMEOUT_MS = 420_000; - -async function main(): Promise { - const url = databaseUrl; - if (url === undefined) throw new Error("unreachable: suite is skipped"); - - await hop("database setup", async () => { - await resetSchema(url); - const report = await setupDatabase(url); - expect(report.action).toBe("migrated"); - }); - - const sidecarId = "greeting-delivery-sidecar"; - const sidecarToken = crypto.randomUUID(); - await provisionSidecar(url, sidecarId, sidecarToken); - - const hub: HubHandle = await hop("hub boot", async () => - startHub({ - databaseUrl: url, - port: freePort(), - sessionSecret: Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("hex"), - dataDir: await tempDir("e2e-greeting-delivery-hub-data-"), - }), - ); - track(hub); - - const sidecar: SpawnedApp = startSidecar({ - hubPort: new URL(hub.baseUrl).port ? Number(new URL(hub.baseUrl).port) : 80, - sidecarId, - token: sidecarToken, - dataDir: await tempDir("e2e-greeting-delivery-sidecar-data-"), - }); - track(sidecar); - - const hubApi: ApiCall = createHubAPI(hub.baseUrl); - - const admin = await hop("alice genesis sign-up", async () => { - const res = await api(hub.baseUrl, "POST", "/api/auth/sign-up/email", { - name: "Alice", - email: "alice@example.com", - password: "password123", - }); - expectStatus("alice sign-up", res, 200); - if (res.cookies.length === 0) { - throw new Error("alice sign-up returned no session cookie"); - } - const userId = stringField( - (res.data as { user: unknown }).user, - "id", - "alice sign-up user field", - ); - const probe = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - res.cookies, - ); - expectStatus("alice genesis probe", probe, 200); - expect((probe.data as { kind: string }).kind).toBe("needs-onboarding"); - const minted = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - { name: "Workbench" }, - res.cookies, - ); - expectStatus("alice genesis provision", minted, 200); - expect((minted.data as { kind: string }).kind).toBe("provisioned"); - return { cookies: res.cookies, userId }; - }); - - const user = await hop("sign-up", () => signUp(hub.baseUrl, "Greeting Delivery Tester")); - - const provisioned = await hop( - "a membership probe joins the genesis root as a member", - async () => { - const res = await api( - hub.baseUrl, - "POST", - "/api/onboarding/provision", - undefined, - user.cookies, - ); - expectStatus("provision probe", res, 200); - const data = res.data as { - kind: string; - tenantSlug: string; - seeded: boolean; - }; - expect(data.kind).toBe("existing-member"); - return data; - }, - ); - - // A joined member is read-only by design, so every owner-level leg - // below runs as alice, the genesis owner. - const tenant = await hop("the joined root resolves through findPersonalTenant", async () => { - const found = await findPersonalTenant(hubApi, admin.cookies, provisioned.tenantSlug); - if (found === undefined) { - throw new Error(`findPersonalTenant found nothing for slug ${provisioned.tenantSlug}`); - } - return found; - }); - - const pushWorkflow = createGitWorkflowPusher(); - - const connected = await hop( - "connecting a real inference credential via the key path (no provider probe — CL-6123)", - async () => { - const testArgs = { - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - userId: admin.userId, - userEmail: "alice@example.com", - provider: CONNECT_PROVIDER, - apiKey: CONNECT_API_KEY, - pushWorkflow, - log: () => undefined, - }; - const result = await testAndPersistCredential( - USE_OLLAMA && OLLAMA_BASE_URL !== undefined - ? { ...testArgs, baseURLOverride: OLLAMA_BASE_URL } - : testArgs, - ); - if (result.kind !== "connected") { - throw new Error(`expected the key-path connect to succeed, got: ${JSON.stringify(result)}`); - } - return result; - }, - ); - - await hop( - "the real, unmodified connect flow fully seeds every default workflow, including 'assistant'", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - if (sidecar.exited()) { - throw new Error( - `sidecar exited before ensureSeeded could run; output:\n${sidecar.output()}`, - ); - } - try { - const seedArgs = { - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - pushWorkflow, - log: () => undefined, - tenant: connected, - provider: CONNECT_PROVIDER, - apiKey: CONNECT_API_KEY, - }; - await ensureSeeded( - USE_OLLAMA && OLLAMA_BASE_URL !== undefined - ? { ...seedArgs, baseURLOverride: OLLAMA_BASE_URL } - : seedArgs, - ); - break; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - }, - ); - - async function deploySeededWorkflows(): Promise { - const deadline = Date.now() + 60_000; - for (;;) { - if (sidecar.exited()) { - throw new Error( - `sidecar exited before default workflows could deploy; output:\n${sidecar.output()}`, - ); - } - try { - await seedTenant({ - api: hubApi, - cookies: admin.cookies, - hubUrl: hub.baseUrl, - tenant: { - tenantId: tenant.tenantId, - principalId: tenant.principalId, - domain: tenant.tenantDomain, - }, - model: await modelSourceFor(hubApi, admin.cookies, tenant.tenantId, CONNECT_PROVIDER), - pushWorkflow, - log: () => undefined, - workflows: DEFAULT_WORKFLOWS, - confirmDeployments: false, - }); - return; - } catch (cause) { - if (Date.now() > deadline) throw cause; - await Bun.sleep(1000); - } - } - } - - await hop("every default workflow deploys and goes live", deploySeededWorkflows); - - const assistantDefinitionId = await hop( - "the 'assistant' default workflow is invitable tenant-wide", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/invitable-definitions`, - undefined, - admin.cookies, - ); - if (res.status === 200) { - const items = arrayField(res.data, "items", "list invitable definitions") as { - id: string; - name: string; - }[]; - const assistant = items.find((item) => item.name === "assistant"); - if (assistant !== undefined) return assistant.id; - } - if (Date.now() > deadline) { - throw new Error(`"assistant" never appeared as invitable: ${JSON.stringify(res.data)}`); - } - await Bun.sleep(1000); - } - }, - ); - - const { chatId, agentAddress } = await hop( - "POST /workbenches kind=chat definitionId=assistant mints a chat with its one agent already joined", - async () => { - const deadline = Date.now() + 60_000; - let res: ApiResult; - for (;;) { - if (sidecar.exited()) { - throw new Error(`sidecar exited before chat creation; output:\n${sidecar.output()}`); - } - res = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches`, - { kind: "chat", definitionId: assistantDefinitionId }, - admin.cookies, - ); - if (res.status !== 500) break; - if (Date.now() > deadline) { - throw new Error( - `chat never became mintable (hub kept answering 500): ` + - `${JSON.stringify(res.data)}\nsidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(1000); - } - expectStatus("create chat", res, 201); - const id = stringField(res.data, "id", "create chat"); - const participants = arrayField(res.data, "participants", "create chat") as { - address: string; - handle: string; - }[]; - const agent = participants.find((p) => p.handle === "myra"); - if (agent === undefined) { - throw new Error(`chat has no "myra" agent participant: ${JSON.stringify(participants)}`); - } - return { chatId: id, agentAddress: agent.address }; - }, - ); - - // The proof: poll the freshly minted chat's own timeline for an - // agent-authored message, sending no user message at any point. - // A stub key dialing the real Anthropic host answers with a real - // 401, which the platform's own inference director folds into a - // *completed* turn carrying a self-describing credential-error - // report rather than failing the run outright (see - // `@intx/inference/src/default-director.ts` and - // `local-rip.test.ts`'s task leg, which asserts the identical - // text for the same reason) — that report landing here, with - // zero human messages sent, is the honest, deterministic proof - // that the greeting kickoff's mail actually triggered the - // agent's turn. - await hop( - "an agent-authored message lands in the chat with no user message ever sent", - async () => { - const deadline = Date.now() + 60_000; - for (;;) { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - admin.cookies, - ); - expectStatus("list chat messages", res, 200); - const items = arrayField(res.data, "items", "list chat messages") as { - sender: { address: string }; - parts: { kind: string; text?: string }[]; - }[]; - const agentMessage = items.find( - (item) => - item.sender.address === agentAddress && item.parts.some((p) => p.kind === "text"), - ); - if (agentMessage !== undefined) { - const text = agentMessage.parts - .filter((p) => p.kind === "text") - .map((p) => p.text ?? "") - .join(""); - if (USE_OLLAMA) { - // A live Ollama instance actually answers: the opening - // turn must be real prose, never the stub-key credential - // error this suite otherwise proves against Anthropic. - if (text.trim().length === 0 || /credential error/i.test(text)) { - throw new Error( - `expected a real Ollama greeting, got: ${JSON.stringify(agentMessage)}`, - ); - } - console.log(` Myra's unprompted greeting (ollama): ${text}`); - } else if (!text.includes("credential error") || !/40[13]/.test(text)) { - throw new Error( - `expected the agent's greeting-turn message to report the ` + - `stub key's credential error, got: ${JSON.stringify(agentMessage)}`, - ); - } - return; - } - if (Date.now() > deadline) { - throw new Error( - `no agent-authored message landed in chat ${chatId} within ` + - `60s of mint with zero user messages sent; messages seen: ` + - `${JSON.stringify(items)}\nsidecar output:\n${sidecar.output()}`, - ); - } - await Bun.sleep(1000); - } - }, - ); - - // ---- play loop ----------------------------------------------------- - const seenIds = new Set(); - async function listAgentMessages(): Promise<{ id: string; text: string; createdAt: string }[]> { - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - undefined, - admin.cookies, - ); - const items = arrayField(res.data, "items", "list") as { - id: string; - createdAt: string; - sender: { address: string }; - parts: { kind: string; text?: string }[]; - }[]; - return items - .filter((i) => i.sender.address === agentAddress && i.parts.some((p) => p.kind === "text")) - .map((i) => ({ - id: i.id, - createdAt: i.createdAt, - text: i.parts.map((p) => p.text ?? "").join(""), - })); - } - for (const m of await listAgentMessages()) seenIds.add(m.id); - - // The play harness has no UI, so nothing ever posts the in-chat - // approve card (`packages/chat/src/chat-orchestrator.ts`'s - // `postApproveBlock`) that a real bench user would click. An - // `approval: "ask"` tool (every pinned mutating tool -- routine - // create/update/run-now, task dispatch, request_capability, ...) - // parks on the platform's native `GET .../approvals` pending list - // instead of ever completing, so this pages through that list and - // approves every pending row with scope "once", printing what it - // approved. Without this, a turn that needs an approval-gated tool - // never finishes and the play loop times out waiting for a reply that - // can never come. - const TenantApprovalsSchema = paginatedSchema(ApprovalResponse); - async function autoApproveAll(): Promise { - let cursor: string | undefined; - for (;;) { - const query = cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""; - const res = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/approvals${query}`, - undefined, - admin.cookies, - ); - if (res.status !== 200) { - throw new Error( - `auto-approve: listing pending approvals failed with HTTP ` + - `${String(res.status)}: ${JSON.stringify(res.data)}`, - ); - } - const page = TenantApprovalsSchema(res.data); - if (page instanceof type.errors) { - throw new Error(`auto-approve: malformed pending approvals list: ${page.summary}`); - } - for (const item of page.data) { - const approved = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/approvals/${item.id}/approve`, - { scope: "once" }, - admin.cookies, - ); - const headline = headlineFor(item.toolDefinition, item.toolArguments); - if (approved.status === 200) { - console.log(` [auto-approved] ${headline}`); - } else { - console.log(` [auto-approve FAILED ${String(approved.status)}] ${headline}`); - } - } - if (page.nextCursor === null) break; - cursor = page.nextCursor; - } - } - - for (const human of SCRIPT) { - console.log(`\n>>> SAWYER: ${human}`); - const sent = await api( - hub.baseUrl, - "POST", - `/api/tenants/${tenant.tenantId}/chat/workbenches/${chatId}/messages`, - { parts: [{ kind: "text", text: human }] }, - admin.cookies, - ); - expectStatus("send", sent, 201); - const t0 = Date.now(); - let answered = false; - while (Date.now() - t0 < TURN_TIMEOUT_MS) { - await autoApproveAll(); - const fresh = (await listAgentMessages()).filter((m) => !seenIds.has(m.id)); - if (fresh.length > 0) { - for (const m of fresh) { - seenIds.add(m.id); - console.log(`<<< MYRA (${Math.round((Date.now() - t0) / 1000)}s): ${m.text}`); - } - // wait a little for follow-up messages in the same turn - await Bun.sleep(4000); - const more = (await listAgentMessages()).filter((m) => !seenIds.has(m.id)); - for (const m of more) { - seenIds.add(m.id); - console.log(`<<< MYRA (+): ${m.text}`); - } - answered = true; - break; - } - await Bun.sleep(2000); - } - if (!answered) { - console.log(`!!! no reply within ${TURN_TIMEOUT_MS / 1000}s`); - const tail = (s: string) => - strip(s) - .split("\n") - .filter( - (l) => - /WRN|ERR/.test(l) && - !/pack push failed|bootstrap retry|no live deployment anchor|heartbeat/.test(l), - ) - .slice(-15) - .join("\n"); - console.log( - "--- hub ---\n" + tail(hub.output()) + "\n--- sidecar ---\n" + tail(sidecar.output()), - ); - break; - } - } - if (process.env["PLAY_DUMP"] === "1") { - console.log("=== HUB ERRORS ==="); - console.log( - strip(hub.output()) - .split("\n") - .filter((l) => /ERR|WRN/.test(l)) - .slice(-40) - .join("\n"), - ); - console.log("=== SIDECAR OUTPUT (filtered) ==="); - console.log( - strip(sidecar.output()) - .split("\n") - .filter( - (l) => - /capabilit|tool|Tool|grants|ERR/.test(l) && !/hub·requests|pack push|bootstrap/.test(l), - ) - .slice(-60) - .join("\n"), - ); - } - // list routines + agents created in this tenant for the record - const routines = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/routines`, - undefined, - admin.cookies, - ); - console.log("\nROUTINES:", JSON.stringify(routines.data).slice(0, 800)); - const invitable = await api( - hub.baseUrl, - "GET", - `/api/tenants/${tenant.tenantId}/chat/invitable`, - undefined, - admin.cookies, - ); - console.log("AGENTS:", JSON.stringify(invitable.data).slice(0, 800)); -} - -await main(); -process.exit(0); diff --git a/scripts/evals-run.ts b/scripts/evals-run.ts deleted file mode 100644 index cd6226c4e5..0000000000 --- a/scripts/evals-run.ts +++ /dev/null @@ -1,236 +0,0 @@ -// `bun run eval` (CL-6143): plays every case in `cases/` against a -// real Myra deployment once, using `targets/real-target.ts`'s boot -// sequence. -// -// Live vs. plumbing, gated by `EVAL_PROVIDER_API_KEY`: -// - unset: runs with a stub credential so a turn is still genuinely -// sent, replied to, and its (empty) tool-call list still -// genuinely read off the platform's own tables — but a -// scorer expecting real interview behavior will correctly -// fail against the canned credential-error reply the stub -// produces, so this mode checks *plumbing* only (every -// step produced a turn with reply text and a tool-call -// array) rather than scorer pass/fail. This keeps CI green -// with no key configured, while still proving the harness -// itself works end to end against the real stack. -// - set: runs for real, prints the full scorer table, and -// persists the run to `evals.run` for history. -// -// Needs DATABASE_URL (see .env.example); like every e2e-shaped suite -// in this repo, a missing DATABASE_URL skips with a warning locally -// and fails loudly when CI=true. -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; - -import { - ALL_EVALS, - applyEvalsMigrations, - bootMyraTarget, - captureWorldSnapshot, - createEvalAssetService, - createPostgresEvalRunStore, - GITHUB_MCP_FAKE_RECORDING, - renderResultsMarkdown, - runMatrix, - type EvalRunResult, - type MyraTargetInfra, - type MyraTargetMcpFake, - type RunConfig, -} from "@corbits/evals"; -import { createDB } from "@intx/db"; -import { resetSchema, setupDatabase } from "./db-setup.ts"; -import { - api, - connectE2eDb, - e2eDatabaseUrl, - expectStatus, - freePort, - provisionSidecar, - startHub, - startSidecar, -} from "./e2e/harness.ts"; -import { assertDatabaseConfigured, skippedDatabaseWarning } from "./e2e/db-gate.ts"; - -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -function requireE2eDatabaseUrl(what: string): string { - const url = e2eDatabaseUrl(); - if (url === undefined) { - throw new Error(`${what}: DATABASE_URL is not set`); - } - return url; -} - -const infra: MyraTargetInfra = { - api, - connectE2eDb, - e2eDatabaseUrl, - expectStatus, - freePort, - provisionSidecar, - resetSchema, - setupDatabase, - startHub, - startSidecar, - // World scorers read real state (CL-6404): an `AssetService` over the - // scratch hub's own data dir, built the same way hub boot builds one - // (a fresh signing key only ever signs writes, which a snapshot reader - // never performs), plus a drizzle handle on the same scratch database. - captureWorldSnapshot: async ({ tenantId, hubDataDir, fakeReceipts }) => { - const url = requireE2eDatabaseUrl("captureWorldSnapshot"); - const { db, close } = createDB(dbConfigFromUrl(url)); - try { - const assetService = await createEvalAssetService({ - db, - dataDir: hubDataDir, - }); - return await captureWorldSnapshot( - { db, assetService, fakeReceiptsReader: fakeReceipts }, - tenantId, - ); - } finally { - await close(); - } - }, -}; - -function databaseUrl(): string | undefined { - const url = process.env["DATABASE_URL"]; - if (url !== undefined && url !== "") return url; - assertDatabaseConfigured(undefined, "bun run eval"); - return undefined; -} - -/** - * Plumbing-mode's own bar: every scripted step actually produced a - * turn (non-empty reply text) with a recorded, well-formed tool-call - * list — never that any scorer passed. Thrown findings name the eval - * and step so a real plumbing break (e.g. the trace reader silently - * returning nothing) fails loudly instead of passing by accident. - */ -function assertPlumbingOnly(results: readonly EvalRunResult[]): void { - const problems: string[] = []; - for (const result of results) { - for (const step of result.steps) { - if (step.turn.replyText.trim() === "") { - problems.push(`${result.evalName} step ${String(step.stepIndex)}: empty reply text`); - } - if (!Array.isArray(step.turn.toolCalls)) { - problems.push( - `${result.evalName} step ${String(step.stepIndex)}: toolCalls is not an array`, - ); - } - } - } - if (problems.length > 0) { - throw new Error(`plumbing check failed:\n${problems.join("\n")}`); - } -} - -async function main(): Promise { - const url = databaseUrl(); - if (url === undefined) { - process.stdout.write(skippedDatabaseWarning("bun run eval") + "\n"); - return; - } - - const live = - (process.env["EVAL_PROVIDER_API_KEY"] !== undefined && - process.env["EVAL_PROVIDER_API_KEY"] !== "") || - (process.env["EVAL_PROVIDER"] === "ollama" && process.env["OLLAMA_BASE_URL"] !== undefined); - - const configs: readonly RunConfig[] = [{ name: live ? "live" : "plumbing-only" }]; - const configByName = new Map(configs.map((config) => [config.name, config])); - - // The recorded GitHub MCP fake (CL-6338) rides every run: connected - // through the same `POST /mcp-servers` route real users use, so a - // live run's connect/GitHub tool calls hit the fixture instead of - // nothing; plumbing-only runs never call a tool, so it is inert there. - const mcpFakes: readonly MyraTargetMcpFake[] = [ - { server: "github", recording: GITHUB_MCP_FAKE_RECORDING }, - ]; - - const results = await runMatrix(ALL_EVALS, configs, async (configName) => { - const config = configByName.get(configName); - if (config === undefined) { - throw new Error(`no RunConfig registered for "${configName}"`); - } - return bootMyraTarget(config, infra, mcpFakes); - }); - - if (!live) { - assertPlumbingOnly(results); - process.stdout.write( - `bun run eval: plumbing-only run passed (${String(results.length)} eval run(s), ` + - "no EVAL_PROVIDER_API_KEY set — scorer results not required to pass).\n", - ); - // The reasons are the triage surface (each scorer names its own - // blocker) — print them even though plumbing mode never gates on - // them, so a scoreboard read never needs a second, live run just to - // see why a scorer is red. - for (const result of results) { - for (const step of result.steps) { - // A harness-driven step's reply IS its outcome record (an - // install summary, a webhook delivery result) — print it so a - // failed delivery's status and body are readable off the run. - if (step.turn.human.startsWith("(harness)")) { - process.stdout.write( - ` ${result.evalName} step ${String(step.stepIndex)}: ` + `${step.turn.replyText}\n`, - ); - } - for (const report of step.scorerReports) { - if (report.pass || report.skipped === true) continue; - process.stdout.write( - ` ${result.evalName} step ${String(step.stepIndex)} ` + - `${report.name}: ${report.reason}\n`, - ); - } - } - } - } - - const evalNames = ALL_EVALS.map((evalDef) => evalDef.name); - const configNames = configs.map((config) => config.name); - const markdown = renderResultsMarkdown(evalNames, configNames, results); - process.stdout.write(`${markdown}\n`); - - const reportPath = path.join(import.meta.dir, "..", "packages", "evals", "eval-report.md"); - await mkdir(path.dirname(reportPath), { recursive: true }); - await writeFile(reportPath, markdown); - - if (live) { - const { store, close } = createPostgresEvalRunStore(url); - try { - await applyEvalsMigrations(url); - for (const result of results) { - await store.save(result); - } - } finally { - await close(); - } - - const failed = results.flatMap((result) => - result.steps.flatMap((step) => - step.scorerReports.filter((report) => !report.pass && report.skipped !== true), - ), - ); - if (failed.length > 0) { - console.error( - `bun run eval: ${String(failed.length)} scorer(s) failed:\n` + - failed.map((report) => ` - ${report.name}: ${report.reason}`).join("\n"), - ); - process.exitCode = 1; - } - } -} - -await main(); diff --git a/workflows/README.md b/workflows/README.md index 2203e9311e..d84e68fda5 100644 --- a/workflows/README.md +++ b/workflows/README.md @@ -23,8 +23,9 @@ re-explaining these three independent flags each time: conversational agent/chat host, a workflow spawned only as another workflow's child (never picked directly), or a workflow whose approval gate is a poor fit for unattended scheduling. -- **Seeded** — whether the workflow is provisioned into every tenant's - bench by default, via `DEFAULT_WORKFLOWS` in - `packages/onboarding/src/tenant-seed.ts`. A workflow can be `automatable` - without being seeded (opt-in, e.g. because it needs a credential not - every tenant has connected) — the two are independent decisions. +- **Seeded** — historically, whether the workflow was provisioned into + every tenant's bench by default via a hub-side seed step; CL-8207 deleted + that step (the hub never seeds — the web client deploys only Myra, over + stock routes, per tenant). A workflow can be `automatable` without being + seeded (opt-in, e.g. because it needs a credential not every tenant has + connected) — the two are independent decisions. diff --git a/workflows/heartbeat/src/index.ts b/workflows/heartbeat/src/index.ts index 4550320062..fc0175dcda 100644 --- a/workflows/heartbeat/src/index.ts +++ b/workflows/heartbeat/src/index.ts @@ -17,8 +17,8 @@ // that needs no model must not call one, but with no non-agent step // shape available the least-bad option is to deploy against the // tenant's own real, resolved catalog offering like every other -// workflow (`resolveRealSourceOfferingIds` in -// `packages/onboarding/src/tenant-seed.ts`) rather than inventing a +// workflow (the same `resolveRealSourceOfferingIds` pattern the deleted +// hub-side seed step used) rather than inventing a // fake provider to route around a step that will still make a real // call. This definition still accepts `inferencePreferences` as plain // deploy-time data; it is the deployer's choice, never this diff --git a/workflows/workbench-digest/src/index.ts b/workflows/workbench-digest/src/index.ts index f3d90b186c..6a95fe3f01 100644 --- a/workflows/workbench-digest/src/index.ts +++ b/workflows/workbench-digest/src/index.ts @@ -14,9 +14,9 @@ // No agent-free step primitive runs on the deployed host today (see // `@corbits/heartbeat-workflow`'s own header comment for why), so this // step is unavoidably an agent step. CL-8160 stopped pinning a -// deployer-supplied synthetic noop `ModelSource` here: `seedTenant` -// (`packages/onboarding/src/tenant-seed.ts`) now deploys this workflow -// against the tenant's own real, resolved catalog offering like every +// deployer-supplied synthetic noop `ModelSource` here: a deployer now +// deploys this workflow against the tenant's own real, resolved catalog +// offering like every // other workflow (`resolveRealSourceOfferingIds`), so a trigger posts // an actual, human-visible digest line at that model's ordinary // per-turn cost rather than a constant empty reply.