diff --git a/.env.example b/.env.example index 3a9a8035d..435b6cd2b 100644 --- a/.env.example +++ b/.env.example @@ -43,15 +43,11 @@ HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 # with an index.html works — ../hub/public is a minimal placeholder page. HUB_STATIC_DIR=../web/dist -# The administrator account: the hub seeds it at boot and makes it the -# owner of the root tenant (WORKBENCH_DEFAULT_TENANT below), so a fresh -# checkout can sign in immediately. Sign in with these credentials right -# away. Unset values fall back to the defaults shown. The hub itself also -# authenticates as this account (and resolves the same root slug) to -# find the operator bench for the env-key auto-plant — see -# ANTHROPIC_API_KEY further down. -# Administrator identity. Unset values fall back to the defaults shown — -# fine for local development, set both for real deployments. +# The administrator identity `bun run dev` seeds for local development: +# the dev script signs this account up and makes it the owner of the root +# tenant (WORKBENCH_DEFAULT_TENANT below), so a fresh checkout can sign in +# immediately. Unset values fall back to the defaults shown — fine for +# local development, set both for real deployments. # HUB_ADMIN_EMAIL=alice@example.com # HUB_ADMIN_PASSWORD=password123 @@ -59,8 +55,8 @@ HUB_STATIC_DIR=../web/dist # feature it configures off; the hub never treats a partially-set group # as configured — it fails loudly at boot instead. -# Slug of the root tenant the hub ensures at boot. Every self-served -# personal bench parents under it, and the env-key auto-plant resolves +# Slug of the root tenant first signup mints. Every self-served +# personal bench parents under it, and `workbench setup`/`seed` resolve # the same slug. Unset falls back # to "workbench". ORG_SLUG is an alias when this is unset — set only one. # Upgrading a deploy whose existing root was not "workbench": set this @@ -93,32 +89,16 @@ HUB_STATIC_DIR=../web/dist # may register (comma-separated). Empty/unset = any domain. # WORKBENCH_ALLOWED_EMAIL_DOMAINS=acme.example -# Your Anthropic API key — set it for real AI replies. The hub plants it -# as a real, probed credential on the operator bench at hub start (the -# env-key auto-plant). Boot itself does not deploy workflows. Catalog -# rows may appear from that plant when the key is set. A freshly -# self-served personal bench gets the default workflow set through -# onboarding once someone connects a provider. -# ANTHROPIC_API_KEY= - -# Every other curated provider's key, read the same way and auto-planted -# the same way at hub start — set any subset of these, or none. See -# packages/onboarding/src/plant-env-credentials.ts (PROVIDER_ENV_VARS) -# for the full, authoritative list. -# OPENAI_API_KEY= -# GEMINI_API_KEY= -# XAI_API_KEY= -# OPENROUTER_API_KEY= -# OPENCODE_ZEN_API_KEY= -# GROQ_API_KEY= -# DEEPSEEK_API_KEY= -# MISTRAL_API_KEY= -# HUGGINGFACE_API_KEY= -# -# Local (or tailscale-tunneled) Ollama origin. Optional: auto-plants a -# probed catalog credential (no key required) on the operator bench at -# hub start, and mounts the `@corbits/memory` plane against the same -# origin when EMBED_BASE_URL is unset (`nomic-embed-text`, ollama style). +# Provider API keys are no longer read from the environment: hub boot +# plants nothing, and setting ANTHROPIC_API_KEY or any other provider's +# env var here has no effect. Connect a provider in the UI (the +# onboarding wizard's connect cards) instead. + +# Local (or tailscale-tunneled) Ollama origin. Optional, and still read +# by `bun run dev`'s memory setup: it mounts the `@corbits/memory` plane +# against this origin when EMBED_BASE_URL is unset (`nomic-embed-text`, +# ollama style). Connect the Ollama provider in the UI to give the hub +# itself an inference credential — no key required, just the base URL. # OLLAMA_BASE_URL=http://localhost:11434 # Set to 1 to make `workbench seed` also deploy the zero-cost diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md index fa8c586c5..5de1ca977 100644 --- a/IMPLEMENTATION.md +++ b/IMPLEMENTATION.md @@ -78,10 +78,9 @@ recorded per-package in each vendored package's own `VENDORED-FROM` file. `bun run dev` seeds the administrator account and ensures the root tenant; it does not insert agents, tools, workflows, or skills. An empty database is a valid hub. `bun run setup` applies migrations against the -running database and is safe to re-run. `ANTHROPIC_API_KEY` is the one -optional variable worth setting before boot — the env-key auto-plant -puts a real credential on the operator bench when it is set; without it, -inference waits until someone connects a provider. +running database and is safe to re-run. Provider API keys are never read +from the environment — hub boot plants no credentials; inference waits +until someone connects a provider. ## Acceptance mechanism: the e2e browser walkthrough diff --git a/README.md b/README.md index 1637c2be0..e3c6cdb47 100644 --- a/README.md +++ b/README.md @@ -77,13 +77,12 @@ inserts no users or tenants. An empty database is a valid hub: boot does not insert agents, tools, workflows, or skills. Product state arrives through onboarding and explicit seed callers, not production boot. -`ANTHROPIC_API_KEY` is the one optional line worth setting before boot — -with it, the env-key auto-plant puts a real credential on the operator -bench so the catalog is launchable; without it, inference waits until -someone connects a provider. +Provider API keys are never read from the environment: hub boot plants no +credentials, so setting `ANTHROPIC_API_KEY` or any other provider's env +var has no effect. Inference waits until someone connects a provider. -Leaving `ANTHROPIC_API_KEY` unset doesn't just apply to the administrator -account: anyone who signs up gets a personal bench with no default routines +That applies to every account, not just the administrator: anyone who +signs up gets a personal bench with no default routines deployed, and first-run tells them exactly that. Onboarding walks them through picking a provider — Anthropic, OpenAI, Google, OpenRouter, Hugging Face, Groq, or another of the curated providers in diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index 08bb3ffce..03f55c198 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -5,27 +5,10 @@ // Anything else the hub learns is data in the database, never // configuration. // -// ANTHROPIC_API_KEY is the one model-related variable a freshly -// self-served personal bench needs: when set, the hub carries a seed -// model credential (anthropic/claude-sonnet-5) it hands to -// `@workbench/onboarding` so that bench gets the default workflow set -// deployed at first login. Left unset, that deployment step is skipped -// — the bench is still provisioned, only the default workflow -// deployment is skipped, and the skip is logged. -// -// ANTHROPIC_API_KEY and every other curated provider's conventional key -// (`@workbench/onboarding`'s `PROVIDER_ENV_VARS` — OPENAI_API_KEY, -// GEMINI_API_KEY/GOOGLE_API_KEY, XAI_API_KEY, OPENROUTER_API_KEY, -// OPENCODE_ZEN_API_KEY, GROQ_API_KEY, DEEPSEEK_API_KEY, MISTRAL_API_KEY, -// HUGGINGFACE_API_KEY) are also read as an env-key auto-plant (CL-6101): -// once the hub finds its own operator bench (HUB_ADMIN_EMAIL/PASSWORD -// signed in, ORG_SLUG resolved — the same identity `workbench setup` / -// `workbench seed` use), it plants a real, probed credential for every -// key it finds there, making that bench's catalog launchable with no -// `workbench seed` re-run. See `../env-credential-plant.ts`. All of -// these — including HUB_ADMIN_EMAIL/PASSWORD/ORG_SLUG — are optional: -// the plant is skipped, quietly and non-fatally, whenever the admin -// identity cannot be resolved or no provider key is set. +// Provider credentials are never read from the environment: operators +// connect a provider in the UI (or via the same connect API the +// onboarding flow uses), never by setting ANTHROPIC_API_KEY or any +// other provider's env var — hub boot plants nothing. // // GOOGLE_CLIENT_ID/SECRET and GITHUB_CLIENT_ID/SECRET are each an // optional pair: set both to enable that OAuth provider on the sign-in @@ -40,11 +23,6 @@ // paste-a-token provider card. import { type } from "arktype"; -import { - envProviderBaseUrlsFrom, - envProviderKeysFrom, -} from "@workbench/onboarding"; -import type { SupportedCredentialProvider } from "@corbits/connections/credential-test"; const HTTP_URL = /^https?:\/\/.+$/; @@ -83,7 +61,7 @@ const HubEnv = type({ "WORKBENCH_DEFAULT_TENANT?": type( /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, ).describe( - 'slug of the root tenant the hub ensures at boot; every self-served personal bench parents under it, and setup/seed/plant resolve the same slug — ORG_SLUG is an alias when this is unset; default "workbench"', + 'slug of the root tenant the hub ensures at boot; every self-served personal bench parents under it, and setup/seed resolve the same slug — ORG_SLUG is an alias when this is unset; default "workbench"', ), "SIGNUP_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe( "the per-IP sign-up rate-limit window, in seconds, e.g. 60", @@ -106,50 +84,8 @@ const HubEnv = type({ "ROUTINE_SCHEDULER_POLL_INTERVAL_MS?": type(/^[1-9]\d*$/).describe( "dev/test-only override for the routine scheduler's poll interval, in milliseconds — unset (default) runs the real 30s production cadence; the e2e harness sets this to a fast interval so a scheduled-routine test doesn't wait out the real cadence", ), - "ANTHROPIC_API_KEY?": type("string > 0").describe( - "your Anthropic API key; optional, enables the default workflow set for freshly self-served benches, and auto-plants a probed catalog credential on the operator bench at hub start", - ), - "OPENAI_API_KEY?": type("string > 0").describe( - "your OpenAI API key; optional, auto-plants a probed catalog credential on the operator bench at hub start", - ), - "GEMINI_API_KEY?": type("string > 0").describe( - "your Google Gemini API key; optional, auto-plants a probed catalog credential on the operator bench at hub start — GOOGLE_API_KEY is used when this is unset", - ), - "GOOGLE_API_KEY?": type("string > 0").describe( - "your Google Gemini API key, under its other common name; only read when GEMINI_API_KEY is unset", - ), - "XAI_API_KEY?": type("string > 0").describe( - "your xAI API key; optional, auto-plants a probed catalog credential on the operator bench at hub start", - ), - "OPENROUTER_API_KEY?": type("string > 0").describe( - "your OpenRouter API key; optional, auto-plants a probed catalog credential on the operator bench at hub start", - ), - "OPENCODE_ZEN_API_KEY?": type("string > 0").describe( - "your Opencode Zen API key; optional, auto-plants a probed catalog credential on the operator bench at hub start", - ), - "GROQ_API_KEY?": type("string > 0").describe( - "your Groq API key; optional, auto-plants a probed catalog credential on the operator bench at hub start", - ), - "DEEPSEEK_API_KEY?": type("string > 0").describe( - "your DeepSeek API key; optional, auto-plants a probed catalog credential on the operator bench at hub start", - ), - "MISTRAL_API_KEY?": type("string > 0").describe( - "your Mistral API key; optional, auto-plants a probed catalog credential on the operator bench at hub start", - ), - "HUGGINGFACE_API_KEY?": type("string > 0").describe( - "your Hugging Face router API token; optional, auto-plants a probed catalog credential on the operator bench at hub start", - ), - "OLLAMA_BASE_URL?": type("string > 0").describe( - "the origin your local (or tailscale-tunneled) Ollama instance listens on, e.g. http://localhost:11434; optional, auto-plants a probed catalog credential (no key required) on the operator bench at hub start", - ), - "HUB_ADMIN_EMAIL?": type(/^[^@\s]+@[^@\s]+$/).describe( - "the administrator account the env-key auto-plant signs in as to find the operator bench; same identity `workbench setup`/`workbench seed` use — unset falls back to alice@example.com, the same default those commands use", - ), - "HUB_ADMIN_PASSWORD?": type("string >= 8").describe( - "the administrator password the env-key auto-plant signs in with; unset falls back to password123, the same default `workbench setup`/`workbench seed` use", - ), "ORG_SLUG?": type(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).describe( - 'alias for WORKBENCH_DEFAULT_TENANT when that is unset — same root/operator slug the hub, setup, seed, and the env-key auto-plant resolve; default "workbench"', + 'alias for WORKBENCH_DEFAULT_TENANT when that is unset — the same root/operator slug setup and seed resolve; default "workbench"', ), "GOOGLE_CLIENT_ID?": type("string > 0").describe( "Google OAuth client id; set together with GOOGLE_CLIENT_SECRET to enable Google sign-in", @@ -273,9 +209,7 @@ function parsePositiveMsEnv( return n; } -const DEFAULT_PLANT_ADMIN_EMAIL = "alice@example.com"; -const DEFAULT_PLANT_ADMIN_PASSWORD = "password123"; -const DEFAULT_PLANT_ORG_SLUG = "workbench"; +const DEFAULT_TENANT_SLUG = "workbench"; // One member per implemented `SidecarProvisioner` backend. Adding a new // backend (e.g. a remote sandbox) is: implement the contract in its own @@ -387,31 +321,6 @@ export type HubConfig = { /** How long an idle chat resident may sit before the hub reaps it via * a state-preserving undeploy. Defaults to `DEFAULT_CHAT_IDLE_REAP_MS`. */ readonly chatIdleReapMs: number; - /** Every curated provider's key found under its conventional env var - * name (`@workbench/onboarding`'s `PROVIDER_ENV_VARS`). Empty when - * none are set — the env-key auto-plant then does nothing. */ - readonly envProviderKeys: Partial< - Record - >; - /** The configured base URL for whichever curated providers carry one - * (`OLLAMA_BASE_URL` today, the only such provider). Empty when unset - * — the env-key auto-plant then probes and seeds ollama, if present in - * `envProviderKeys`, against its own default local origin. */ - readonly envProviderBaseUrls: Partial< - Record - >; - /** The identity the env-key auto-plant signs in as to find the - * operator bench — the same identity `workbench setup`/`workbench - * seed` use, defaulted the same way when unset. Always populated - * (never optional): an unset HUB_ADMIN_EMAIL/PASSWORD/ORG_SLUG is a - * valid local-dev shape, not a reason to skip the plant outright — - * the plant itself degrades to a no-op, logged, when this identity - * does not resolve to a real operator bench. */ - readonly envCredentialPlantAdmin: { - readonly email: string; - readonly password: string; - readonly orgSlug: string; - }; }; type ParsedHubEnv = typeof HubEnv.infer; @@ -646,13 +555,11 @@ export function readHubConfig( .map((d) => d.trim()) .filter((d) => d.length > 0); - // One deployment fact shared by first-signup genesis, setup/seed, and the - // env-key auto-plant. WORKBENCH_DEFAULT_TENANT wins; ORG_SLUG is the - // alias when that is unset. + // One deployment fact shared by first-signup genesis, setup, and seed. + // WORKBENCH_DEFAULT_TENANT wins; ORG_SLUG is the alias when that is + // unset. const defaultTenantSlug = - parsed.WORKBENCH_DEFAULT_TENANT ?? - parsed.ORG_SLUG ?? - DEFAULT_PLANT_ORG_SLUG; + parsed.WORKBENCH_DEFAULT_TENANT ?? parsed.ORG_SLUG ?? DEFAULT_TENANT_SLUG; const hubConfig: { -readonly [K in keyof HubConfig]: HubConfig[K] } = { databaseUrl: parsed.DATABASE_URL, @@ -683,13 +590,6 @@ export function readHubConfig( ? Number(parsed.SIGNIN_RATE_LIMIT_MAX) : DEFAULT_SIGNIN_RATE_LIMIT_MAX, }, - envProviderKeys: envProviderKeysFrom(parsed), - envProviderBaseUrls: envProviderBaseUrlsFrom(parsed), - envCredentialPlantAdmin: { - email: parsed.HUB_ADMIN_EMAIL ?? DEFAULT_PLANT_ADMIN_EMAIL, - password: parsed.HUB_ADMIN_PASSWORD ?? DEFAULT_PLANT_ADMIN_PASSWORD, - orgSlug: defaultTenantSlug, - }, chatIdleReapMs: parsePositiveMsEnv( parsed.WORKBENCH_CHAT_IDLE_REAP_MS, "WORKBENCH_CHAT_IDLE_REAP_MS", diff --git a/apps/hub/src/env-credential-plant.ts b/apps/hub/src/env-credential-plant.ts deleted file mode 100644 index 19bce4778..000000000 --- a/apps/hub/src/env-credential-plant.ts +++ /dev/null @@ -1,294 +0,0 @@ -// CL-6101: schedules the env-key auto-plant at hub boot. The actual -// planting is `@workbench/onboarding`'s `plantEnvProviderCredentials` -// (reused, not reimplemented); this module's only job is finding the -// tenant it should run against and running it without ever blocking or -// failing hub boot. -// -// The target tenant is resolved the same way `workbench setup` / -// `workbench seed` resolve it: sign in as the administrator -// (HUB_ADMIN_EMAIL/PASSWORD, defaulted the same way those commands -// default them) and look up the bench named ORG_SLUG among that -// account's own memberships. On a virgin database neither the admin -// account nor that bench exist yet — first signup (or `bun run -// dev`'s own account seeding) creates them, often as a separate -// process, well after this hub has already started serving. Rather -// than fail hub boot over a bench that legitimately doesn't exist yet, -// an unresolved target is retried with backoff until it resolves; the -// retry itself is logged only once per distinct failure reason, never -// on every tick. -// -// Sign-in only, never sign-up: unlike `workbench setup`/`workbench -// seed`, this runs unattended at every hub boot, including against a -// virgin, open-signup database. Falling through to sign-up the way -// those interactive commands do would let a boot-time retry tick mint -// the default admin account (and its default password) on its own — -// so this module authenticates with `@corbits/hub-api-client`'s -// sign-in-only `signIn`, and an unresolved admin account is treated -// exactly like an unresolved bench: retried quietly, never a reason to -// self-provision the account. -// -// Runs entirely in-process against the fully composed, guarded app's -// own `fetch` — no network hop, no dependency on `Bun.serve` already -// listening on a real port. This is the same "call the hub's own HTTP -// API as a typed client" idiom `selfApi` (this file's caller, for -// `@workbench/access-policy`'s routes) already uses, just wired to an -// in-process request entry point instead of a real origin so the very -// first attempt can run before the process is accepting connections. - -import { getLogger } from "@intx/log"; -import { paginatedSchema, PrincipalSummary } from "@intx/types"; -import { - signIn, - createHubAPI, - parseAs, - type ApiCall, - type Session, -} from "@corbits/hub-api-client"; -import { - plantEnvProviderCredentials, - type PlantEnvProviderCredentialsOutcome, -} from "@workbench/onboarding"; -import type { HubConfig } from "./config"; - -const DEFAULT_RETRY_INTERVAL_MS = 10_000; -const DEFAULT_MAX_RETRY_INTERVAL_MS = 5 * 60_000; -const DEFAULT_GIVE_UP_AFTER_MS = 24 * 60 * 60_000; - -const log = getLogger(["hub", "env-credential-plant"]); - -export type EnvCredentialPlantDeps = { - baseUrl: string; - envProviderKeys: HubConfig["envProviderKeys"]; - envProviderBaseUrls: HubConfig["envProviderBaseUrls"]; - admin: HubConfig["envCredentialPlantAdmin"]; - /** The fully composed, guarded app's own request entry point. */ - fetch: (request: Request) => Promise; - retryIntervalMs?: number; - /** Backoff cap; the retry interval doubles each unresolved tick up to - * this ceiling. Defaults to 5 minutes. */ - maxRetryIntervalMs?: number; - /** Total time to keep retrying an unresolved target before giving up - * for good. Defaults to 24 hours. */ - giveUpAfterMs?: number; - /** Test seam: a fake replaces the real `plantEnvProviderCredentials` - * the same way `seedCatalogFn` replaces the real `seedCatalog` - * elsewhere in this codebase, so this module's own retry-until- - * resolved scheduling is testable without a live probe or a real - * catalog plant. */ - plant?: typeof plantEnvProviderCredentials; -}; - -function localFetchImpl( - entry: (request: Request) => Promise, -): typeof fetch { - return ((input, init) => - entry(new Request(input as string, init as RequestInit))) as typeof fetch; -} - -type TenantLookup = - | { outcome: "resolved"; tenantId: string } - | { outcome: "unauthorized" } - | { outcome: "not-found" }; - -async function resolveOperatorTenantId( - api: ApiCall, - cookies: string[], - orgSlug: string, -): Promise { - const response = await api("GET", "/api/me/principals", undefined, cookies); - if (response.status === 401) return { outcome: "unauthorized" }; - if (response.status !== 200) return { outcome: "not-found" }; - const summary = parseAs( - paginatedSchema(PrincipalSummary), - response.data, - "principals response", - ); - const tenantId = summary.data.find((p) => p.tenantSlug === orgSlug)?.tenantId; - return tenantId !== undefined - ? { outcome: "resolved", tenantId } - : { outcome: "not-found" }; -} - -type ResolveResult = - | { status: "resolved"; tenantId: string; session: Session } - /** The session used to look up the bench is still good — just no - * matching bench yet. Worth caching: the next tick should retry only - * the lookup, not re-authenticate. */ - | { status: "unresolved"; session: Session }; - -/** - * Runs one bench-resolution pass against a possibly-cached session: - * reuse `session` when given (retrying only the bench lookup, never - * re-authenticating on every tick), re-authenticating exactly once when - * the cached cookie has gone stale (a 401 from the bench lookup). Lets - * `signIn` itself throw uncaught — an admin account that does not exist - * yet (or a password mismatch) is a distinct, un-cacheable failure the - * caller must not paper over with a stale session. - */ -async function resolveWithSession( - api: ApiCall, - deps: EnvCredentialPlantDeps, - cachedSession: Session | undefined, -): Promise { - const session = cachedSession ?? (await signIn(api, deps.admin)); - const lookup = await resolveOperatorTenantId( - api, - session.cookies, - deps.admin.orgSlug, - ); - - if (lookup.outcome === "resolved") { - return { status: "resolved", tenantId: lookup.tenantId, session }; - } - if (lookup.outcome === "not-found") { - return { status: "unresolved", session }; - } - - // The cached cookie is stale — re-authenticate once and retry the - // lookup with the fresh session, rather than waiting for the next - // scheduled tick. - const freshSession = await signIn(api, deps.admin); - const retried = await resolveOperatorTenantId( - api, - freshSession.cookies, - deps.admin.orgSlug, - ); - return retried.outcome === "resolved" - ? { status: "resolved", tenantId: retried.tenantId, session: freshSession } - : { status: "unresolved", session: freshSession }; -} - -type AttemptResult = - | { - status: "ran"; - outcomes: readonly PlantEnvProviderCredentialsOutcome[]; - session: Session; - } - | { status: "unresolved"; session: Session }; - -async function attemptPlant( - deps: EnvCredentialPlantDeps, - cachedSession: Session | undefined, -): Promise { - const api = createHubAPI(deps.baseUrl, localFetchImpl(deps.fetch)); - const resolved = await resolveWithSession(api, deps, cachedSession); - if (resolved.status === "unresolved") { - return resolved; - } - const plant = deps.plant ?? plantEnvProviderCredentials; - const outcomes = await plant({ - api, - cookies: resolved.session.cookies, - tenantId: resolved.tenantId, - envProviderKeys: deps.envProviderKeys, - envProviderBaseUrls: deps.envProviderBaseUrls, - log: (line) => log.info`${line}`, - }); - return { status: "ran", outcomes, session: resolved.session }; -} - -function failureReason(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - -/** - * Fires the first plant attempt immediately (never blocking the - * caller — the returned handle resolves independently of hub boot) and - * keeps retrying, with exponential backoff up to `maxRetryIntervalMs`, - * until the operator bench resolves and a plant actually runs with - * nothing left `"blocked"`. A blocked outcome (a proven env key that - * could not be stored because a stale credential of the same name is in - * the way) is not treated as done — a later retry, after the operator - * clears the stale row, can still succeed. Every other outcome — - * planted, skipped, or a probe failure — is terminal for this run: a bad - * key is not a reason to keep polling. - * - * An unresolved target that never resolves stops retrying after - * `giveUpAfterMs` (default 24h), logging exactly one error-level line. - * The authenticated session is cached across ticks and only - * re-established on a 401, so a long retry window does not mint a fresh - * session (or a fresh row) every ten seconds; the cached session is - * dropped once the run reaches a terminal state, letting its cookie - * lapse rather than keeping it alive with no further use. - * - * A no-op with no scheduled retry when no provider env key is set at - * all, so a hub boot with nothing to plant never even attempts to sign - * in. - */ -export function scheduleEnvProviderCredentialPlant( - deps: EnvCredentialPlantDeps, -): { stop: () => void } { - if (Object.keys(deps.envProviderKeys).length === 0) { - return { stop: () => {} }; - } - - const intervalMs = deps.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS; - const maxIntervalMs = - deps.maxRetryIntervalMs ?? DEFAULT_MAX_RETRY_INTERVAL_MS; - const giveUpAfterMs = deps.giveUpAfterMs ?? DEFAULT_GIVE_UP_AFTER_MS; - - let stopped = false; - let timer: ReturnType | undefined; - let currentIntervalMs = intervalMs; - let lastLoggedReason: string | undefined; - let session: Session | undefined; - const startedAt = Date.now(); - - function scheduleRetry(): void { - if (stopped) return; - if (Date.now() - startedAt >= giveUpAfterMs) { - log.error`env credential plant: giving up after ${Math.round(giveUpAfterMs / 3_600_000)}h with the operator bench still unresolved (last reason: ${lastLoggedReason ?? "unknown"})`; - session = undefined; - return; - } - timer = setTimeout(() => void attempt(), currentIntervalMs); - if (typeof timer.unref === "function") timer.unref(); - currentIntervalMs = Math.min(currentIntervalMs * 2, maxIntervalMs); - } - - function logUnresolved(reason: string): void { - if (reason === lastLoggedReason) return; - const level = lastLoggedReason === undefined ? "info" : "error"; - const message = `env credential plant: not ready yet (${reason}); will keep retrying with backoff up to ${Math.round(maxIntervalMs / 1000)}s`; - if (level === "error") log.error`${message}`; - else log.info`${message}`; - lastLoggedReason = reason; - } - - async function attempt(): Promise { - if (stopped) return; - try { - const result = await attemptPlant(deps, session); - if (result.status === "unresolved") { - session = result.session; - logUnresolved( - `operator bench "${deps.admin.orgSlug}" does not exist yet (or ${deps.admin.email} is not a member of it)`, - ); - scheduleRetry(); - return; - } - const stillBlocked = result.outcomes.some((o) => o.status === "blocked"); - if (!stillBlocked) { - // A run happened with nothing left blocked — done. - session = undefined; - return; - } - session = result.session; - scheduleRetry(); - } catch (cause) { - session = undefined; - logUnresolved(failureReason(cause)); - scheduleRetry(); - } - } - - void attempt(); - - return { - stop(): void { - stopped = true; - session = undefined; - if (timer !== undefined) clearTimeout(timer); - }, - }; -} diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 71a978e96..51b6889ad 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -369,7 +369,6 @@ import { type SidecarProvisionerConfig, } from "./config"; import type { SidecarProvisioner } from "@intx/hub-sessions"; -import { scheduleEnvProviderCredentialPlant } from "./env-credential-plant"; import { withTurnPartWriteDefaults } from "./turn-part-content-default"; import { createBootAssetWiring, REGISTRIES } from "./asset-service-factory"; import { @@ -3592,18 +3591,6 @@ export async function createHub(config: HubConfig) { const inFlight = createInFlightRequestTracker(); const servingApp = withInFlightRequestTracking(guardedApp, inFlight); - // Env-key auto-plant (CL-6101): runs in-process against the app this - // function is about to return, so it needs nothing more than that - // app's own `fetch` — see ./env-credential-plant.ts. A no-op when no - // curated provider key is set in this process's environment. - const envCredentialPlant = scheduleEnvProviderCredentialPlant({ - baseUrl: config.baseUrl, - envProviderKeys: config.envProviderKeys, - envProviderBaseUrls: config.envProviderBaseUrls, - admin: config.envCredentialPlantAdmin, - fetch: (request) => Promise.resolve(servingApp.fetch(request)), - }); - return { app: servingApp, whenRequestsIdle: () => inFlight.whenIdle(), @@ -3629,7 +3616,6 @@ export async function createHub(config: HubConfig) { // intermittent test timeout. relaunchSweepSeries += 1; clearTimeout(relaunchSweepTimer); - envCredentialPlant.stop(); chatOrchestrator.dispose(); workflowScheduler.stop(); credentialExpirySweep.stop(); diff --git a/apps/hub/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts index 895356121..044364222 100644 --- a/apps/hub/test/boot-does-not-seed.test.ts +++ b/apps/hub/test/boot-does-not-seed.test.ts @@ -178,13 +178,6 @@ describeIfDb("createHub on a scratch database inserts no tenant", () => { allowPlaintextSecrets: true, allowUnverifiedEmails: true, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, chatIdleReapMs: 30 * 60_000, }; const hub = await createHub(config); @@ -205,3 +198,203 @@ describeIfDb("createHub on a scratch database inserts no tenant", () => { }); }, 60_000); }); + +// CL-7579: hub boot must never plant provider credentials from +// environment variables. Operators connect providers through the +// onboarding/connect flow instead, so a hub booted with curated +// provider env vars set (e.g. OLLAMA_BASE_URL) inserts zero credential +// rows and zero catalog offerings — verified against a local fake +// Ollama whose probe the old env-plant would have passed. +describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { + function fakeOllama(): { url: string; stop: () => void } { + const server = Bun.serve({ + port: 0, + fetch: (request) => { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/api/tags") { + return Response.json({ + models: [{ name: "qwen3.8:27b", model: "qwen3.8:27b" }], + }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { + url: `http://localhost:${server.port}`, + stop: () => server.stop(true), + }; + } + + async function countRows(url: string, table: string): Promise { + const sql = postgres(url, { max: 1, onnotice: () => undefined }); + try { + const rows = await sql<{ count: number }[]>` + select count(*)::int as count from ${sql(table)} + `; + return rows[0]?.count ?? 0; + } finally { + await sql.end(); + } + } + + /** Fails fast once the old plant's rows appear; resolves quietly when + * the plant never fires, giving its boot-time retry a real window. */ + async function expectZeroRowsFor(url: string, waitMs: number): Promise { + const deadline = Date.now() + waitMs; + while (Date.now() < deadline) { + const credentials = await countRows(url, "credential"); + const offerings = await countRows(url, "offering"); + if (credentials > 0 || offerings > 0) { + throw new Error( + `boot planted state: ${credentials} credential row(s), ${offerings} offering row(s)`, + ); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } + + async function genesisOwner(baseUrl: string): Promise { + const signUp = await fetch(`${baseUrl}/api/auth/sign-up/email`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-real-ip": `198.51.100.${Math.floor(Math.random() * 200) + 10}`, + }, + body: JSON.stringify({ + name: "Alice", + email: ALICE.email, + password: ALICE.password, + }), + }); + expect(signUp.status).toBe(200); + const cookies = signUp.headers.getSetCookie(); + expect(cookies.length).toBeGreaterThan(0); + const provision = await fetch(`${baseUrl}/api/onboarding/provision`, { + method: "POST", + headers: { + "content-type": "application/json", + cookie: cookies.join("; "), + }, + body: JSON.stringify({ name: "Workbench" }), + }); + expect(provision.status).toBe(200); + } + + test("process boot with OLLAMA_BASE_URL set inserts no credential or offering rows", async () => { + await withScratchDatabase("boot_env_key_no_plant", async (url) => { + // Phase 1: mint the operator identity (signup genesis) so the old + // plant's target — admin sign-in + root tenant by slug — resolves + // immediately on the second boot. Without it the plant would sit + // in its retry loop and the test would prove nothing. + const genesis = await hop("hub process boot", async () => + startHub({ + databaseUrl: url, + port: freePort(), + sessionSecret: Buffer.from( + crypto.getRandomValues(new Uint8Array(32)), + ).toString("hex"), + dataDir: await tempDir("hub-boot-env-plant-genesis-"), + }), + ); + await genesisOwner(genesis.baseUrl); + await genesis.stop(); + + // Phase 2: reboot with a provider env var set, aimed at a fake + // Ollama whose probe would have succeeded. + const ollama = fakeOllama(); + try { + const hub = await hop( + "hub process boot with OLLAMA_BASE_URL", + async () => + startHub({ + databaseUrl: url, + port: freePort(), + sessionSecret: Buffer.from( + crypto.getRandomValues(new Uint8Array(32)), + ).toString("hex"), + dataDir: await tempDir("hub-boot-env-plant-"), + extraEnv: { OLLAMA_BASE_URL: ollama.url }, + }), + ); + track(hub); + await expectZeroRowsFor(url, 15_000); + } finally { + ollama.stop(); + } + }); + }, 90_000); + + test("createHub with a resolved operator bench plants no credential rows", async () => { + await withScratchDatabase("create_hub_env_key_no_plant", async (url) => { + const root = mkdtempSync(path.join(tmpdir(), "hub-createhub-plant-")); + const staticDir = path.join(root, "static"); + mkdirSync(staticDir, { recursive: true }); + writeFileSync(path.join(staticDir, "index.html"), "shell"); + mkdirSync(path.join(root, "data"), { recursive: true }); + + // Onboarding routes reach the hub over HTTP, so the composed app + // must be served on a real port and `baseUrl` must name it. + const server = Bun.serve({ + port: 0, + fetch: () => new Response("booting", { status: 503 }), + }); + + const baseConfig: HubConfig = { + databaseUrl: url, + baseUrl: `http://localhost:${server.port}`, + sessionSecret: "insecure-test-only-session-secret-0000", + hubDataDir: path.join(root, "data"), + hubStaticDir: staticDir, + defaultTenantSlug: "workbench", + signupRateLimit: { windowSeconds: 60, max: 5 }, + signInRateLimit: { windowSeconds: 60, max: 10 }, + socialProviders: {}, + signupMode: "closed", + allowedEmailDomains: [], + allowPlaintextSecrets: true, + allowUnverifiedEmails: true, + sidecarProvisioners: [], + chatIdleReapMs: 30 * 60_000, + }; + + // Phase 1: mint the operator bench the old plant targeted. + const genesis = await createHub(baseConfig); + server.reload({ fetch: genesis.app.fetch }); + const signUp = await genesis.app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { + "content-type": "application/json", + "x-real-ip": "198.51.100.77", + }, + body: JSON.stringify({ + name: "Alice", + email: ALICE.email, + password: ALICE.password, + }), + }); + expect(signUp.status).toBe(200); + const provision = await genesis.app.request("/api/onboarding/provision", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: signUp.headers.getSetCookie().join("; "), + }, + body: JSON.stringify({ name: "Workbench" }), + }); + expect(provision.status).toBe(200); + await genesis.close(); + + const hub = await createHub({ + ...baseConfig, + hubDataDir: path.join(root, "data-2"), + }); + server.reload({ fetch: hub.app.fetch }); + closers.push(async () => { + server.stop(true); + await hub.close(); + rmSync(root, { recursive: true, force: true }); + }); + await expectZeroRowsFor(url, 15_000); + }); + }, 90_000); +}); diff --git a/apps/hub/test/chat-mount.test.ts b/apps/hub/test/chat-mount.test.ts index fb9379944..734026eb5 100644 --- a/apps/hub/test/chat-mount.test.ts +++ b/apps/hub/test/chat-mount.test.ts @@ -40,13 +40,6 @@ const config: HubConfig = { allowPlaintextSecrets: true, allowUnverifiedEmails: true, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 84b65b5b2..95435477f 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -42,13 +42,6 @@ const config: HubConfig = { allowPlaintextSecrets: true, allowUnverifiedEmails: true, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index 1d510bc91..bc524ea43 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -38,75 +38,39 @@ describe("readHubConfig", () => { allowUnverifiedEmails: false, sidecarProvisioners: [{ id: "process" }], defaultSidecarProvisionerId: "process", - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, chatIdleReapMs: 30 * 60_000, }); }); - describe("envProviderKeys", () => { - test("empty when no provider key env vars are set", () => { - expect(readHubConfig(validEnv).envProviderKeys).toEqual({}); - }); - - test("collects every curated provider's key under its conventional env var", () => { - const config = readHubConfig({ - ...validEnv, - ANTHROPIC_API_KEY: "sk-ant-test", - OPENROUTER_API_KEY: "sk-or-test", - }); - expect(config.envProviderKeys).toEqual({ - anthropic: "sk-ant-test", - openrouter: "sk-or-test", - }); - }); - - test("GEMINI_API_KEY wins over GOOGLE_API_KEY for google-genai", () => { - const config = readHubConfig({ - ...validEnv, - GEMINI_API_KEY: "gemini-key", - GOOGLE_API_KEY: "google-key", - }); - expect(config.envProviderKeys["google-genai"]).toBe("gemini-key"); - }); - - test("OLLAMA_BASE_URL plants the fixed placeholder secret and its own base URL", () => { - const config = readHubConfig({ - ...validEnv, - OLLAMA_BASE_URL: "http://localhost:11434", - }); - expect(config.envProviderKeys.ollama).toBe("ollama"); - expect(config.envProviderBaseUrls.ollama).toBe("http://localhost:11434"); - }); - }); - - describe("envCredentialPlantAdmin", () => { - test("defaults to the same identity workbench setup/seed use", () => { - expect(readHubConfig(validEnv).envCredentialPlantAdmin).toEqual({ - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }); - }); - - test("honors HUB_ADMIN_EMAIL/HUB_ADMIN_PASSWORD/ORG_SLUG when set", () => { - const config = readHubConfig({ - ...validEnv, - HUB_ADMIN_EMAIL: "owner@acme.example", - HUB_ADMIN_PASSWORD: "correct-horse-battery", - ORG_SLUG: "acme", - }); - expect(config.envCredentialPlantAdmin).toEqual({ - email: "owner@acme.example", - password: "correct-horse-battery", - orgSlug: "acme", - }); - }); + test("provider env vars are no longer configuration", () => { + const config = readHubConfig({ + ...validEnv, + ANTHROPIC_API_KEY: "sk-ant-test", + OPENAI_API_KEY: "sk-oai-test", + OLLAMA_BASE_URL: "http://localhost:11434", + HUB_ADMIN_EMAIL: "owner@acme.example", + HUB_ADMIN_PASSWORD: "correct-horse-battery", + }); + expect(Object.keys(config).sort()).toEqual( + [ + "allowPlaintextSecrets", + "allowUnverifiedEmails", + "allowedEmailDomains", + "baseUrl", + "chatIdleReapMs", + "databaseUrl", + "defaultSidecarProvisionerId", + "defaultTenantSlug", + "hubDataDir", + "hubStaticDir", + "sessionSecret", + "signInRateLimit", + "signupMode", + "signupRateLimit", + "socialProviders", + "sidecarProvisioners", + ].sort(), + ); }); describe("social providers", () => { @@ -180,7 +144,6 @@ describe("readHubConfig", () => { test("ORG_SLUG aliases WORKBENCH_DEFAULT_TENANT when the latter is unset", () => { const config = readHubConfig({ ...validEnv, ORG_SLUG: "acme" }); expect(config.defaultTenantSlug).toBe("acme"); - expect(config.envCredentialPlantAdmin.orgSlug).toBe("acme"); }); test("WORKBENCH_DEFAULT_TENANT wins over ORG_SLUG when both are set", () => { @@ -190,7 +153,6 @@ describe("readHubConfig", () => { ORG_SLUG: "acme", }); expect(config.defaultTenantSlug).toBe("root"); - expect(config.envCredentialPlantAdmin.orgSlug).toBe("root"); }); test("OPERATOR_TENANT_ID fails loudly with an actionable message", () => { diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index e03eb9bca..547fd0c31 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -27,13 +27,6 @@ const baseConfig: HubConfig = { allowPlaintextSecrets: false, allowUnverifiedEmails: false, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/env-credential-plant.test.ts b/apps/hub/test/env-credential-plant.test.ts deleted file mode 100644 index 98acc18e1..000000000 --- a/apps/hub/test/env-credential-plant.test.ts +++ /dev/null @@ -1,429 +0,0 @@ -// The retry-until-resolved scheduling `scheduleEnvProviderCredentialPlant` -// owns: finding the operator bench (sign in as the admin, resolve its -// slug) and, once found, running the plant exactly once. The actual -// planting is `@workbench/onboarding`'s own, thoroughly tested -// `plantEnvProviderCredentials` — these tests fake it out (the `plant` -// seam) so they only prove this module's own job: sign in (never sign -// up), resolve, then hand off, retrying quietly when the bench does not -// exist yet. - -import { afterEach, describe, expect, test } from "bun:test"; -import { scheduleEnvProviderCredentialPlant } from "../src/env-credential-plant.ts"; - -const BASE_URL = "http://hub.test"; -const ADMIN = { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", -}; - -type FakeState = { - principals: { tenantId: string; tenantSlug: string; principalId: string }[]; - signInOk: boolean; - /** Flips true only if the fake ever answers a sign-in call with a - * fresh cookie value, so a re-auth-on-401 test can tell a cached - * session apart from a freshly minted one. */ - signInCount: number; - signUpCalled: boolean; - /** When true, `/api/me/principals` answers 401 once (simulating a - * stale/expired session cookie) before answering normally. */ - unauthorizeOnce: boolean; -}; - -function principalsBody(rows: FakeState["principals"]) { - return { - data: rows.map((r) => ({ - principalId: r.principalId, - tenantId: r.tenantId, - tenantName: r.tenantSlug, - tenantSlug: r.tenantSlug, - kind: "user", - status: "active", - roles: [], - })), - nextCursor: null, - }; -} - -function json(body: unknown, status = 200, cookie = "session=abc"): Response { - return new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json", "set-cookie": cookie }, - }); -} - -function fakeFetch(state: FakeState): (request: Request) => Promise { - return async (request) => { - const url = new URL(request.url); - if (url.pathname === "/api/auth/sign-in/email") { - if (!state.signInOk) return json({ error: "invalid_credentials" }, 401); - state.signInCount += 1; - return json( - { user: { id: "usr_admin" } }, - 200, - `session=abc${state.signInCount}`, - ); - } - // Answering 200 here (instead of throwing on an "unexpected path") - // is deliberate: a fake that throws on sign-up hides the bug this - // suite exists to catch — a sign-in-only implementation must never - // reach this branch at all, proven by `signUpCalled` below, not by - // this fake refusing to answer. - if (url.pathname === "/api/auth/sign-up/email") { - state.signUpCalled = true; - return json({ user: { id: "usr_admin_signed_up" } }); - } - if (url.pathname === "/api/me/principals") { - if (state.unauthorizeOnce) { - state.unauthorizeOnce = false; - return json({ error: "unauthorized" }, 401); - } - return json(principalsBody(state.principals)); - } - throw new Error(`fake fetch: unexpected path ${url.pathname}`); - }; -} - -const handles: { stop: () => void }[] = []; -afterEach(() => { - for (const handle of handles) handle.stop(); - handles.length = 0; -}); - -describe("scheduleEnvProviderCredentialPlant", () => { - test("no provider keys: never calls fetch or plant", async () => { - let fetchCalled = false; - let plantCalled = false; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: {}, - envProviderBaseUrls: {}, - admin: ADMIN, - fetch: async () => { - fetchCalled = true; - return new Response("{}", { status: 200 }); - }, - plant: async () => { - plantCalled = true; - return []; - }, - }); - handles.push(handle); - - await new Promise((resolve) => setTimeout(resolve, 10)); - expect(fetchCalled).toBe(false); - expect(plantCalled).toBe(false); - }); - - test("the operator bench already exists: plants once immediately, no retries", async () => { - const state: FakeState = { - signInOk: true, - signInCount: 0, - signUpCalled: false, - unauthorizeOnce: false, - principals: [ - { - tenantId: "ten_operator", - tenantSlug: "workbench", - principalId: "prn_admin", - }, - ], - }; - let plantCalls = 0; - let seenTenantId: string | undefined; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: { anthropic: "sk-ant-test" }, - envProviderBaseUrls: {}, - admin: ADMIN, - retryIntervalMs: 10, - fetch: fakeFetch(state), - plant: async (args: { tenantId: string }) => { - plantCalls += 1; - seenTenantId = args.tenantId; - return [{ provider: "anthropic" as const, status: "planted" as const }]; - }, - }); - handles.push(handle); - - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(plantCalls).toBe(1); - expect(seenTenantId).toBe("ten_operator"); - expect(state.signUpCalled).toBe(false); - - // No further retries once a run has happened, even after the retry - // interval elapses again. - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(plantCalls).toBe(1); - }); - - test("retries until the operator bench is provisioned, then plants exactly once", async () => { - const state: FakeState = { - signInOk: true, - signInCount: 0, - signUpCalled: false, - unauthorizeOnce: false, - principals: [], - }; - let plantCalls = 0; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: { anthropic: "sk-ant-test" }, - envProviderBaseUrls: {}, - admin: ADMIN, - retryIntervalMs: 10, - maxRetryIntervalMs: 10, - fetch: fakeFetch(state), - plant: async () => { - plantCalls += 1; - return [{ provider: "anthropic" as const, status: "planted" as const }]; - }, - }); - handles.push(handle); - - // Give it a couple of retry ticks with no bench yet. - await new Promise((resolve) => setTimeout(resolve, 35)); - expect(plantCalls).toBe(0); - - // The bench now exists — as it would once `workbench setup` runs. - state.principals = [ - { - tenantId: "ten_operator", - tenantSlug: "workbench", - principalId: "prn_admin", - }, - ]; - - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(plantCalls).toBe(1); - expect(state.signUpCalled).toBe(false); - }); - - test("a sign-in failure is retried quietly and never falls through to sign-up", async () => { - const state: FakeState = { - signInOk: false, - signInCount: 0, - signUpCalled: false, - unauthorizeOnce: false, - principals: [], - }; - let plantCalls = 0; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: { anthropic: "sk-ant-test" }, - envProviderBaseUrls: {}, - admin: ADMIN, - retryIntervalMs: 10, - maxRetryIntervalMs: 10, - fetch: fakeFetch(state), - plant: async () => { - plantCalls += 1; - return []; - }, - }); - handles.push(handle); - - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(plantCalls).toBe(0); - // The critical assertion: on an open-signup virgin database, a - // sign-in-failure retry tick must never mint the default admin - // account by falling through to sign-up. - expect(state.signUpCalled).toBe(false); - }); - - test("caches the session across ticks: only one sign-in for repeated unresolved-bench retries", async () => { - const state: FakeState = { - signInOk: true, - signInCount: 0, - signUpCalled: false, - unauthorizeOnce: false, - principals: [], - }; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: { anthropic: "sk-ant-test" }, - envProviderBaseUrls: {}, - admin: ADMIN, - retryIntervalMs: 10, - maxRetryIntervalMs: 10, - fetch: fakeFetch(state), - plant: async () => [ - { provider: "anthropic" as const, status: "planted" as const }, - ], - }); - handles.push(handle); - - await new Promise((resolve) => setTimeout(resolve, 45)); - // Several ticks elapsed with the bench still unresolved — the bench - // lookup itself never fails here (it just returns no match), so the - // session should have been authenticated exactly once and reused. - expect(state.signInCount).toBe(1); - }); - - test("re-authenticates once when the cached session goes stale (401), inline within the same tick", async () => { - const state: FakeState = { - signInOk: true, - signInCount: 0, - signUpCalled: false, - unauthorizeOnce: false, - principals: [ - { - tenantId: "ten_operator", - tenantSlug: "workbench", - principalId: "prn_admin", - }, - ], - }; - let plantCalls = 0; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: { anthropic: "sk-ant-test" }, - envProviderBaseUrls: {}, - admin: ADMIN, - retryIntervalMs: 60, - maxRetryIntervalMs: 60, - fetch: fakeFetch(state), - // Stays unresolved ("blocked") every tick so the session survives - // across ticks instead of the run going terminal after one pass. - plant: async () => { - plantCalls += 1; - return [{ provider: "anthropic" as const, status: "blocked" as const }]; - }, - }); - handles.push(handle); - - // First tick (immediate): fresh sign-in, session cached. - await new Promise((resolve) => setTimeout(resolve, 15)); - expect(plantCalls).toBe(1); - expect(state.signInCount).toBe(1); - - // Simulate the cached cookie going stale for the next lookup — the - // module must re-authenticate once, inline, and still complete the - // tick's plant, rather than waiting out a full retry interval. The - // wide 60ms retry interval leaves a comfortable margin so exactly - // one more tick fires in this window. - state.unauthorizeOnce = true; - await new Promise((resolve) => setTimeout(resolve, 70)); - expect(plantCalls).toBe(2); - expect(state.signInCount).toBe(2); - expect(state.signUpCalled).toBe(false); - }); - - test("a blocked outcome keeps retrying instead of stopping as done", async () => { - const state: FakeState = { - signInOk: true, - signInCount: 0, - signUpCalled: false, - unauthorizeOnce: false, - principals: [ - { - tenantId: "ten_operator", - tenantSlug: "workbench", - principalId: "prn_admin", - }, - ], - }; - let plantCalls = 0; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: { anthropic: "sk-ant-test" }, - envProviderBaseUrls: {}, - admin: ADMIN, - retryIntervalMs: 10, - maxRetryIntervalMs: 10, - fetch: fakeFetch(state), - plant: async () => { - plantCalls += 1; - return [{ provider: "anthropic" as const, status: "blocked" as const }]; - }, - }); - handles.push(handle); - - await new Promise((resolve) => setTimeout(resolve, 45)); - expect(plantCalls).toBeGreaterThan(1); - }); - - test("gives up after giveUpAfterMs, logging once, and stops retrying", async () => { - const state: FakeState = { - signInOk: true, - signInCount: 0, - signUpCalled: false, - unauthorizeOnce: false, - principals: [], - }; - let plantCalls = 0; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: { anthropic: "sk-ant-test" }, - envProviderBaseUrls: {}, - admin: ADMIN, - retryIntervalMs: 5, - maxRetryIntervalMs: 5, - giveUpAfterMs: 20, - fetch: fakeFetch(state), - plant: async () => { - plantCalls += 1; - return [{ provider: "anthropic" as const, status: "planted" as const }]; - }, - }); - handles.push(handle); - - await new Promise((resolve) => setTimeout(resolve, 60)); - const plantCallsAtGiveUp = plantCalls; - expect(plantCallsAtGiveUp).toBe(0); - - state.principals = [ - { - tenantId: "ten_operator", - tenantSlug: "workbench", - principalId: "prn_admin", - }, - ]; - await new Promise((resolve) => setTimeout(resolve, 40)); - // Given up already — a bench that shows up afterward is never - // picked up. - expect(plantCalls).toBe(0); - }); - - test("stop() cancels a pending retry", async () => { - // No bench yet, so the (already in-flight) first attempt fails and - // schedules a retry. stop() must cancel that scheduled retry — even - // though a bench shows up moments later, nothing ever picks it up. - const state: FakeState = { - signInOk: true, - signInCount: 0, - signUpCalled: false, - unauthorizeOnce: false, - principals: [], - }; - let plantCalls = 0; - const handle = scheduleEnvProviderCredentialPlant({ - baseUrl: BASE_URL, - envProviderKeys: { anthropic: "sk-ant-test" }, - envProviderBaseUrls: {}, - admin: ADMIN, - retryIntervalMs: 10, - fetch: fakeFetch(state), - plant: async () => { - plantCalls += 1; - return [{ provider: "anthropic" as const, status: "planted" as const }]; - }, - }); - - // Let the first (failing) attempt actually run and schedule its - // retry before stopping — stopping mid-flight on the very first - // attempt can race the fake fetch's own state read, which is not - // what this test is about. - await new Promise((resolve) => setTimeout(resolve, 5)); - handle.stop(); - state.principals = [ - { - tenantId: "ten_operator", - tenantSlug: "workbench", - principalId: "prn_admin", - }, - ]; - await new Promise((resolve) => setTimeout(resolve, 40)); - expect(plantCalls).toBe(0); - }); -}); diff --git a/apps/hub/test/eval-runs-mount.test.ts b/apps/hub/test/eval-runs-mount.test.ts index c65618406..0ac28ff63 100644 --- a/apps/hub/test/eval-runs-mount.test.ts +++ b/apps/hub/test/eval-runs-mount.test.ts @@ -39,13 +39,6 @@ const config: HubConfig = { allowPlaintextSecrets: true, allowUnverifiedEmails: true, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/presence-mount.test.ts b/apps/hub/test/presence-mount.test.ts index 7f745f859..f83adda75 100644 --- a/apps/hub/test/presence-mount.test.ts +++ b/apps/hub/test/presence-mount.test.ts @@ -38,13 +38,6 @@ const config: HubConfig = { allowPlaintextSecrets: true, allowUnverifiedEmails: true, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, chatIdleReapMs: 30 * 60_000, }; diff --git a/apps/hub/test/relaunch-sweep-close.test.ts b/apps/hub/test/relaunch-sweep-close.test.ts index 5aa76cf37..899160170 100644 --- a/apps/hub/test/relaunch-sweep-close.test.ts +++ b/apps/hub/test/relaunch-sweep-close.test.ts @@ -40,14 +40,7 @@ const config: HubConfig = { socialProviders: {}, allowUnverifiedEmails: true, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, chatIdleReapMs: 30 * 60_000, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, signupMode: "closed", allowedEmailDomains: [], allowPlaintextSecrets: true, diff --git a/apps/hub/test/signup-genesis.test.ts b/apps/hub/test/signup-genesis.test.ts index 371647551..cf465c3b8 100644 --- a/apps/hub/test/signup-genesis.test.ts +++ b/apps/hub/test/signup-genesis.test.ts @@ -115,13 +115,6 @@ async function bootEmptyHub(args: { allowPlaintextSecrets: true, allowUnverifiedEmails: true, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, - envCredentialPlantAdmin: { - email: "boot-admin@example.com", - password: "password123", - orgSlug: "workbench", - }, chatIdleReapMs: 30 * 60_000, }; const hub = await createHub(config); diff --git a/apps/hub/test/slack-tag-mount.test.ts b/apps/hub/test/slack-tag-mount.test.ts index c0036922b..a612be0c9 100644 --- a/apps/hub/test/slack-tag-mount.test.ts +++ b/apps/hub/test/slack-tag-mount.test.ts @@ -39,14 +39,7 @@ const config: HubConfig = { socialProviders: {}, allowUnverifiedEmails: true, sidecarProvisioners: [], - envProviderKeys: {}, - envProviderBaseUrls: {}, chatIdleReapMs: 30 * 60_000, - envCredentialPlantAdmin: { - email: "alice@example.com", - password: "password123", - orgSlug: "workbench", - }, signupMode: "closed", allowedEmailDomains: [], allowPlaintextSecrets: true, diff --git a/docs/TENANCY.md b/docs/TENANCY.md index 7906ddcbb..7a9f71ee2 100644 --- a/docs/TENANCY.md +++ b/docs/TENANCY.md @@ -28,8 +28,8 @@ walks the chain on every read. The default tenant slug is a deployment fact, not a boot insert. An empty database is a valid hub: boot mints no root. First signup -creates the root with this slug, and the env-key auto-plant resolves -the same slug once that tenant exists: +creates the root with this slug, and `workbench setup`/`seed` resolve +the same slug: 1. `WORKBENCH_DEFAULT_TENANT` if set 2. else `ORG_SLUG` (alias) @@ -37,8 +37,7 @@ the same slug once that tenant exists: Set only one. Custom-slug upgrades whose existing root is not `workbench` must set `WORKBENCH_DEFAULT_TENANT=` -before the next genesis or plant — otherwise first signup mints a -`workbench` root. Leftover +— otherwise first signup mints a `workbench` root. Leftover `OPERATOR_TENANT_ID` is no longer read: `readHubConfig` fails loudly and tells the operator to set `WORKBENCH_DEFAULT_TENANT` (or remove the stale key for the default slug). diff --git a/docs/local-dev.md b/docs/local-dev.md index be0dd8a80..fd2a775e8 100644 --- a/docs/local-dev.md +++ b/docs/local-dev.md @@ -4,11 +4,12 @@ Workbench can run with no cloud LLM key at all, using a local [Ollama](https://ollama.com) instance as the inference provider. Set `OLLAMA_BASE_URL` in `.env` to the origin your Ollama instance listens -on (e.g. `http://localhost:11434`, or a tailscale-tunneled origin) — see -`apps/hub/src/config.ts`. Unlike every other provider, Ollama needs no key: -its mere presence auto-plants a probed catalog credential on the operator -bench at hub start. Each provider gets its own URL/key card in onboarding; -Ollama's is the one that asks for a base URL instead of a token. +on (e.g. `http://localhost:11434`, or a tailscale-tunneled origin) — +`bun run dev`'s memory setup still reads it (it mounts the `@corbits/memory` +plane against the same origin when `EMBED_BASE_URL` is unset). The hub +itself no longer reads provider env vars at all: connect the Ollama +provider in the UI instead — its onboarding card is the one that asks for +a base URL instead of a token, and it needs no key. Tool-heavy turns (anything that calls `mcp_list_tools`, dispatches a task, or chains several tool calls) take noticeably longer — minutes, not diff --git a/docs/local-rip.md b/docs/local-rip.md index be51b773b..5a6cd277b 100644 --- a/docs/local-rip.md +++ b/docs/local-rip.md @@ -43,10 +43,11 @@ UI, seeds the administrator account and root tenant, and starts the hub and the web build. An empty database is a valid hub: boot does not insert agents, tools, workflows, or skills (see [README.md](../README.md#running-locally) for exactly what it checks). -Leave `ANTHROPIC_API_KEY` unset in `.env` for -this walkthrough — the point is proving a bench with no hub-owned seed +No provider API key is needed in +`.env` for this walkthrough — the hub never reads provider env vars — +the point is proving a bench with no hub-owned seed model gets fully seeded through a person's own connected credential, not -through the operator's key. +through an operator's key. Once `bun run dev` reports the hub and web server up, open `http://localhost:3000` (or whatever `BASE_URL`/`PORT` you set in `.env`). @@ -70,7 +71,7 @@ workbench" name form calls `POST /api/onboarding/provision` with that name, which mints your personal bench through the platform's native tenant-creation route. (An occupied hub joins you to its root as a plain member instead — `kind: "existing-member"` naming the tenant you joined, -no wizard.) With no `ANTHROPIC_API_KEY` configured, a minted bench is +no wizard.) With no hub-owned credential, a minted bench is unseeded (`seeded: false`, with a `seedSkipReason` naming why) — the UI keeps you on the credential step rather than pretending you're done. diff --git a/docs/seed-reconciliation.md b/docs/seed-reconciliation.md index 422029a23..0344df3b5 100644 --- a/docs/seed-reconciliation.md +++ b/docs/seed-reconciliation.md @@ -206,18 +206,23 @@ made once every required connection reads satisfied. On success the entry moves out of Available into the ordinary scheduled/deployed list the rest of the page already reads. -## Env provider credentials (hub boot) - -`apps/hub/src/env-credential-plant.ts` delegates to -`plantEnvProviderCredentials` (`packages/onboarding`): keyed by the +## Env provider credentials + +The hub's env-key auto-plant was removed (CL-7579): hub boot never reads +provider credentials from the environment. Connect a provider in the UI +(or call the same connect API a granted setup step uses) — that flow +plants via `persistConnectorCredential` and then runs `seedCatalog`. +`plantEnvProviderCredentials` (`packages/onboarding`) is kept as a +library for a future granted setup step; nothing in production calls it +today. Its semantics, should a setup step adopt it: keyed by the provider's stable credential name, a provider already carrying an active credential is not probed and its key is not overwritten — a rotated or hand-renamed key is never touched. `seedCatalog` still -runs against that existing credential (`existingCredentialId`, no -`apiKey`) so a hub restart backfills newly curated models additively: +runs against an existing credential (`existingCredentialId`, no +`apiKey`) so a re-connect backfills newly curated models additively: missing rows are planted, existing ones 409-skip, nothing is deleted. -Removing an env var never deletes the planted credential: credentials -are operator data once planted, not seeds to garbage-collect. +Credentials are operator data once planted, not seeds to +garbage-collect. ## Credential-bound catalog workflows (CL-7073) diff --git a/packages/onboarding/src/plant-env-credentials.ts b/packages/onboarding/src/plant-env-credentials.ts index 1a4d2f495..d4f3d189e 100644 --- a/packages/onboarding/src/plant-env-credentials.ts +++ b/packages/onboarding/src/plant-env-credentials.ts @@ -3,11 +3,9 @@ // `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 the hub's own -// process was started with — so an operator who sets ANTHROPIC_API_KEY -// (or any other curated provider's key) in the hub's environment gets a -// launchable catalog the moment the hub boots, with no `workbench seed` -// re-run and no manual step. +// 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 @@ -16,10 +14,9 @@ // 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 on every hub boot, and folding -// 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, and must never stop the hub itself from starting. +// 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, diff --git a/packages/seeding/src/seed.ts b/packages/seeding/src/seed.ts index ec7c86ee9..7097e38de 100644 --- a/packages/seeding/src/seed.ts +++ b/packages/seeding/src/seed.ts @@ -2276,7 +2276,7 @@ export async function seedCatalog( } else { log( `catalog models for ${seed.provider.name} seeded without a credential; ` + - `no workbench or workflow can launch against them until a ${seed.provider.name} API key is set — set it in the hub's own environment and restart (the env-key auto-plant, CL-6101, then plants it with no other step), or set it here and re-run: workbench seed`, + `no workbench or workflow can launch against them until a ${seed.provider.name} API key is connected — connect one in the UI's provider step, or set it here and re-run: workbench seed`, ); return { hasCompletionCapableModel: hasCompletionCapableModel( diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 8654b1c98..bddc16b2d 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -21,7 +21,6 @@ # drift as unrelated code around a catch changes. apps/hub/src/bench-session.ts 1 const message = cause instanceof Error ? cause.message : String(cause); apps/hub/src/credential-expiry-sweep.ts 1 log.error`credential expiry sweep failed: ${ -apps/hub/src/env-credential-plant.ts 1 session = undefined; apps/hub/src/grant-allowance.ts 1 return null; apps/hub/src/index.ts 1 email = ""; apps/hub/src/index.ts 1 email = undefined;