diff --git a/apps/hub/src/asset-service-factory.test.ts b/apps/hub/src/asset-service-factory.test.ts deleted file mode 100644 index 0fba5c0e5d..0000000000 --- a/apps/hub/src/asset-service-factory.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync } from "node:fs"; -import path from "node:path"; -import { cleanupGitFixtures, gitFixture, scratchGitFixtureDir } from "../test/git-fixture"; -import { createBootAssetWiring } from "./asset-service-factory"; -import { HubDataDirInsideGitWorkTreeError } from "./hub-data-dir-git-safety"; - -afterAll(cleanupGitFixtures); - -test("createBootAssetWiring refuses a data dir inside an existing git work tree", async () => { - const workTree = scratchGitFixtureDir("boot-asset-inside-"); - gitFixture(workTree, ["init", "-b", "main"]); - const dataDir = path.join(workTree, ".data", "hub"); - mkdirSync(dataDir, { recursive: true }); - await expect(createBootAssetWiring({ db: {} as never, dataDir })).rejects.toBeInstanceOf( - HubDataDirInsideGitWorkTreeError, - ); -}); - -test("createBootAssetWiring opt-in skips the work-tree refusal", async () => { - const workTree = scratchGitFixtureDir("boot-asset-opt-in-"); - gitFixture(workTree, ["init", "-b", "main"]); - const dataDir = path.join(workTree, ".data", "hub"); - mkdirSync(dataDir, { recursive: true }); - try { - await createBootAssetWiring({ - db: {} as never, - dataDir, - allowGitInsideWorkTree: true, - }); - } catch (error) { - expect(error).not.toBeInstanceOf(HubDataDirInsideGitWorkTreeError); - } -}); diff --git a/apps/hub/src/asset-service-factory.ts b/apps/hub/src/asset-service-factory.ts deleted file mode 100644 index 939991e82e..0000000000 --- a/apps/hub/src/asset-service-factory.ts +++ /dev/null @@ -1,54 +0,0 @@ -// The hub's asset-service composition, factored into a typed factory -// (CL-6404) so it has exactly two callers and one implementation: hub -// boot (`index.ts`) and the eval harness (`scripts/evals-run.ts`), -// which needs to read a scratch hub's agent-repo assets — the same -// data dir, the same store construction — to capture world snapshots. -// Never a parallel implementation: this IS the boot wiring. -import { - createAgentRepoStore, - createAssetService, - type AgentRepoStore, - type AssetService, -} from "@intx/hub-sessions"; -import { generateKeyPair } from "@intx/crypto"; -import type { DB } from "@intx/db"; -import { assertHubDataDirGitSafety } from "./hub-data-dir-git-safety"; - -// Host policy constant, not configuration — shared by boot (tool-package -// registry routing) and asset-service construction. -export const REGISTRIES = new Map([["npmjs", { url: "https://registry.npmjs.org" }]]); - -export interface BootAssetWiring { - readonly signingKey: Awaited>; - readonly agentRepoStore: AgentRepoStore; - readonly assetService: AssetService; -} - -/** - * Builds the signing key, agent-repo store, and asset service the hub - * boots with, over `dataDir`. A second caller pointing at a running - * hub's own data dir gets a read-equivalent `AssetService` over the - * same on-disk repos (the fresh signing key only ever signs writes, - * which a snapshot reader never performs). - */ -export async function createBootAssetWiring(args: { - db: DB["db"]; - dataDir: string; - allowGitInsideWorkTree?: boolean; -}): Promise { - assertHubDataDirGitSafety( - args.dataDir, - args.allowGitInsideWorkTree === true ? { allowInsideWorkTree: true } : {}, - ); - const signingKey = await generateKeyPair(); - const agentRepoStore = createAgentRepoStore({ - dataDir: args.dataDir, - signingKey, - }); - const assetService = createAssetService({ - db: args.db, - repoStore: agentRepoStore.repoStore, - reservedPackageRegistryNames: new Set(REGISTRIES.keys()), - }); - return { signingKey, agentRepoStore, assetService }; -} diff --git a/apps/hub/src/config.ts b/apps/hub/src/config.ts index e844c2f2ab..afee380a27 100644 --- a/apps/hub/src/config.ts +++ b/apps/hub/src/config.ts @@ -23,6 +23,71 @@ // paste-a-token provider card. import { type } from "arktype"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, realpathSync } from "node:fs"; +import path from "node:path"; + +// Refuse to initialize hub git-on-disk state inside an existing git work +// tree. isomorphic-git walks up from a data dir looking for `.git`; when +// HUB_DATA_DIR lives inside this repo (the `.env` default `.data/hub`), a +// missed nested init authors a genesis commit onto the working branch and a +// checkout of that tree can delete the checkout (see AGENTS.md). +const GIT_DIR_VARS = new Set([ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", +]); + +function envWithoutGitDir(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const next: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(env)) { + if (value === undefined || GIT_DIR_VARS.has(key)) continue; + next[key] = value; + } + return next; +} + +function enclosingGitWorkTree(dir: string, env: NodeJS.ProcessEnv): string | null { + const absolute = path.resolve(dir); + mkdirSync(absolute, { recursive: true }); + const canonical = realpathSync(absolute); + const result = spawnSync( + "git", + ["-c", "safe.directory=*", "-C", canonical, "rev-parse", "--show-toplevel"], + { encoding: "utf8", env: envWithoutGitDir(env) }, + ); + if (result.status !== 0) return null; + const toplevel = result.stdout.trim(); + return toplevel === "" ? null : realpathSync(toplevel); +} + +/** + * Called at hub boot (`index.ts`), never from `readHubConfig` itself: + * this does real filesystem/subprocess IO against `dir`, which would + * make every test parsing a config trip it against whatever `.data/hub`- + * shaped path a fixture happens to use. + */ +export function assertHubDataDirGitSafety( + dir: string, + allowInsideWorkTree: boolean, + env: NodeJS.ProcessEnv, +): void { + if (allowInsideWorkTree) return; + const enclosing = enclosingGitWorkTree(dir, env); + if (enclosing === null) return; + throw new Error( + [ + `HUB_DATA_DIR (${dir}) is inside the git work tree at ${enclosing}.`, + "The hub refuses to initialize git-on-disk state there: a nested init", + "that misses its own .git walks up and commits onto the working", + "branch, which can delete the checkout.", + "Point HUB_DATA_DIR at a directory outside any git work tree, or set", + "HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 to opt in.", + ].join(" "), + ); +} const HTTP_URL = /^https?:\/\/.+$/; @@ -62,12 +127,6 @@ const HubEnv = type({ "SIGNUP_RATE_LIMIT_MAX?": type(/^[1-9]\d*$/).describe( "the maximum sign-ups a single IP may make per window, e.g. 5", ), - "SIGNIN_RATE_LIMIT_WINDOW_SECONDS?": type(/^[1-9]\d*$/).describe( - "the per-account sign-in rate-limit window, in seconds, e.g. 60; overrides better-auth's built-in 10-second/3-attempt default, which is too tight for a person retyping a password", - ), - "SIGNIN_RATE_LIMIT_MAX?": type(/^[1-9]\d*$/).describe( - "the maximum failed sign-in attempts a single account may accrue per window before further failures are rejected, e.g. 10 — keyed on the target email, not client IP (see sign-in-rate-limit.ts); a correct password always succeeds regardless of this budget", - ), "GOOGLE_CLIENT_ID?": type("string > 0").describe( "Google OAuth client id; set together with GOOGLE_CLIENT_SECRET to enable Google sign-in", ), @@ -141,20 +200,6 @@ const HubEnv = type({ const DEFAULT_SIGNUP_RATE_LIMIT_WINDOW_SECONDS = 60; const DEFAULT_SIGNUP_RATE_LIMIT_MAX = 5; -// better-auth's own built-in special rule for /sign-in* is 3 attempts per -// 10 seconds, keyed per client IP -- too tight for a bucket that can end up -// shared (CL-6494): when the IP can't be resolved, or is forged, every -// signed-out visitor, or an attacker replaying the same forged header, can -// starve it. index.ts now disables that built-in rule for sign-in entirely -// (see sign-in-rate-limit.ts) and uses these knobs to configure its -// account-keyed replacement instead, applied the same in every environment -// rather than branched on NODE_ENV (this file already rejects inferring -// auth behavior from NODE_ENV — see `rateLimit.enabled` below). This gives -// real users room to mistype a password, and a lone local developer room -// to keep working even while no IP can be resolved, without loosening -// brute-force resistance. -const DEFAULT_SIGNIN_RATE_LIMIT_WINDOW_SECONDS = 60; -const DEFAULT_SIGNIN_RATE_LIMIT_MAX = 10; // One member per implemented `SidecarProvisioner` backend. Adding a new // backend (e.g. a remote sandbox) is: implement the contract in its own @@ -222,10 +267,6 @@ export type HubConfig = { readonly windowSeconds: number; readonly max: number; }; - readonly signInRateLimit: { - readonly windowSeconds: number; - readonly max: number; - }; readonly socialProviders: Readonly>>; readonly huggingfaceOAuthClientId?: string; readonly githubAppClientId?: string; @@ -477,14 +518,6 @@ export function readHubConfig(env: Record): HubConfi ? Number(parsed.SIGNUP_RATE_LIMIT_MAX) : DEFAULT_SIGNUP_RATE_LIMIT_MAX, }, - signInRateLimit: { - windowSeconds: parsed.SIGNIN_RATE_LIMIT_WINDOW_SECONDS - ? Number(parsed.SIGNIN_RATE_LIMIT_WINDOW_SECONDS) - : DEFAULT_SIGNIN_RATE_LIMIT_WINDOW_SECONDS, - max: parsed.SIGNIN_RATE_LIMIT_MAX - ? Number(parsed.SIGNIN_RATE_LIMIT_MAX) - : DEFAULT_SIGNIN_RATE_LIMIT_MAX, - }, }; if (parsed.HUB_ALLOW_GIT_INSIDE_WORK_TREE !== undefined) hubConfig.allowGitInsideWorkTree = true; if (parsed.PORT !== undefined) hubConfig.listenPort = Number(parsed.PORT); diff --git a/apps/hub/src/credential-material-refresh.test.ts b/apps/hub/src/credential-material-refresh.test.ts deleted file mode 100644 index 03198f9136..0000000000 --- a/apps/hub/src/credential-material-refresh.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - createServingRefresh, - type ServingRefreshGrantArgs, - type ServingRefreshStore, -} from "./credential-material-refresh"; - -// Times sit just outside/inside the 60s skew lead so "not expiring" and -// "expiring now" are unambiguous. -const NOW = 1_000_000_000; -const LATER = NOW + 10 * 60 * 1000; - -type Row = - Parameters extends [string] - ? Awaited> - : never; - -function store(initial: NonNullable) { - let row: NonNullable | null = initial; - const calls = { - refreshedTokens: [] as Awaited< - ReturnType - > extends never - ? never - : Parameters[1][], - marked: [] as string[], - pushes: [] as string[], - grantArgs: [] as ServingRefreshGrantArgs[], - }; - const s: ServingRefreshStore = { - loadRow: async () => row, - applyRefreshedTokens: async (id, tokens) => { - calls.refreshedTokens.push(tokens); - if (row !== null && row.id === id) { - row = { - ...row, - secret: `enc:${tokens.secret}`, - refreshSecret: - tokens.refreshSecret === undefined ? row.refreshSecret : `enc:${tokens.refreshSecret}`, - expiresAt: tokens.expiresAt, - status: "active", - }; - } - return true; - }, - markReauthRequired: async (id) => { - calls.marked.push(id); - if (row !== null && row.id === id) row = { ...row, status: "error" }; - }, - pushUpdates: async (tenantId) => { - calls.pushes.push(tenantId); - }, - decrypt: async (_id, _column, value) => `plain:${value}`, - loadDueRows: async () => [], - }; - return { store: s, row: () => row, calls }; -} - -function row(overrides: Partial> = {}) { - return { - id: "cred_1", - tenantId: "ten_1", - type: "oauth_token", - status: "active", - secret: "enc:access-1", - refreshSecret: "enc:refresh-1", - expiresAt: new Date(LATER), - providerName: "mcp:example", - apiBaseUrl: "https://mcp.example", - metadata: { url: "https://mcp.example" }, - ...overrides, - } as NonNullable; -} - -function harness( - initial: NonNullable, - grant?: (args: ServingRefreshGrantArgs) => Promise<{ - accessToken: string; - refreshToken?: string; - expiresIn?: number; - }>, -) { - const d = store(initial); - const refresh = createServingRefresh({ - store: d.store, - hubUrl: "https://hub.example", - ...(grant === undefined ? {} : { grant }), - now: () => NOW, - }); - return { ...d, refresh }; -} - -describe("createServingRefresh", () => { - test("an api_key credential passes through untouched — never refreshed, never pushed", async () => { - const h = harness(row({ type: "api_key", refreshSecret: null, expiresAt: null })); - const result = await h.refresh(servingOf(h)); - expect(result).toEqual({ ok: true }); - expect(h.calls.grantArgs).toHaveLength(0); - expect(h.calls.refreshedTokens).toHaveLength(0); - expect(h.calls.pushes).toHaveLength(0); - }); - - test("a well-before-expiry oauth_token is served without a refresh", async () => { - const h = harness(row()); - const result = await h.refresh(servingOf(h)); - expect(result).toEqual({ ok: true }); - expect(h.calls.refreshedTokens).toHaveLength(0); - expect(h.calls.pushes).toHaveLength(0); - }); - - test("an expiring oauth_token refreshes, persists, and pushes the updated frame", async () => { - const h = harness(row({ expiresAt: new Date(NOW + 1000) }), async (args) => ({ - accessToken: `fresh:${args.refreshToken}`, - refreshToken: "rotated-refresh", - expiresIn: 3600, - })); - const result = await h.refresh(servingOf(h)); - expect(result).toEqual({ ok: true }); - expect(h.calls.refreshedTokens).toHaveLength(1); - expect(h.calls.refreshedTokens[0]?.secret).toBe("fresh:plain:enc:refresh-1"); - expect(h.calls.refreshedTokens[0]?.refreshSecret).toBe("rotated-refresh"); - expect(h.calls.pushes).toEqual(["ten_1"]); - }); - - test("a failed refresh marks the credential re-auth-required, pushes, and skips the offering", async () => { - const h = harness(row({ expiresAt: new Date(NOW + 1000) }), async () => { - throw new Error("grant rejected"); - }); - const result = await h.refresh(servingOf(h)); - expect(result.ok).toBe(false); - expect(h.calls.marked).toEqual(["cred_1"]); - expect(h.row()?.status).toBe("error"); - expect(h.calls.pushes).toEqual(["ten_1"]); - // The marked credential is never retried: a second serving skips - // without another doomed grant. - const second = await h.refresh(servingOf(h, "error")); - expect(second.ok).toBe(false); - expect(h.calls.marked).toEqual(["cred_1"]); - }); - - test("a grant that states no expires_in persists a NULL expiry — the row never re-enters the due set", async () => { - const h = harness(row({ expiresAt: new Date(NOW + 1000) }), async (args) => ({ - accessToken: `fresh:${args.refreshToken}`, - refreshToken: "rotated-refresh", - // no expiresIn: provider stated no lifetime - })); - const result = await h.refresh(servingOf(h)); - expect(result).toEqual({ ok: true }); - expect(h.row()?.expiresAt).toBeNull(); - // Subsequent servings see a non-due row: no further grant, no push. - const again = await h.refresh(servingOf(h)); - expect(again).toEqual({ ok: true }); - expect(h.calls.refreshedTokens).toHaveLength(1); - expect(h.calls.pushes).toEqual(["ten_1"]); - }); - - test("a lost apply race (row claimed elsewhere) suppresses the push without erroring", async () => { - const d = store(row({ expiresAt: new Date(NOW + 1000) })); - d.store.applyRefreshedTokens = async () => false; - let grants = 0; - const refresh = createServingRefresh({ - store: d.store, - hubUrl: "https://hub.example", - grant: async () => { - grants += 1; - return { accessToken: "fresh", expiresIn: 3600 }; - }, - now: () => NOW, - }); - const result = await refresh(servingOf(d)); - expect(result).toEqual({ ok: true }); - expect(grants).toBe(1); - expect(d.calls.pushes).toEqual([]); - }); -}); - -function servingOf( - h: { - row: () => NonNullable>> | null; - }, - status: string = "active", -): Parameters>[0] { - const current = h.row(); - return { - id: current?.id ?? "cred_1", - tenantId: current?.tenantId ?? "ten_1", - type: current?.type ?? "oauth_token", - status, - refreshSecret: current?.refreshSecret ?? null, - expiresAt: current?.expiresAt ?? null, - }; -} diff --git a/apps/hub/src/credential-material-refresh.ts b/apps/hub/src/credential-material-refresh.ts deleted file mode 100644 index 4f5c4de08b..0000000000 --- a/apps/hub/src/credential-material-refresh.ts +++ /dev/null @@ -1,421 +0,0 @@ -// Serving-time credential refresh for inference `oauth_token` credentials -// (CL-7505). -// -// The live serving-time trigger is the chat platform's `sendMail` choke -// point: ahead of every send, `createTenantServingRefresh` refreshes each -// due `oauth_token` credential in the tenant — via the connections token -// session (`@corbits/connections`'s `createCredentialTokenSession`, over -// `@corbits/oauth-core`), coalesced and skew-aware — and the -// refreshed material reaches running deployments through the existing -// `credentials-updated` push (`pushSourceUpdates` → `sendCredentialsUpdate`). -// The existing MCP expiry sweep keeps running as the background backstop -// and reconnect-nudge path. -// -// The vendored material-resolution seam (`@intx/db`'s `buildSource`, -// threaded through `resolveModelSources` / `resolveInstanceModelSources` / -// `resolveDefinitionSources` / the hub-sessions push path as the recorded -// CL-7505 delta) also accepts this hook, but no production caller supplies -// it yet — it is reserved for launch-time resolution on the CL-7505 -// umbrella; do not cite it as a live trigger. -// -// On a failed refresh the credential is marked `status: "error"` — the -// Connections card reads any non-`active` status as needs-attention — and -// the hook reports `ok: false`, which makes the serving seam SKIP the -// offering: an unauthenticated dial is never served. The expiry sweep's -// mail nudge remains the human backstop for the same dead credential. -import { and, eq, isNotNull, lte } from "drizzle-orm"; -import { type } from "arktype"; -import { credential, provider } from "@intx/db/schema"; -import type { DB } from "@intx/db"; -import type { ServingRefresh } from "@intx/db"; -import { getLogger } from "@intx/log"; -import { - createCredentialTokenSession, - type CredentialTokenRow, - type RefreshedTokens, -} from "@corbits/connections/token-session"; -import { pushSourceUpdates, type SidecarRouter } from "@intx/hub-sessions"; -import { credentialAad, type CredentialCipher } from "@intx/types"; -import { mcpSlugOf, refreshMcpOAuthTokens } from "@corbits/connections"; -import { refreshCodexTokens } from "@corbits/codex-provider"; -import { refreshXaiTokens } from "@corbits/xai-provider"; -import type { - OAuthClientInformationMixed, - OAuthTokens, -} from "@modelcontextprotocol/sdk/shared/auth.js"; - -const log = getLogger(["hub", "credential-material-refresh"]); - -// Re-check this far ahead of the stored `expiresAt`: inside the lead the -// token is treated as expiring and refreshed, so a dial that starts now -// still lands on a live token after its round trip. Matches the token -// session's own default skew lead. -const SKEW_LEAD_MS = 60 * 1000; - -const CredentialMetadata = type({ - url: "string", - "name?": "string", - "clientInformation?": "unknown", -}); - -/** The codex credential's metadata slice: the `chatgpt_account_id` the - * connect flow derived from the id_token (CL-7508). */ -const CodexCredentialMetadata = type({ "accountId?": "string" }); - -function metadataAccountId(metadata: unknown): string | undefined { - const parsed = CodexCredentialMetadata(metadata); - return parsed instanceof type.errors ? undefined : parsed.accountId; -} - -/** The row shape the serving seam hands the hook (a `credential` row - * subset; `refreshSecret` still ciphertext). */ -export type ServingCredentialRow = { - readonly id: string; - readonly tenantId: string; - readonly type: string; - readonly status: string; - readonly refreshSecret: string | null; - readonly expiresAt: Date | null; -}; - -/** Everything the refresh needs from persistence and delivery, behind a - * store seam so the orchestration is testable without a live Postgres. */ -export type ServingRefreshStore = { - /** The full current row for a credential id (still-encrypted secrets), - * or null when it is gone. */ - loadRow(credentialId: string): Promise< - | (ServingCredentialRow & { - readonly secret: string; - readonly providerName: string; - readonly apiBaseUrl: string | null; - readonly metadata: unknown; - }) - | null - >; - /** Persists a successful refresh (encrypting the new pair) — only an - * `active` row is updated; false means it was claimed meanwhile. */ - applyRefreshedTokens(credentialId: string, tokens: RefreshedTokens): Promise; - /** Marks the credential re-auth-required: visible to the settings - * Connections card as needs-attention. */ - markReauthRequired(credentialId: string, reason: string): Promise; - /** Re-resolves and pushes `credentials-updated` frames for the tenant's - * running instances, so sidecars pick the new material up live. */ - pushUpdates(tenantId: string): Promise; - /** Decrypts a stored secret column for the grant. */ - decrypt(credentialId: string, column: "secret" | "refreshSecret", value: string): Promise; - /** Every `active` `oauth_token` credential in the tenant whose stored - * `expiresAt` is at or before `cutoff` — the tenant-wide serving-time - * sweep `refreshTenantServingCredentials` runs ahead of a dial. */ - loadDueRows(tenantId: string, cutoff: Date): Promise; -}; - -export type ServingRefreshGrantArgs = { - readonly credentialId: string; - readonly tenantId: string; - readonly providerName: string; - readonly serverUrl: string; - readonly accessToken: string; - readonly refreshToken: string; - readonly clientInformation?: OAuthClientInformationMixed; -}; - -export type ServingRefreshDeps = { - store: ServingRefreshStore; - hubUrl: string; - /** The provider-pluggable refresh grant. Defaults to the generalized - * MCP `auth()` refresh (any provider whose metadata carries the server - * URL / registered client); a different credential class supplies its - * own grant here without touching this module. */ - grant?: (args: ServingRefreshGrantArgs) => Promise<{ - accessToken: string; - refreshToken?: string; - /** Lifetime in seconds, per RFC 6749 §5.1 `expires_in`. */ - expiresIn?: number; - }>; - now?: () => number; -}; - -export function createServingRefresh(deps: ServingRefreshDeps): ServingRefresh { - const now = deps.now ?? Date.now; - const grant = - deps.grant ?? - (async (args: ServingRefreshGrantArgs) => { - const tokens: OAuthTokens = { - access_token: args.accessToken, - token_type: "bearer", - refresh_token: args.refreshToken, - }; - const result = await refreshMcpOAuthTokens({ - serverUrl: args.serverUrl, - tokens, - ...(args.clientInformation === undefined - ? {} - : { clientInformation: args.clientInformation }), - callbackUrl: new URL( - `/api/tenants/${args.tenantId}/mcp-servers/oauth/${mcpSlugOf(args.providerName)}/callback`, - deps.hubUrl, - ).toString(), - clientName: "Corbits Hub", - }); - if (!result.ok) throw new Error(result.message); - return { - accessToken: result.tokens.access_token, - ...(result.tokens.refresh_token === undefined - ? {} - : { refreshToken: result.tokens.refresh_token }), - ...(result.tokens.expires_in === undefined ? {} : { expiresIn: result.tokens.expires_in }), - }; - }); - - const sessions = new Map>(); - let lastApplyWon = true; - - function buildSession() { - return createCredentialTokenSession({ - now, - skewLeadMs: SKEW_LEAD_MS, - loadProfile: async (credentialId) => { - const row = await deps.store.loadRow(credentialId); - if (row === null) return null; - return { - id: row.id, - type: row.type, - secret: await deps.store.decrypt(row.id, "secret", row.secret), - refreshSecret: - row.refreshSecret === null - ? null - : await deps.store.decrypt(row.id, "refreshSecret", row.refreshSecret), - expiresAt: row.expiresAt, - } satisfies CredentialTokenRow; - }, - updateTokens: async (credentialId, tokens) => { - // False = another writer claimed the row (claimed-elsewhere): a - // lost race, not an error — skip the follow-up push and let the - // next resolution re-read whatever was persisted. - const applied = await deps.store.applyRefreshedTokens(credentialId, tokens); - lastApplyWon = applied; - }, - refresh: async (row) => { - const full = await deps.store.loadRow(row.id); - if (full === null || row.refreshSecret === null) { - throw new Error(`credential ${row.id} lost its refresh secret`); - } - // The two loopback-OAuth inference providers (CL-7508) refresh - // through their provider packages' own grants, not the generalized - // MCP `auth()` refresh: their endpoints, public client ids, and - // token shapes live in @corbits/{codex,xai}-provider, and the - // provider row's name is the connector id those packages key on. - if (full.providerName === "codex") { - const accountId = metadataAccountId(full.metadata); - const refreshed = await refreshCodexTokens(row.refreshSecret, now(), { - access: row.secret, - refresh: row.refreshSecret, - ...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt.getTime() }), - // The account id rides the credential's metadata (written at - // connect from the id_token); carry it forward so a refresh - // response without an id_token never drops chatgpt-account-id. - ...(accountId !== undefined ? { accountId } : {}), - }); - return { - secret: refreshed.access, - ...(refreshed.refresh === undefined ? {} : { refreshSecret: refreshed.refresh }), - expiresAt: refreshed.expiresAt === undefined ? null : new Date(refreshed.expiresAt), - }; - } - if (full.providerName === "xai-oauth") { - const refreshed = await refreshXaiTokens(row.refreshSecret, now()); - return { - secret: refreshed.access, - ...(refreshed.refresh === undefined ? {} : { refreshSecret: refreshed.refresh }), - expiresAt: refreshed.expiresAt === undefined ? null : new Date(refreshed.expiresAt), - }; - } - const parsed = CredentialMetadata(full.metadata ?? {}); - const serverUrl = - full.apiBaseUrl ?? (parsed instanceof type.errors ? undefined : parsed.url); - if (serverUrl === undefined) { - throw new Error( - `credential ${row.id} has neither a provider API base URL nor a metadata URL to refresh against`, - ); - } - const refreshed = await grant({ - credentialId: row.id, - tenantId: full.tenantId, - providerName: full.providerName, - serverUrl, - accessToken: row.secret, - refreshToken: row.refreshSecret, - ...(parsed instanceof type.errors || parsed.clientInformation === undefined - ? {} - : { - clientInformation: parsed.clientInformation as OAuthClientInformationMixed, - }), - }); - return { - secret: refreshed.accessToken, - ...(refreshed.refreshToken === undefined - ? {} - : { refreshSecret: refreshed.refreshToken }), - // No `expires_in` stated: persist a NULL expiry — non-due, per - // the oauth-core stance. Never a short artificial - // timer that would re-refresh on every dial. - expiresAt: - refreshed.expiresIn === undefined ? null : new Date(now() + refreshed.expiresIn * 1000), - }; - }, - }); - } - - return async (serving: ServingCredentialRow) => { - // Only `oauth_token` rows refresh; an `api_key` (or any other class) - // takes its exact prior path, untouched. - if (serving.type !== "oauth_token") return { ok: true }; - // An already-marked credential is past saving: skip it without another - // doomed grant, until a human reconnects. - if (serving.status !== "active") { - return { - ok: false, - message: `credential ${serving.id} is ${serving.status} — re-authorization required`, - }; - } - // No stored expiry (or no refresh grant stored): the column says the - // token is not expiring, so serve as before. - if (serving.expiresAt === null) return { ok: true }; - if (serving.expiresAt.getTime() - SKEW_LEAD_MS > now()) return { ok: true }; - - lastApplyWon = true; - let session = sessions.get(serving.id); - if (session === undefined) { - session = buildSession(); - sessions.set(serving.id, session); - } - const result = await session.getValidToken(serving.id); - if (result.ok) { - if (result.refreshed && lastApplyWon) { - // Deliver the fresh material to every running instance in the - // tenant via the existing credentials-updated push. A lost - // apply race (another writer claimed the row) already persisted - // someone else's tokens — skip the redundant push. - await deps.store.pushUpdates(serving.tenantId); - } - return { ok: true }; - } - if (result.reauthRequired) { - await deps.store.markReauthRequired(serving.id, result.message); - await deps.store.pushUpdates(serving.tenantId); - log.warn`credential ${serving.id} needs re-authorization: ${result.message}`; - } - return { ok: false, message: result.message }; - }; -} - -export function createDrizzleServingRefreshStore( - db: DB["db"], - credentialCipher: CredentialCipher, - sidecarRouter: SidecarRouter, -): ServingRefreshStore { - return { - async loadRow(credentialId) { - const rows = await db - .select({ - id: credential.id, - tenantId: credential.tenantId, - type: credential.type, - status: credential.status, - secret: credential.secret, - refreshSecret: credential.refreshSecret, - expiresAt: credential.expiresAt, - providerName: provider.name, - apiBaseUrl: provider.apiBaseUrl, - metadata: credential.metadata, - }) - .from(credential) - .innerJoin(provider, eq(provider.id, credential.providerId)) - .where(eq(credential.id, credentialId)) - .limit(1); - return rows[0] ?? null; - }, - async applyRefreshedTokens(credentialId, tokens) { - const now = new Date(); - const encryptedSecret = await credentialCipher.encrypt( - tokens.secret, - credentialAad(credentialId, "secret"), - ); - const encryptedRefreshSecret = - tokens.refreshSecret === undefined - ? undefined - : await credentialCipher.encrypt( - tokens.refreshSecret, - credentialAad(credentialId, "refreshSecret"), - ); - // Only an `active` row is updated: a row a racing writer already - // claimed as expired/re-auth-required must not be resurrected with - // fresh tokens — the false return is the caller's lost-race signal. - const updated = await db - .update(credential) - .set({ - secret: encryptedSecret, - ...(encryptedRefreshSecret !== undefined - ? { refreshSecret: encryptedRefreshSecret } - : {}), - expiresAt: tokens.expiresAt, - updatedAt: now, - }) - .where(and(eq(credential.id, credentialId), eq(credential.status, "active"))) - .returning({ id: credential.id }); - return updated.length > 0; - }, - async markReauthRequired(credentialId, _reason) { - await db - .update(credential) - .set({ - status: "error", - updatedAt: new Date(), - }) - .where(eq(credential.id, credentialId)); - }, - pushUpdates: (tenantId) => pushSourceUpdates(db, sidecarRouter, tenantId, credentialCipher), - decrypt: (credentialId, column, value) => - credentialCipher.decrypt(value, credentialAad(credentialId, column)), - async loadDueRows(tenantId, cutoff) { - return db - .select({ - id: credential.id, - tenantId: credential.tenantId, - type: credential.type, - status: credential.status, - refreshSecret: credential.refreshSecret, - expiresAt: credential.expiresAt, - }) - .from(credential) - .where( - and( - eq(credential.tenantId, tenantId), - eq(credential.type, "oauth_token"), - eq(credential.status, "active"), - isNotNull(credential.expiresAt), - lte(credential.expiresAt, cutoff), - ), - ); - }, - }; -} - -/** - * The tenant-wide serving-time trigger: refresh every due `oauth_token` - * credential in the tenant, then (per credential, inside the hook) push the - * updated frames. Runs best-effort ahead of a serving dial — the chat - * platform calls it at `sendMail` — never rejecting, so a refresh failure - * only marks the credential and lets the dial fail over to a live source. - */ -export function createTenantServingRefresh( - deps: ServingRefreshDeps & { skewLeadMs?: number }, -): (tenantId: string) => Promise { - const refresh = createServingRefresh(deps); - const skewLeadMs = deps.skewLeadMs ?? 60 * 1000; - const now = deps.now ?? Date.now; - return async (tenantId: string) => { - const due = await deps.store.loadDueRows(tenantId, new Date(now() + skewLeadMs)); - await Promise.all(due.map((row) => refresh(row))); - }; -} diff --git a/apps/hub/src/grant-allowance.ts b/apps/hub/src/grant-allowance.ts deleted file mode 100644 index 9c09dd9fdf..0000000000 --- a/apps/hub/src/grant-allowance.ts +++ /dev/null @@ -1,118 +0,0 @@ -// CL-6345: the composition-root bindings for `@corbits/approvals`' -// grant-allowance gate. Two ports live here because only the hub holds -// their ingredients: -// -// - `createMcpServerToolsAllowanceLoader` — the live `tools/list` -// read `@corbits/mcp-tools`' `mcp_call` classifier verifies a -// downstream tool's `readOnlyHint` claim against, built from the -// tenant's stored MCP connection (`@corbits/connections`) and its -// decrypted token, mirroring how `mcp-credential-bindings.ts` names -// the same connections for tool delivery. -// - `createAllowanceAutoApprover` — the native-resolve binding that -// flips a riding call's approval row "approved" with allowance -// (null-principal) authority through `@intx/hub-api`'s -// `resolveApproval`, so the ledgered decision and the resume signal -// both travel the exact machinery a human click would. -import { and, eq } from "drizzle-orm"; - -import type { DB } from "@intx/db"; -import { parseGrantRow, resolveProviderByName, schema } from "@intx/db"; -import type { CredentialCipher } from "@intx/types"; -import { credentialAad } from "@intx/types"; -import { resolveApproval, type CreateApprovalRoutesDeps } from "@intx/hub-api"; -import type { GrantRule } from "@intx/types/authz"; -import type { GrantAllowanceGateDeps } from "@corbits/approvals"; -import { listMcpTools, withMcpConnection, type McpServerToolsLoader } from "@corbits/mcp-tools"; -import { listMcpServerConnections } from "@corbits/connections"; -import { MCP_NO_TOKEN_SENTINEL } from "@corbits/credential-providers"; - -function bearerFetch(token: string | undefined): typeof fetch { - if (token === undefined || token.length === 0) return fetch; - return ((input, init) => { - const headers = new Headers(init?.headers); - headers.set("authorization", `Bearer ${token}`); - return fetch(input, { ...init, headers }); - }) as typeof fetch; -} - -export function createMcpServerToolsAllowanceLoader(deps: { - db: DB["db"]; - credentialCipher: CredentialCipher; -}): McpServerToolsLoader { - return async (tenantId, serverSlug) => { - const connections = await listMcpServerConnections(deps.db, tenantId); - const connection = connections.find((c) => c.slug === serverSlug); - if (connection === undefined || connection.url.length === 0) return null; - - const provider = await resolveProviderByName(deps.db, tenantId, `mcp:${serverSlug}`); - if (provider === null) return null; - const credential = await deps.db.query.credential.findFirst({ - where: and( - eq(schema.credential.providerId, provider.id), - eq(schema.credential.status, "active"), - ), - }); - if (credential === undefined) return null; - const secret = await deps.credentialCipher.decrypt( - credential.secret, - credentialAad(credential.id, "secret"), - ); - const token = secret === MCP_NO_TOKEN_SENTINEL ? undefined : secret; - try { - return await withMcpConnection( - { url: connection.url, fetchImpl: bearerFetch(token) }, - (client) => listMcpTools(client), - ); - } catch { - return null; - } - }; -} - -export function createTenantGrantLister(db: DB["db"]): GrantAllowanceGateDeps["listTenantGrants"] { - return async (tenantId) => { - const rows = await db.query.grant.findMany({ - where: eq(schema.grant.tenantId, tenantId), - }); - return rows.map((row): GrantRule => parseGrantRow(row)); - }; -} - -export function createRegisteredApprovalFinder( - db: DB["db"], -): GrantAllowanceGateDeps["findRegisteredApproval"] { - return async (correlationId) => { - const row = await db.query.approval.findFirst({ - where: and( - eq(schema.approval.correlationId, correlationId), - eq(schema.approval.status, "pending"), - ), - }); - if (row === undefined) return null; - return { approvalId: row.id, tenantId: row.tenantId }; - }; -} - -export function createAllowanceAutoApprover( - deps: CreateApprovalRoutesDeps, - log: (line: string) => void, -): GrantAllowanceGateDeps["autoApprove"] { - return async ({ approvalId, tenantId, resource, grantId }) => { - const result = await resolveApproval(deps, { - approvalId, - tenantId, - principalId: null, - status: "approved", - scope: "once", - decisionPayload: { - outcome: "approved", - message: `Auto-approved by grant allowance: read-only call covered by grant ${grantId} on ${resource}`, - }, - }); - if (result.kind !== "resolved") { - log(`grant-allowance: approval ${approvalId} auto-resolve returned "${result.kind}"`); - return false; - } - return true; - }; -} diff --git a/apps/hub/src/grant-conditions.ts b/apps/hub/src/grant-conditions.ts deleted file mode 100644 index bac60ca472..0000000000 --- a/apps/hub/src/grant-conditions.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { timeWindowEvaluator } from "@intx/authz"; -import type { ConditionRegistry } from "@intx/types/authz"; - -/** - * Grant conditions the hub evaluates on every mount that takes a - * `conditionRegistry`. Nothing here is chat-specific: `time_window` is - * `@intx/authz`'s own evaluator, matching the default `mountHubRoutes` - * builds when no registry is supplied. - */ -export const grantConditionRegistry: ConditionRegistry = { - time_window: timeWindowEvaluator, -}; diff --git a/apps/hub/src/hub-data-dir-git-safety.test.ts b/apps/hub/src/hub-data-dir-git-safety.test.ts deleted file mode 100644 index e496535216..0000000000 --- a/apps/hub/src/hub-data-dir-git-safety.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync, realpathSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { cleanupGitFixtures, gitFixture, scratchGitFixtureDir } from "../test/git-fixture"; -import { - assertHubDataDirGitSafety, - enclosingGitWorkTree, - HubDataDirInsideGitWorkTreeError, -} from "./hub-data-dir-git-safety"; - -afterAll(cleanupGitFixtures); - -test("a directory with no enclosing git work tree is allowed", () => { - const dir = scratchGitFixtureDir("hub-data-outside-"); - expect(enclosingGitWorkTree(dir)).toBeNull(); - expect(() => assertHubDataDirGitSafety(dir)).not.toThrow(); -}); - -test("a directory inside an existing git work tree fails loud", () => { - const workTree = scratchGitFixtureDir("hub-data-inside-wt-"); - gitFixture(workTree, ["init", "-b", "main"]); - writeFileSync(path.join(workTree, "keep.txt"), "keep\n"); - gitFixture(workTree, ["add", "keep.txt"]); - gitFixture(workTree, ["commit", "-m", "keep"]); - const dataDir = path.join(workTree, ".data", "hub"); - mkdirSync(dataDir, { recursive: true }); - - expect(enclosingGitWorkTree(dataDir)).toBe(realpathSync(workTree)); - expect(() => assertHubDataDirGitSafety(dataDir)).toThrow(HubDataDirInsideGitWorkTreeError); - try { - assertHubDataDirGitSafety(dataDir); - throw new Error("expected assertHubDataDirGitSafety to throw"); - } catch (error) { - expect(error).toBeInstanceOf(HubDataDirInsideGitWorkTreeError); - const message = (error as Error).message; - expect(message).toContain(realpathSync(dataDir)); - expect(message).toContain(realpathSync(workTree)); - expect(message).toContain("HUB_ALLOW_GIT_INSIDE_WORK_TREE=1"); - expect(message).toContain("refuses"); - } -}); - -test("opt-in allows initializing inside an existing git work tree", () => { - const workTree = scratchGitFixtureDir("hub-data-opt-in-"); - gitFixture(workTree, ["init", "-b", "main"]); - const dataDir = path.join(workTree, ".data", "hub"); - expect(() => assertHubDataDirGitSafety(dataDir, { allowInsideWorkTree: true })).not.toThrow(); -}); diff --git a/apps/hub/src/hub-data-dir-git-safety.ts b/apps/hub/src/hub-data-dir-git-safety.ts deleted file mode 100644 index ff0dcb7876..0000000000 --- a/apps/hub/src/hub-data-dir-git-safety.ts +++ /dev/null @@ -1,77 +0,0 @@ -// Refuse to initialize hub git-on-disk state inside an existing git work -// tree. isomorphic-git walks up from `dataDir` looking for `.git`; when -// HUB_DATA_DIR lives inside this repo (the `.env` default `.data/hub`), -// a missed nested init authors a genesis commit onto the working branch -// and a checkout of that tree can delete the checkout. -import { spawnSync } from "node:child_process"; -import { mkdirSync, realpathSync } from "node:fs"; -import path from "node:path"; - -const GIT_DIR_VARS = new Set([ - "GIT_DIR", - "GIT_WORK_TREE", - "GIT_INDEX_FILE", - "GIT_OBJECT_DIRECTORY", - "GIT_ALTERNATE_OBJECT_DIRECTORIES", -]); - -export function envWithoutGitDir(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { - const next: NodeJS.ProcessEnv = {}; - for (const [key, value] of Object.entries(env)) { - if (value === undefined) continue; - if (GIT_DIR_VARS.has(key)) continue; - next[key] = value; - } - return next; -} - -export function enclosingGitWorkTree( - dir: string, - env: NodeJS.ProcessEnv = process.env, -): string | null { - const absolute = path.resolve(dir); - mkdirSync(absolute, { recursive: true }); - const canonical = realpathSync(absolute); - const result = spawnSync( - "git", - ["-c", "safe.directory=*", "-C", canonical, "rev-parse", "--show-toplevel"], - { encoding: "utf8", env: envWithoutGitDir(env) }, - ); - if (result.status !== 0) return null; - const toplevel = result.stdout.trim(); - return toplevel === "" ? null : realpathSync(toplevel); -} - -export class HubDataDirInsideGitWorkTreeError extends Error { - readonly hubDataDir: string; - readonly enclosingWorkTree: string; - - constructor(hubDataDir: string, enclosingWorkTree: string) { - super( - [ - `HUB_DATA_DIR (${hubDataDir}) is inside the git work tree at ${enclosingWorkTree}.`, - "The hub refuses to initialize git-on-disk state there: a nested", - "init that misses its own .git walks up and commits onto the working", - "branch, which can delete the checkout.", - "Point HUB_DATA_DIR at a directory outside any git work tree, or set", - "HUB_ALLOW_GIT_INSIDE_WORK_TREE=1 to opt in.", - ].join(" "), - ); - this.name = "HubDataDirInsideGitWorkTreeError"; - this.hubDataDir = hubDataDir; - this.enclosingWorkTree = enclosingWorkTree; - } -} - -export function assertHubDataDirGitSafety( - dir: string, - opts?: { - allowInsideWorkTree?: boolean; - env?: NodeJS.ProcessEnv; - }, -): void { - if (opts?.allowInsideWorkTree === true) return; - const enclosing = enclosingGitWorkTree(dir, opts?.env ?? process.env); - if (enclosing === null) return; - throw new HubDataDirInsideGitWorkTreeError(realpathSync(path.resolve(dir)), enclosing); -} diff --git a/apps/hub/src/hub-error-handler.test.ts b/apps/hub/src/hub-error-handler.test.ts deleted file mode 100644 index 7aa11a80c2..0000000000 --- a/apps/hub/src/hub-error-handler.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// Exercises `hubErrorHandler` through a real Hono app rather than a -// hand-rolled `Context` double, so the assertions cover exactly what -// `app.onError` actually receives and returns. Errors are reported -// through `@corbits/error-sink`'s real `reportError`, captured the same -// way `packages/error-sink/src/index.test.ts` captures its own sink. -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { configureSync, resetSync } from "@intx/log"; - -import { hubErrorHandler } from "./hub-error-handler"; - -/** - * `hubErrorHandler` maps a guidance-bearing error by its string `.name` - * (duck-typed, see `hub-error-handler.ts`), never by `instanceof` — so this - * stands in for the real `InferenceResolutionError` (`@corbits/chat`'s - * `model-unavailable.ts`) without importing it. - */ -const MODEL_UNAVAILABLE_CONSUMER_MESSAGE = "This agent's model isn't available here."; - -class FakeInferenceResolutionError extends Error { - readonly guidance: string; - constructor(launchLabel: string, resolutionMessage: string) { - super( - `cannot resolve an inference source for ${launchLabel} ` + - `(${resolutionMessage}); seed a tenant catalog source (provider, ` + - `credential, catalog model/provider/offering) before launching`, - ); - this.name = "InferenceResolutionError"; - this.guidance = MODEL_UNAVAILABLE_CONSUMER_MESSAGE; - } -} - -let records: { properties: Record }[]; - -function installCapturingSink(): void { - records = []; - configureSync({ - reset: true, - sinks: { - capture: (record) => { - records.push(record as { properties: Record }); - }, - }, - loggers: [ - { category: ["errors"], sinks: ["capture"], lowestLevel: "debug" }, - { category: ["logtape", "meta"], sinks: [], lowestLevel: "warning" }, - ], - }); -} - -beforeEach(() => installCapturingSink()); -afterEach(() => resetSync()); - -describe("hubErrorHandler", () => { - test("reports the failing route through reportError and answers a generic 500 with a refId", async () => { - const app = new Hono(); - app.onError(hubErrorHandler()); - app.get("/boom", () => { - throw new Error("definition asset never materialized"); - }); - - const res = await app.request("/boom"); - - expect(res.status).toBe(500); - const body = (await res.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("internal_error"); - expect(body.error.userMessage).toBe("Something went wrong. Please try again."); - expect(typeof body.error.refId).toBe("string"); - expect(body.error.refId.length).toBeGreaterThan(0); - - expect(records).toHaveLength(1); - const properties = records[0]?.properties; - expect(properties?.operation).toBe("hub.unhandled_route_error"); - expect(properties?.refId).toBe(body.error.refId); - expect(properties?.extra).toEqual({ path: "/boom", method: "GET" }); - }); - - test("maps a guidance-bearing error to a 422 with its own message and a refId", async () => { - const app = new Hono(); - app.onError(hubErrorHandler()); - app.get("/launch", () => { - class NamedLaunchError extends Error { - readonly guidance = "Reduce it to a single step and try again."; - constructor() { - super("definition wfd_research is not single-step (2 steps)"); - this.name = "MultiStepFoldUnsupportedError"; - } - } - throw new NamedLaunchError(); - }); - - const res = await app.request("/launch"); - - expect(res.status).toBe(422); - const body = (await res.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("MultiStepFoldUnsupportedError"); - expect(body.error.userMessage).toBe("definition wfd_research is not single-step (2 steps)"); - expect(typeof body.error.refId).toBe("string"); - expect(body.error.refId.length).toBeGreaterThan(0); - expect(records).toHaveLength(1); - expect(records[0]?.properties.refId).toBe(body.error.refId); - }); - - test("maps InferenceResolutionError to 422 with consumer copy, not 500", async () => { - const app = new Hono(); - app.onError(hubErrorHandler()); - app.get("/wake", () => { - throw new FakeInferenceResolutionError( - "the woken instance", - 'No launchable inference source for model "claude-sonnet-5"', - ); - }); - - const res = await app.request("/wake"); - - expect(res.status).toBe(422); - const body = (await res.json()) as { - error: { code: string; userMessage: string; refId: string }; - }; - expect(body.error.code).toBe("InferenceResolutionError"); - expect(body.error.userMessage).toBe("This agent's model isn't available here."); - expect(body.error.userMessage).not.toContain("claude-sonnet-5"); - expect(body.error.userMessage).not.toMatch(/cannot resolve an inference/); - expect(typeof body.error.refId).toBe("string"); - }); -}); diff --git a/apps/hub/src/hub-error-handler.ts b/apps/hub/src/hub-error-handler.ts deleted file mode 100644 index a0423ec41d..0000000000 --- a/apps/hub/src/hub-error-handler.ts +++ /dev/null @@ -1,74 +0,0 @@ -// The hub's global `app.onError` handler. Without one, an exception that -// escapes a route (e.g. a routine "run now" whose definition fails to -// launch) falls through to Hono's built-in handler: a bare 500 with no -// logged trail, which is how a genuinely broken launch — a multi-step -// workflow's launch body failing to read, a definition asset that never -// materialized — stays invisible until someone reports it from the UI. -// This handler is the one place every such exception is guaranteed to be -// reported, and it maps a named consumer-facing error to a real 4xx rather -// than folding it into a generic message. -import type { Context } from "hono"; -import type { TenantEnv } from "@intx/hub-api"; -import { reportError } from "@corbits/error-sink"; -import { makeErrorEnvelope } from "@corbits/error-sink"; - -/** - * Duck-typed rather than an `instanceof` allowlist: any error carrying a - * string `guidance` alongside its `message` is a named, consumer-facing - * failure the thrower already wrote for a human to read — see - * `@corbits/workflows`'s `DefinitionProjectionMissingError` and - * `MultiStepFoldUnsupportedError`. Its `message` is safe to return - * verbatim; anything else is a platform fault whose internals must not - * leak to a client. - */ -function hasGuidance(err: unknown): err is Error & { guidance: string } { - return ( - err instanceof Error && - "guidance" in err && - typeof (err as { guidance: unknown }).guidance === "string" - ); -} - -/** - * `app.onError` runs for routes mounted both inside and outside the - * platform's tenant middleware, so `c`'s `Variables` aren't statically - * known here; this borrows the same `TenantEnv` a tenant-scoped route - * types `c.get("tenant")` with to read `tenant.id` when a tenant-scoped - * route set it, without claiming the wider type up front. - */ -function extractTenantId(c: Context): string | undefined { - return (c as unknown as Context).var.tenant?.id; -} - -/** Builds the handler passed to `app.onError`. */ -export function hubErrorHandler() { - return (err: unknown, c: Context): Response | Promise => { - const tenantId = extractTenantId(c); - const refId = reportError(err, { - operation: "hub.unhandled_route_error", - ...(tenantId !== undefined ? { tenantId } : {}), - extra: { path: c.req.path, method: c.req.method }, - }); - - if (hasGuidance(err)) { - const userMessage = err.name === "InferenceResolutionError" ? err.guidance : err.message; - return c.json( - makeErrorEnvelope({ - code: err.name, - userMessage, - refId, - }), - 422, - ); - } - - return c.json( - makeErrorEnvelope({ - code: "internal_error", - userMessage: "Something went wrong. Please try again.", - refId, - }), - 500, - ); - }; -} diff --git a/apps/hub/src/hub-zero-t4-registry.test.ts b/apps/hub/src/hub-zero-t4-registry.test.ts deleted file mode 100644 index 606d20b3f8..0000000000 --- a/apps/hub/src/hub-zero-t4-registry.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Hub-zero T4 (CL-8126) red test: the hub's connector surface and -// scheduled-delivery path cut over to native primitives, with zero import -// from the legacy template package. -// -// Part A (structural): `index.ts`, `pinned-package-credential-bindings.ts`, -// and `native-workflow-routine-launch.ts` carry no legacy template import, and the -// delivery join (settings-row read + run-participant join) is gone — -// scheduled runs launch without joining a chat. -// -// Part B (behavioral): the native registry adapter serves the same -// connector set and preset list the routes mount. -// -// Every needle below is built from concatenated fragments so this file -// itself never trips the `check:hub-workbench-routes` done-when scan. -import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import path from "node:path"; - -const HUB_SRC = path.join(import.meta.dir, "..", "..", "..", "apps", "hub", "src"); - -function readHub(rel: string): string { - return readFileSync(path.join(HUB_SRC, rel), "utf8"); -} - -const LEGACY_PKG = "@work" + "bench/templates"; -const LIST_SETTINGS = "list" + "WorkbenchSettings"; -const GET_SETTINGS = "get" + "WorkbenchSettings"; -const JOIN_DEPS = "Scheduled" + "DeliveryJoinDeps"; -const JOIN_FN = "joinScheduled" + "DefinitionToChat"; -const JOIN_PORT = "join" + "DeliveryChat"; -const JOIN_INPUT = "Join" + "RunParticipantInput"; -const COMMAND_GUARD = "workbench" + "BelongsToTenant"; - -describe("hub-zero T4: no legacy template import in the hub", () => { - for (const rel of [ - "index.ts", - "pinned-package-credential-bindings.ts", - "native-workflow-routine-launch.ts", - ]) { - test(`${rel} imports no legacy template package`, () => { - expect(readHub(rel).includes(LEGACY_PKG), `${rel} still imports ${LEGACY_PKG}`).toBe(false); - }); - } -}); - -describe("hub-zero T4: the scheduled delivery join is cut", () => { - test("no settings-row list read survives in the hub", () => { - for (const rel of ["index.ts", "native-workflow-routine-launch.ts"]) { - expect(readHub(rel).includes(LIST_SETTINGS), `${rel} still reads ${LIST_SETTINGS}`).toBe( - false, - ); - } - }); - - test("no delivery-join seam survives in the routine launch path", () => { - const source = readHub("native-workflow-routine-launch.ts"); - for (const needle of [JOIN_DEPS, JOIN_FN, JOIN_PORT, JOIN_INPUT]) { - expect( - source.includes(needle), - `native-workflow-routine-launch.ts still contains ${needle}`, - ).toBe(false); - } - }); - - test("no delivery-join seam survives in index.ts", () => { - const source = readHub("index.ts"); - for (const needle of [JOIN_DEPS, JOIN_FN, JOIN_PORT]) { - expect(source.includes(needle), `index.ts still contains ${needle}`).toBe(false); - } - }); - - test("the single get-variant read stays inside the command guard", () => { - // T5-owned: `createCommandRoutes`' membership check. List-only would - // strand it; the get-variant is the documented exception. - const source = readHub("index.ts"); - const occurrences = source.split(GET_SETTINGS).length - 1; - expect(occurrences).toBe(1); - expect(source.includes(COMMAND_GUARD)).toBe(true); - }); -}); - -describe("hub-zero T4: the native connector registry", () => { - test("serves the full connector set the routes mount", async () => { - const { CONNECTOR_REGISTRY } = await import("./native-connector-registry"); - const ids = new Set(Object.keys(CONNECTOR_REGISTRY)); - for (const id of [ - "anthropic", - "openai", - "google-genai", - "xai", - "codex", - "xai-oauth", - "groq", - "deepseek", - "mistral", - "openrouter", - "huggingface", - "ollama", - "granola", - "granola-webhook", - "manus", - "exa", - "scrapecreators", - "linear", - "gmail", - "github", - ]) { - expect(ids.has(id), `native registry is missing connector ${id}`).toBe(true); - } - }); - - test("every descriptor carries the shape the route factories read", async () => { - const { CONNECTOR_REGISTRY } = await import("./native-connector-registry"); - for (const [key, descriptor] of Object.entries(CONNECTOR_REGISTRY)) { - expect(descriptor.id, `${key} id`).toBe(key); - expect(descriptor.displayName.length > 0, `${key} displayName`).toBe(true); - expect(Array.isArray(descriptor.feedsTools), `${key} feedsTools`).toBe(true); - } - }); - - test("tool-feeding connectors keep their tool packages", async () => { - const { CONNECTOR_REGISTRY } = await import("./native-connector-registry"); - const feedsToolsFor = (id: string): readonly string[] => - CONNECTOR_REGISTRY[id]?.feedsTools ?? []; - expect(feedsToolsFor("github")).toContain("@corbits/github-tools"); - expect(feedsToolsFor("exa")).toContain("@corbits/web-search-tools"); - expect(feedsToolsFor("granola")).toContain("@corbits/granola-tools"); - expect(feedsToolsFor("linear")).toContain("@corbits/linear-tools"); - expect(feedsToolsFor("manus")).toContain("@corbits/manus-tools"); - }); - - test("serves the curated preset list the MCP mounts read", async () => { - const { MCP_PRESETS } = await import("./native-connector-registry"); - const slugs = new Set(MCP_PRESETS.map((preset) => preset.slug)); - for (const slug of [ - "granola", - "exa", - "linear", - "github-mcp", - "notion", - "sentry", - "attio", - "railway", - "posthog", - "sumble", - "canva", - ]) { - expect(slugs.has(slug), `preset list is missing ${slug}`).toBe(true); - } - for (const preset of MCP_PRESETS) { - expect(preset.url.length > 0, `${preset.slug} url`).toBe(true); - expect(preset.connectionMode.length > 0, `${preset.slug} mode`).toBe(true); - } - }); -}); diff --git a/apps/hub/src/in-flight-requests.test.ts b/apps/hub/src/in-flight-requests.test.ts deleted file mode 100644 index 528279da3c..0000000000 --- a/apps/hub/src/in-flight-requests.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { expect, test } from "bun:test"; -import { Hono } from "hono"; - -import { createInFlightRequestTracker } from "./in-flight-requests"; - -test("pending starts at zero and whenIdle resolves immediately", async () => { - const tracker = createInFlightRequestTracker(); - expect(tracker.pending).toBe(0); - await tracker.whenIdle(); - expect(tracker.pending).toBe(0); -}); - -test("middleware counts a request until the handler returns", async () => { - const tracker = createInFlightRequestTracker(); - const app = new Hono(); - app.use(tracker.middleware); - let pendingDuringHandler: number | undefined; - app.get("/work", async () => { - pendingDuringHandler = tracker.pending; - return new Response("ok"); - }); - - const response = await app.request("/work"); - expect(response.status).toBe(200); - expect(pendingDuringHandler).toBe(1); - expect(tracker.pending).toBe(0); -}); - -test("whenIdle waits for an in-flight handler, including one that throws", async () => { - const tracker = createInFlightRequestTracker(); - const app = new Hono(); - app.use(tracker.middleware); - app.onError(() => new Response("error", { status: 500 })); - - let release!: () => void; - const held = new Promise((resolve) => { - release = resolve; - }); - app.get("/held", async () => { - await held; - throw new Error("handler fault"); - }); - - const request = app.request("/held"); - const idle = tracker.whenIdle(); - let idleSettled = false; - void idle.then(() => { - idleSettled = true; - }); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(tracker.pending).toBe(1); - expect(idleSettled).toBe(false); - - release(); - await request; - await idle; - expect(idleSettled).toBe(true); - expect(tracker.pending).toBe(0); -}); diff --git a/apps/hub/src/in-flight-requests.ts b/apps/hub/src/in-flight-requests.ts deleted file mode 100644 index bbf0fbf67f..0000000000 --- a/apps/hub/src/in-flight-requests.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { Hono, type MiddlewareHandler } from "hono"; - -export type InFlightRequestTracker = { - readonly middleware: MiddlewareHandler; - readonly pending: number; - whenIdle: () => Promise; -}; - -/** - * Counts Hono handlers that have not yet returned a Response. Streaming - * bodies (SSE) and upgraded websockets stay open after that return, so they - * do not keep `pending` above zero — those are lingering connections the - * drain force-closes, not in-flight work. - */ -export function createInFlightRequestTracker(): InFlightRequestTracker { - let pending = 0; - const waiters = new Set<() => void>(); - - const notifyIfIdle = () => { - if (pending !== 0) return; - for (const waiter of waiters) waiter(); - waiters.clear(); - }; - - const middleware: MiddlewareHandler = async (_c, next) => { - pending += 1; - try { - await next(); - } finally { - pending -= 1; - notifyIfIdle(); - } - }; - - return { - middleware, - get pending() { - return pending; - }, - whenIdle() { - if (pending === 0) return Promise.resolve(); - return new Promise((resolve) => { - waiters.add(resolve); - }); - }, - }; -} - -export function withInFlightRequestTracking( - app: Hono, - tracker: InFlightRequestTracker, -): Hono { - const outer = new Hono(); - outer.use(tracker.middleware); - outer.route("/", app); - return outer; -} diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 0e26e0a1be..129c1a74a7 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -13,19 +13,23 @@ import { createGrantStore, createPrincipalKeyStore, createSidecarAllocationStore, - createSignalCorrelationStore, createWorkflowRunDispatchStore, - listAssetsForTenant, + type DB, } from "@intx/db"; -import { tenant as tenantTable, user as userTable, workflowDefinition } from "@intx/db/schema"; +import { + sidecar, + tenant as tenantTable, + user as userTable, + workflowDefinition, + workflowRun, +} from "@intx/db/schema"; import { and, eq, inArray } from "drizzle-orm"; -import { createEnvKeyCredentialCipher, createNoopCredentialCipher } from "@intx/crypto"; +import { createEnvKeyCredentialCipher, createNoopCredentialCipher, sha256 } from "@intx/crypto"; import type { CredentialCipher } from "@intx/types"; import { createApp, createMailTriggeredRunGrantsMaterializer, createRequireGrant, - readDurableWorkflowRunLifecycles, type AppEnv, type TenantEnv, } from "@intx/hub-api"; @@ -106,6 +110,8 @@ import { readProvisionerConfig as readE2BProvisionerConfig, } from "@corbits/e2b-sandbox-sidecar"; import { + createAgentRepoStore, + createAssetService, createEventCollectorRegistry, createHubSessionLookups, createHubSessionOrchestrator, @@ -117,24 +123,22 @@ import { createWorkflowAllocationService, createWorkflowDispatchService, resolveRoutableAddress, - type AgentRepoStore, type EventCollectorRegistry, type WsHandle, } from "@intx/hub-sessions"; -import { createLaunchCaches } from "./launch-caches"; -import { hubErrorHandler } from "./hub-error-handler"; -import { grantConditionRegistry } from "./grant-conditions"; -import { wireMailRedelivery } from "./mail-redelivery"; +import { generateKeyPair } from "@intx/crypto"; +import { timeWindowEvaluator } from "@intx/authz"; +import type { ConditionRegistry } from "@intx/types/authz"; + +// The same condition registry `mountHubRoutes` builds by default when no +// registry is supplied (`vendor/intx/hub-api/src/app.ts`) -- kept as one +// local constant so every route factory below shares the identical +// registry, rather than each re-deriving stock's own default. +const grantConditionRegistry: ConditionRegistry = { + time_window: timeWindowEvaluator, +}; import { getLogger, setup } from "@intx/log"; import { hexEncode } from "@intx/types"; -import { createToolAllowanceRegistry, withGrantAllowance } from "@corbits/approvals"; -import { createMcpCallClassifier, MCP_CALL_TOOL, mcpTools } from "@corbits/mcp-tools"; -import { - createAllowanceAutoApprover, - createMcpServerToolsAllowanceLoader, - createRegisteredApprovalFinder, - createTenantGrantLister, -} from "./grant-allowance"; import { createDockerSidecarProvisioner } from "@corbits/docker-provisioner"; import { createProcessSidecarProvisioner, @@ -176,35 +180,52 @@ import { listDeployedCronDefinitions, SCHEDULE_TICK_CONTENT, } from "@corbits/workflows"; -import { - createDrizzleServingRefreshStore, - createTenantServingRefresh, -} from "./credential-material-refresh"; -import { type } from "arktype"; import { betterAuth } from "better-auth"; -import { createSignInAttemptLimiter } from "./sign-in-rate-limit"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { type Context, Hono, type Next } from "hono"; import { upgradeWebSocket, websocket } from "hono/bun"; import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; -import { readHubConfig, type HubConfig, type SidecarProvisionerConfig } from "./config"; +import { + assertHubDataDirGitSafety, + readHubConfig, + type HubConfig, + type SidecarProvisionerConfig, +} from "./config"; import type { SidecarProvisioner } from "@intx/hub-sessions"; -import { withTurnPartWriteDefaults } from "./turn-part-content-default"; -import { createDrizzleTurnTextSnapshotReader } from "./turn-text-snapshot"; -import { createBootAssetWiring, REGISTRIES } from "./asset-service-factory"; import { triggerNativeWorkflowRoutineRun } from "./native-workflow-routine-launch"; import { createCronEmitter } from "@corbits/workflow-schedule/emitter"; -import { createToolGrantsForPins } from "./tool-grants"; -import { drainHubServer, shutdownHub } from "./shutdown"; -import { createInFlightRequestTracker, withInFlightRequestTracking } from "./in-flight-requests"; -import { - createWorkflowRunAuthenticator, - withWorkflowRunTenantAuth, -} from "./workflow-run-tenant-auth"; // Host policy constants, not configuration. const MAX_TARBALL_BYTES = 10 * 1024 * 1024; +const REGISTRIES = new Map([["npmjs", { url: "https://registry.npmjs.org" }]]); + +// The one concrete `WorkflowRunAuthenticator` every workflow-run-authenticated +// surface in this app takes structurally (agent-directory, chat, connections, +// memory-hub, `@corbits/artifacts`' `mountWorkflowArtifacts`, and stock +// `@intx/hub-api`'s own `workflowRunAuthenticator` deploy mount): a sidecar +// bearer token + run address resolve to the tenant/principal/run it names. +function createWorkflowRunAuthenticator(deps: { db: DB["db"] }) { + return { + async resolve(token: string, runAddress: string) { + if (token === "" || runAddress === "") return null; + const tokenHash = await sha256(token); + const sidecarRow = await deps.db.query.sidecar.findFirst({ + where: eq(sidecar.tokenHashSha256, tokenHash), + }); + if (sidecarRow === undefined) return null; + const run = await deps.db.query.workflowRun.findFirst({ + where: eq(workflowRun.address, runAddress), + }); + if (run === undefined || run.principalId === null) return null; + return { + tenantId: run.tenantId, + principalId: run.principalId, + runId: run.id, + }; + }, + }; +} // In-repo tool packages (`packages/granola-tools`, `packages/linear-tools`, // `packages/skills-tools`) are unpublished to npm and stay that way: // they are integration bundles for this product's own routes, not @@ -224,8 +245,6 @@ const MAX_TARBALL_BYTES = 10 * 1024 * 1024; // carry an unpublished scope anyway). const TENANT_PREFIX = "/api/tenants/:tenantId"; const SIGN_UP_EMAIL_PATH = "/sign-up/email"; -const SIGN_IN_EMAIL_PATH = "/sign-in/email"; -const SignInEmailBody = type({ email: "string" }); // CL-8187: idle hibernation is the sidecar's own decision now — it tracks // per-run last activity itself and tears an idle run down with a // state-preserving teardown, keeping the deployment record, step-state, @@ -435,6 +454,7 @@ function buildSidecarProvisioner( } export async function createHub(config: HubConfig) { + assertHubDataDirGitSafety(config.hubDataDir, config.allowGitInsideWorkTree === true, process.env); const { db, close } = createDB(dbConfigFromUrl(config.databaseUrl)); const { db: mailboxDb, close: closeMailbox } = createMailboxDb(config.databaseUrl); const mailboxBus = createInMemoryMailboxEventBus(); @@ -481,43 +501,26 @@ export async function createHub(config: HubConfig) { window: config.signupRateLimit.windowSeconds, max: config.signupRateLimit.max, }, - // `false` fully disables better-auth's own built-in special rule - // for /sign-in* (3 attempts / 10 seconds, keyed on the client IP - // above) rather than leaving it running in parallel as a second, - // weaker mechanism: that IP key is exactly what CL-6494's - // private-network bypass defeats, so enforcement for this path - // lives entirely in `signInAttemptLimiter` below instead. - [SIGN_IN_EMAIL_PATH]: false, + // Sign-in stays on better-auth's own IP-keyed rule (CL-8185: the + // hub's account-keyed limiter working around its private-network + // bypass was deleted; see "Upstream asks" for the gap). }, }, }); - // Account-keyed sign-in rate limit (CL-6494) — see `sign-in-rate-limit.ts` - // for why this replaces better-auth's own IP-keyed sign-in enforcement - // entirely rather than composing with it. - const signInAttemptLimiter = createSignInAttemptLimiter( - config.signInRateLimit.windowSeconds, - config.signInRateLimit.max, - ); - const { signingKey, agentRepoStore, assetService } = await createBootAssetWiring({ - db, + const signingKey = await generateKeyPair(); + const agentRepoStore = createAgentRepoStore({ dataDir: config.hubDataDir, - ...(config.allowGitInsideWorkTree === true ? { allowGitInsideWorkTree: true } : {}), + signingKey, + }); + const assetService = createAssetService({ + db, + repoStore: agentRepoStore.repoStore, + reservedPackageRegistryNames: new Set(REGISTRIES.keys()), }); const baseLookups = createHubSessionLookups({ db, agentRepoStore }); // A chat agent is a native provisioned deployment. Reconnect // ownership is Interchange's live run. Completed means dead; wake is a // fresh provision, not a folded-run idle settle. - // CL-6345: the grant-allowance gate wraps `registerSignalCorrelation` - // so a parked read-only call whose resource a standing grant covers is - // auto-approved right after its approval row lands — no card for a - // human, the ledgered row still records the decision. The gate's deps - // (dispatch service, grant store, approval stores) don't exist yet at - // this point in the composition, so the wrapper reads through this ref, - // assigned once they do; until then every registration takes the plain - // parked path. - const grantAllowanceGateRef: { - current?: (args: Parameters[0]) => Promise; - } = {}; // Forward reference: `eventCollectors` (the wrapped // `EventCollectorRegistry`) isn't constructed until later in this // composition, but `createHubPersistMailWithSessionEnsure` below needs @@ -568,13 +571,6 @@ export async function createHub(config: HubConfig) { bus: mailboxBus, resolveRefs: hubMailboxResolveRefs, }), - async registerSignalCorrelation( - args: Parameters[0], - ): Promise { - const gate = grantAllowanceGateRef.current; - if (gate !== undefined) return gate(args); - return baseLookups.registerSignalCorrelation(args); - }, }; const hubPublicKey = hexEncode(signingKey.publicKey); // One resolver serves both seams, exactly as @intx/hub-sessions's own @@ -639,7 +635,7 @@ export async function createHub(config: HubConfig) { await runArtifactMigrations(db); const artifactContentStore = InlineContentStore; const baseEventCollectors = createEventCollectorRegistry({ - db: withTurnPartWriteDefaults(db), + db, // CL-7418: deliberately no terminal-status settle here. The folded // routine-fire settle (CL-6778, keyed off routines.routine_run) died // with the routines cut (CL-4455) and has no native equivalent: a @@ -703,38 +699,24 @@ export async function createHub(config: HubConfig) { db, eventCollectors, }); - // CL-6225: the launch path re-reads every tool-package tarball and - // rebuilds a full git pack of every attached asset on every agent - // launch; both reads are pure functions of an immutable commit SHA (see - // `./launch-caches.ts`). Only `createSessionService`'s launch path gets - // the cached wrapper — the smart-HTTP git routes and the asset REST - // routes below keep the raw `agentRepoStore`/`assetService` because they - // serve requests under per-request principals the cache is not sound - // for (see `./launch-caches.ts`'s header comment). - const launchCaches = createLaunchCaches({ - assetService, - repoStore: agentRepoStore.repoStore, - }); - // CL-6149: a launch's pinned tool packages (`toolPackagePins`) carry - // no grants of their own — the deploy-time capability walk - // (`vendor/intx/workflow-deploy/src/capability-walk.ts`) only derives - // `tool:` grants for inline tool factories. `toolGrantsForPins` — the - // port `createHubChatPlatform`'s `CreateHubChatPlatformDeps` is built - // with — turns a launch's pins into `tool:` grants read - // from the tenant-resolved `corbits-tools` asset's packed manifests - // (CL-7582), through the same cached launch-path read seam above. - const toolGrantsForPins = createToolGrantsForPins({ - listAssets: (tenantId, kind) => listAssetsForTenant(db, tenantId, kind), - assetService: launchCaches.assetService, - }); - const launchAgentRepoStore: AgentRepoStore = { - writeDeployTree: agentRepoStore.writeDeployTree, - createDeployPack: agentRepoStore.createDeployPack, - receiveAgentStatePack: agentRepoStore.receiveAgentStatePack, - receiveWorkflowRunPack: agentRepoStore.receiveWorkflowRunPack, - getSigningPublicKey: agentRepoStore.getSigningPublicKey, - repoStore: launchCaches.repoStore, - }; + // CL-8185: the launch-path SHA-keyed read cache (`launch-caches.ts`) was + // a pure performance optimization -- deleted under the hub-sweep's + // less-is-more ruling. Every launch now re-reads the tool-package + // registry and rebuilds deploy packs uncached; see "Upstream asks". + // + // CL-6149: a launch's pinned tool packages (`toolPackagePins`) carry no + // grants of their own — the deploy-time capability walk only derives + // `tool:` grants for inline tool factories, and this hub-owned + // `tool-grants.ts` port (deleted in the same sweep) was the workaround + // that minted `tool:` grants from a pinned package's own + // manifest. Stubbed to no grants for now; see "Upstream asks" — a + // pinned tool package's calls fail closed until this is addressed. + const toolGrantsForPins = async () => + [] as readonly { + readonly resource: string; + readonly action: "invoke"; + readonly effect: "allow" | "ask" | "deny"; + }[]; // Shared-capacity `deployWorkflowFromSource` / `deployAdoptedWorkflowFromSource` // are gone on this pin. The provisioned path persists its source in // `workflow_run_launch_spec`; there is nothing left for a session-service @@ -742,8 +724,8 @@ export async function createHub(config: HubConfig) { const sessionService = createSessionService({ sidecarRouter, sidecarAllocationRouter: sidecarRouter, - agentRepoStore: launchAgentRepoStore, - assetService: launchCaches.assetService, + agentRepoStore, + assetService, db, toolPackageRegistries: { httpRegistries: REGISTRIES, @@ -898,44 +880,7 @@ export async function createHub(config: HubConfig) { const result = await auth.api.getSession({ headers }); return result ? { user: result.user, session: result.session } : null; }, - authHandler: async (c) => { - // Account-keyed sign-in brute-force protection (CL-6494, hardened - // CL-6521) — see `sign-in-rate-limit.ts` for why this fully replaces - // better-auth's own IP-keyed enforcement for this path instead of - // running beside it, and for why only failures ever consume budget. - if (c.req.method === "POST" && c.req.path.endsWith(SIGN_IN_EMAIL_PATH)) { - let email: string | undefined; - try { - const body: unknown = await c.req.raw.clone().json(); - const parsed = SignInEmailBody(body); - if (!(parsed instanceof type.errors)) email = parsed.email; - } catch { - email = undefined; - } - // A body that doesn't parse to `{ email: string }` never touches - // the limiter at all — there is no account to key a bucket on, - // and better-auth will reject the request on its own terms. - const response = await auth.handler(c.req.raw); - if (email === undefined) return response; - if (response.status >= 200 && response.status < 300) { - signInAttemptLimiter.recordSuccess(email); - return response; - } - const decision = signInAttemptLimiter.recordFailure(email); - if (!decision.allowed) { - return c.json( - { - error: "rate_limited", - message: `Too many sign-in attempts. Try again in ${decision.retryAfterSeconds} second${decision.retryAfterSeconds === 1 ? "" : "s"}.`, - }, - 429, - { "Retry-After": decision.retryAfterSeconds.toString() }, - ); - } - return response; - } - return auth.handler(c.req.raw); - }, + authHandler: async (c) => auth.handler(c.req.raw), db, sidecarRouter, sessionService, @@ -964,73 +909,16 @@ export async function createHub(config: HubConfig) { }), }); - // Without this, any exception escaping a route (extension or platform - // alike) falls through to Hono's built-in handler: a bare 500 with - // nothing reported. See `hubErrorHandler`'s own doc comment. - app.onError(hubErrorHandler()); + // CL-8185: the hub-owned global `app.onError` (`hub-error-handler.ts`) + // was deleted under the hub-sweep's less-is-more ruling. An exception + // escaping a route now falls through to Hono's own built-in handler -- + // a bare 500 with nothing reported. See "Upstream asks". // The hub's own grant store, built the same way `createApp` builds its // default when none is supplied (see `@intx/hub-api`'s // `mountHubRoutes`). `createRequireGrant` is the published construction // the platform's own internal instance is not exported for. const chatGrantStore = createGrantStore(db); - // CL-6345: arm the grant-allowance gate declared up at `lookups`. The - // one annotation today is `mcp_call` (registered under both its bare - // and pinned-namespaced names): a downstream MCP tool the server - // itself marks `readOnlyHint: true`, called on a connection whose - // `mcp:` resource an `allow`/"read" grant covers, is - // auto-approved through the native resolve machinery; every other - // parked call — writes, unverified claims, uncovered connections — - // waits for a human exactly as before. - { - const mcpCallClassify = createMcpCallClassifier( - createMcpServerToolsAllowanceLoader({ db, credentialCipher }), - ); - const allowanceLog = (line: string) => log.info`${line}`; - grantAllowanceGateRef.current = withGrantAllowance( - (args) => baseLookups.registerSignalCorrelation(args), - { - registry: createToolAllowanceRegistry([ - { - tool: MCP_CALL_TOOL, - grantAction: "read", - classify: mcpCallClassify, - }, - { - tool: `${mcpTools.id}:${MCP_CALL_TOOL}`, - grantAction: "read", - classify: mcpCallClassify, - }, - ]), - findRegisteredApproval: createRegisteredApprovalFinder(db), - listTenantGrants: createTenantGrantLister(db), - autoApprove: createAllowanceAutoApprover( - { - db, - sidecarRouter, - workflowDispatchService, - readRunLifecycles: async (agentAddress, topLevelRunId, targetRunId) => { - const lifecycles = await readDurableWorkflowRunLifecycles( - agentRepoStore.repoStore, - agentAddress, - [topLevelRunId, targetRunId], - ); - return { - topLevel: lifecycles.get(topLevelRunId) ?? "absent", - target: lifecycles.get(targetRunId) ?? "absent", - }; - }, - grantStore: chatGrantStore, - conditionRegistry: grantConditionRegistry, - approvalStore: createApprovalStore(db), - signalCorrelationStore: createSignalCorrelationStore(db), - }, - allowanceLog, - ), - log: allowanceLog, - }, - ); - } const threadStore = createDrizzleThreadStore(db); const blockResponseStore = createDrizzleBlockResponseStore(db); const reactionStore = createDrizzleReactionStore(db); @@ -1084,14 +972,10 @@ export async function createHub(config: HubConfig) { toolGrantsForPins, cryptoProviders, workflowAllocationService, - // CL-7505: serving-time token refresh — every outbound mail refreshes - // the tenant's due oauth_token credentials first and pushes the - // refreshed frames, so the run dials on a live token or fails over - // past a credential that just went re-auth-required. - refreshServingCredentials: createTenantServingRefresh({ - store: createDrizzleServingRefreshStore(db, credentialCipher, sidecarRouter), - hubUrl: config.baseUrl, - }), + // CL-8185: serving-time oauth_token refresh (`credential-material-refresh.ts`) + // was deleted under the hub-sweep's less-is-more ruling; see + // "Upstream asks" — a run can now dial on a stale token instead of + // refreshing it first. // A hand-authored definition with no model requirements of its own // (see `@corbits/agent-directory`'s `createAgentDefinitionCore` // doc) still launches on invite by falling back to this same @@ -1099,7 +983,6 @@ export async function createHub(config: HubConfig) { workbenchHostInferencePreferences: chatHostInferencePreferencesResolver, relaunchNotice: relaunchNoticeRef, }); - wireMailRedelivery({ sidecarRouter, chatPlatform }); // The one SSE subscriber registry for this process's chat events // (see `@corbits/chat`'s `workbench-events.ts`), constructed here in // the composition root and shared by every consumer below: the chat @@ -1297,7 +1180,6 @@ export async function createHub(config: HubConfig) { threads: threadStore, turnMailCorrelation, agentTurns, - turnTextSnapshot: (input) => createDrizzleTurnTextSnapshotReader(db).read(input), blockResponses: blockResponseStore, reactions: reactionStore, pins: pinStore, @@ -1801,20 +1683,16 @@ export async function createHub(config: HubConfig) { app.get("/*", createStaticHandler(path.resolve(config.hubStaticDir))); - // Stock Interchange currently leaves tenant creation and dispatch ungated. - // Let a workflow-run agent reach every stock tenant route with the run - // bearer, not just the deploy endpoint — see ./workflow-run-tenant-auth.ts - // for why this is an outer wrap. - const runBearerApp = withWorkflowRunTenantAuth(app, { - db, - authenticator: createWorkflowRunAuthenticator({ db }), - }); - const inFlight = createInFlightRequestTracker(); - const servingApp = withInFlightRequestTracking(runBearerApp, inFlight); - + // CL-8185: the hub-sweep's less-is-more ruling deleted the tenant-subtree + // bearer widening (`workflow-run-tenant-auth.ts`'s `withWorkflowRunTenantAuth`) + // and the in-flight request tracker (`in-flight-requests.ts`). Stock + // `@intx/hub-api` still authenticates a run bearer on the single + // `/workflows/deployments` mount via `workflowRunAuthenticator` above; a + // run agent reaching any other tenant route now needs a session like + // everything else. See "Upstream asks" for both gaps. return { - app: servingApp, - whenRequestsIdle: () => inFlight.whenIdle(), + app, + whenRequestsIdle: () => Promise.resolve(), db, close: async () => { sidecarAllocationReconciliationStopped = true; @@ -1865,24 +1743,20 @@ if (import.meta.main) { }); const log = getLogger(["hub"]); log.info`Hub serving on port ${port}`; - const SHUTDOWN_DRAIN_MS = 10_000; - // In-flight Hono handlers (a request mid-Postgres-transaction, a git - // write, anything that has not returned a Response yet) must finish - // before connections are torn down. `server.stop()` with no argument - // also waits for SSE bridges and idle sidecar websockets, which never - // close on their own — so once handlers are idle, force-close what's - // left. A live stream must not turn this drain into a timeout fault. + // CL-8185: the bounded-drain shutdown (`shutdown.ts`) and its in-flight + // tracker were deleted under the hub-sweep's less-is-more ruling. A + // signal now force-stops the listener and closes hub resources directly, + // with no drain window for an in-flight request and no reported cause on + // a hang. See "Upstream asks". const shutdown = () => - shutdownHub({ - drain: () => - drainHubServer({ - whenRequestsIdle: hub.whenRequestsIdle, - stop: (force) => server.stop(force), - close: hub.close, - }), - timeoutMs: SHUTDOWN_DRAIN_MS, - exit: (code) => process.exit(code), - }); + void (async () => { + try { + server.stop(true); + await hub.close(); + } finally { + process.exit(0); + } + })(); process.on("SIGINT", () => void shutdown()); process.on("SIGTERM", () => void shutdown()); } diff --git a/apps/hub/src/launch-caches.test.ts b/apps/hub/src/launch-caches.test.ts deleted file mode 100644 index bf5901a2bd..0000000000 --- a/apps/hub/src/launch-caches.test.ts +++ /dev/null @@ -1,298 +0,0 @@ -// CL-6225: proves the launch-path read cache serves repeated reads of the -// same commit from memory (inner service called once), still calls through -// on a genuinely different commit, evicts under its byte budget, and leaves -// every other AssetService/RepoStore method wired straight through. -import { describe, expect, test } from "bun:test"; -import type { - Asset, - AssetService, - ListAssetBlobsParams, - Principal, - ReadAssetBlobParams, - RepoId, - RepoStore, -} from "@intx/hub-sessions"; -import { createLaunchCaches } from "./launch-caches"; - -const ASSET_ID = "asset_pkgreg1"; -const HEAD_REF = "refs/heads/main"; - -function countingAssetService(heads: Map): { - assetService: AssetService; - readCalls: ReadAssetBlobParams[]; - listCalls: ListAssetBlobsParams[]; -} { - const readCalls: ReadAssetBlobParams[] = []; - const listCalls: ListAssetBlobsParams[] = []; - const assetService: AssetService = { - createAsset: (): Promise => { - throw new Error("not exercised in these tests"); - }, - populateAsset: () => { - throw new Error("not exercised in these tests"); - }, - readAssetBlob: (params) => { - readCalls.push(params); - const sha = heads.get(params.ref ?? HEAD_REF) ?? "missing"; - return Promise.resolve(new TextEncoder().encode(`${params.path}@${sha}`)); - }, - listAssetBlobs: (params) => { - listCalls.push(params); - return Promise.resolve(["tarballs/a.tgz", "tarballs/b.tgz"]); - }, - }; - return { assetService, readCalls, listCalls }; -} - -function countingRepoStore(heads: Map): { - repoStore: RepoStore; - resolveRefCalls: number; - createPackCalls: { repoId: RepoId; ref: string }[]; -} { - const createPackCalls: { repoId: RepoId; ref: string }[] = []; - let resolveRefCalls = 0; - const repoStore: RepoStore = { - initRepo: () => Promise.resolve(), - writeTree: () => { - throw new Error("not exercised in these tests"); - }, - writeTreePreservingPrefix: () => { - throw new Error("not exercised in these tests"); - }, - writeTreeDelta: () => { - throw new Error("not exercised in these tests"); - }, - receivePack: () => { - throw new Error("not exercised in these tests"); - }, - createPack: (_principal: Principal, repoId: RepoId, ref: string) => { - createPackCalls.push({ repoId, ref }); - const sha = heads.get(ref) ?? "missing"; - const pack = new TextEncoder().encode(`pack@${sha}`); - return Promise.resolve({ pack, commitSha: sha, ref }); - }, - commitPackedTip: () => undefined, - resolveRef: (_principal: Principal, _repoId: RepoId, ref: string) => { - resolveRefCalls += 1; - return Promise.resolve(heads.get(ref) ?? null); - }, - listRefs: () => Promise.resolve([]), - resolveHead: () => Promise.resolve(null), - getRepoDir: (repoId: RepoId) => `/data/${repoId.kind}/${repoId.id}`, - openCommittedReads: () => Promise.resolve(null), - openCommittedReadsAtCommit: () => Promise.resolve(null), - subscribe: () => { - throw new Error("not exercised in these tests"); - }, - }; - return { repoStore, resolveRefCalls, createPackCalls }; -} - -describe("createLaunchCaches: assetService reads", () => { - test("serves a repeated blob read at the same head SHA from cache", async () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const inner = countingAssetService(heads); - const { repoStore } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: inner.assetService, - repoStore, - }); - - const first = await caches.assetService.readAssetBlob({ - assetId: ASSET_ID, - path: "tarballs/a.tgz", - }); - const second = await caches.assetService.readAssetBlob({ - assetId: ASSET_ID, - path: "tarballs/a.tgz", - }); - - expect(inner.readCalls.length).toBe(1); - expect(second).toEqual(first); - }); - - test("misses the cache when the head SHA changes", async () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const inner = countingAssetService(heads); - const { repoStore } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: inner.assetService, - repoStore, - }); - - await caches.assetService.readAssetBlob({ - assetId: ASSET_ID, - path: "tarballs/a.tgz", - }); - heads.set(HEAD_REF, "sha-2"); - await caches.assetService.readAssetBlob({ - assetId: ASSET_ID, - path: "tarballs/a.tgz", - }); - - expect(inner.readCalls.length).toBe(2); - }); - - test("caches listAssetBlobs the same way", async () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const inner = countingAssetService(heads); - const { repoStore } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: inner.assetService, - repoStore, - }); - - await caches.assetService.listAssetBlobs({ - assetId: ASSET_ID, - dir: "tarballs", - }); - await caches.assetService.listAssetBlobs({ - assetId: ASSET_ID, - dir: "tarballs", - }); - - expect(inner.listCalls.length).toBe(1); - }); - - test("lists an empty catalog when the package-registry has no resolvable main", async () => { - const heads = new Map(); - const inner = countingAssetService(heads); - const { repoStore } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: inner.assetService, - repoStore, - }); - - const listed = await caches.assetService.listAssetBlobs({ - assetId: ASSET_ID, - dir: "tarballs", - }); - - expect(listed).toEqual([]); - expect(inner.listCalls.length).toBe(0); - }); - - test("delegates createAsset and populateAsset untouched", () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const inner = countingAssetService(heads); - const { repoStore } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: inner.assetService, - repoStore, - }); - - expect(caches.assetService.createAsset).toBe(inner.assetService.createAsset); - expect(caches.assetService.populateAsset).toBe(inner.assetService.populateAsset); - }); -}); - -describe("createLaunchCaches: repoStore packs", () => { - const repoId: RepoId = { kind: "package-registry", id: ASSET_ID }; - const principal: Principal = { kind: "hub" }; - - test("serves a repeated createPack at the same commit from cache", async () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const { repoStore, createPackCalls } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: countingAssetService(heads).assetService, - repoStore, - }); - - const first = await caches.repoStore.createPack(principal, repoId, HEAD_REF); - const second = await caches.repoStore.createPack(principal, repoId, HEAD_REF); - - expect(createPackCalls.length).toBe(1); - expect(second).toEqual(first); - }); - - test("rebuilds the pack when the ref moves to a new commit", async () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const { repoStore, createPackCalls } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: countingAssetService(heads).assetService, - repoStore, - }); - - await caches.repoStore.createPack(principal, repoId, HEAD_REF); - heads.set(HEAD_REF, "sha-2"); - await caches.repoStore.createPack(principal, repoId, HEAD_REF); - - expect(createPackCalls.length).toBe(2); - }); - - test("delegates every other RepoStore method untouched", () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const { repoStore } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: countingAssetService(heads).assetService, - repoStore, - }); - - expect(caches.repoStore.initRepo).toBe(repoStore.initRepo); - expect(caches.repoStore.writeTree).toBe(repoStore.writeTree); - expect(caches.repoStore.writeTreePreservingPrefix).toBe(repoStore.writeTreePreservingPrefix); - expect(caches.repoStore.writeTreeDelta).toBe(repoStore.writeTreeDelta); - expect(caches.repoStore.receivePack).toBe(repoStore.receivePack); - expect(caches.repoStore.commitPackedTip).toBe(repoStore.commitPackedTip); - expect(caches.repoStore.resolveRef).toBe(repoStore.resolveRef); - expect(caches.repoStore.listRefs).toBe(repoStore.listRefs); - expect(caches.repoStore.resolveHead).toBe(repoStore.resolveHead); - expect(caches.repoStore.getRepoDir).toBe(repoStore.getRepoDir); - expect(caches.repoStore.openCommittedReads).toBe(repoStore.openCommittedReads); - expect(caches.repoStore.openCommittedReadsAtCommit).toBe(repoStore.openCommittedReadsAtCommit); - expect(caches.repoStore.subscribe).toBe(repoStore.subscribe); - }); -}); - -describe("createLaunchCaches: bounded LRU eviction", () => { - test("evicts the least-recently-used blob once the byte budget is exceeded", async () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const inner = countingAssetService(heads); - const { repoStore } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: inner.assetService, - repoStore, - maxBytes: 32, - maxEntries: 64, - }); - - const bigPath = "tarballs/" + "a".repeat(20) + ".tgz"; - const otherPath = "tarballs/" + "b".repeat(20) + ".tgz"; - await caches.assetService.readAssetBlob({ - assetId: ASSET_ID, - path: bigPath, - }); - await caches.assetService.readAssetBlob({ - assetId: ASSET_ID, - path: otherPath, - }); - // The budget is too small to hold both; the first entry should have - // been evicted, so re-reading it calls through again. - await caches.assetService.readAssetBlob({ - assetId: ASSET_ID, - path: bigPath, - }); - - expect(inner.readCalls.length).toBe(3); - }); - - test("evicts the least-recently-used entry once the entry-count budget is exceeded", async () => { - const heads = new Map([[HEAD_REF, "sha-1"]]); - const inner = countingAssetService(heads); - const { repoStore } = countingRepoStore(heads); - const caches = createLaunchCaches({ - assetService: inner.assetService, - repoStore, - maxEntries: 2, - maxBytes: 1024 * 1024, - }); - - await caches.assetService.readAssetBlob({ assetId: ASSET_ID, path: "a" }); - await caches.assetService.readAssetBlob({ assetId: ASSET_ID, path: "b" }); - await caches.assetService.readAssetBlob({ assetId: ASSET_ID, path: "c" }); - // "a" should have been evicted to make room for "c". - await caches.assetService.readAssetBlob({ assetId: ASSET_ID, path: "a" }); - - expect(inner.readCalls.length).toBe(4); - }); -}); diff --git a/apps/hub/src/launch-caches.ts b/apps/hub/src/launch-caches.ts deleted file mode 100644 index f80bd582c5..0000000000 --- a/apps/hub/src/launch-caches.ts +++ /dev/null @@ -1,209 +0,0 @@ -// CL-6225: chat mint is slow because every agent launch re-reads every -// tarball in a tenant's `corbits-tools` package-registry asset and rebuilds -// a full git pack for every asset attachment — see -// `AssetRegistrySource.#buildPackuments` in -// `@intx/tool-packaging/src/resolver.ts` and -// `resolveAssetAttachment` in `vendor/intx/hub-sessions/src/session-service.ts`. -// Both reads are uncached across launches even though the content they read -// is immutable per commit: a blob, a directory listing, and a deploy pack -// are all pure functions of (repoId, commit SHA, path). This module wraps -// the two vendored services the launch path consumes with a SHA-keyed -// cache, so a tenant whose tool registry hasn't changed since the last -// launch pays for one cheap `resolveRef` instead of re-reading and -// re-hashing every tarball, and instead of rebuilding the deploy pack. -// -// Because the cache key always includes the content's commit SHA, no -// write-through invalidation is needed: a write advances the ref to a new -// SHA, which is simply a cache miss the next time the ref is read. Stale -// entries for abandoned SHAs age out of the bounded LRU on their own. -// -// Safety invariant: this wrapper is only sound for a caller that always -// reads with the same fully-trusted principal, because a `createPack` cache -// hit returns pack bytes without re-running the substrate's per-call -// `authorize` gate. The hub's launch path satisfies this today — -// `resolveAssetAttachment` in `session-service.ts` always calls -// `agentRepoStore.repoStore` with its own internal `{ kind: "hub" }` -// principal — but this wrapper must never be wired into the smart-HTTP git -// routes or the asset REST routes, where different requests carry different, -// less-privileged principals. -// -// `assetService.readAssetBlob` / `listAssetBlobs` resolve their ref through -// their own direct `isomorphic-git` call and never surface the SHA they -// landed on, so this module learns it independently via -// `repoStore.resolveRef` before touching the cache. The launch path's only -// `assetService` consumer (`buildAndResolve` in `session-service.ts`) reads -// exclusively from `package-registry` assets, so the `RepoId` this module -// builds for that lookup hardcodes `kind: "package-registry"` — the same -// `{ kind, id }` shape `assetService`'s own `resolveCommitTreeOid` computes -// a directory from, so the two stay pointed at the same on-disk repo. -import { DEFAULT_ASSET_REF } from "@intx/hub-sessions"; -import type { - AssetService, - ListAssetBlobsParams, - Principal, - ReadAssetBlobParams, - RepoId, - RepoStore, -} from "@intx/hub-sessions"; - -const DEFAULT_MAX_ENTRIES = 64; -const DEFAULT_MAX_BYTES = 64 * 1024 * 1024; - -const LAUNCH_CACHE_PRINCIPAL: Principal = { kind: "hub" }; - -type WeighedEntry = { value: V; bytes: number }; - -/** - * Bounded LRU keyed by string. A `Map`'s iteration order already tracks - * insertion order, so recency is tracked by deleting and re-inserting a key - * on every touch; the oldest (first) key is always the eviction candidate. - * Eviction runs after every `set` until the cache is back under both the - * entry-count and byte budgets. - */ -class BoundedCache { - readonly #entries = new Map>(); - readonly #maxEntries: number; - readonly #maxBytes: number; - #bytes = 0; - - constructor(maxEntries: number, maxBytes: number) { - this.#maxEntries = maxEntries; - this.#maxBytes = maxBytes; - } - - get(key: string): V | undefined { - const entry = this.#entries.get(key); - if (entry === undefined) return undefined; - this.#entries.delete(key); - this.#entries.set(key, entry); - return entry.value; - } - - set(key: string, value: V, bytes: number): void { - const existing = this.#entries.get(key); - if (existing !== undefined) this.#bytes -= existing.bytes; - this.#entries.delete(key); - this.#entries.set(key, { value, bytes }); - this.#bytes += bytes; - this.#evictOverBudget(); - } - - #evictOverBudget(): void { - while (this.#isOverBudget() && this.#entries.size > 0) { - const oldestKey = this.#entries.keys().next().value; - if (oldestKey === undefined) break; - const oldest = this.#entries.get(oldestKey); - this.#entries.delete(oldestKey); - if (oldest !== undefined) this.#bytes -= oldest.bytes; - } - } - - #isOverBudget(): boolean { - return this.#entries.size > this.#maxEntries || this.#bytes > this.#maxBytes; - } -} - -function utf16Bytes(value: string): number { - return value.length * 2; -} - -export type LaunchCaches = { - assetService: AssetService; - repoStore: RepoStore; -}; - -export function createLaunchCaches(deps: { - assetService: AssetService; - repoStore: RepoStore; - maxEntries?: number; - maxBytes?: number; -}): LaunchCaches { - const { assetService, repoStore } = deps; - const maxEntries = deps.maxEntries ?? DEFAULT_MAX_ENTRIES; - const maxBytes = deps.maxBytes ?? DEFAULT_MAX_BYTES; - - const blobCache = new BoundedCache(maxEntries, maxBytes); - const listCache = new BoundedCache(maxEntries, maxBytes); - const packCache = new BoundedCache<{ - pack: Uint8Array; - commitSha: string; - ref: string; - }>(maxEntries, maxBytes); - - async function resolvePackageRegistryHeadSha( - assetId: string, - ref: string | undefined, - ): Promise { - const effectiveRef = ref ?? DEFAULT_ASSET_REF; - const repoId: RepoId = { kind: "package-registry", id: assetId }; - return repoStore.resolveRef(LAUNCH_CACHE_PRINCIPAL, repoId, effectiveRef); - } - - async function readAssetBlob(params: ReadAssetBlobParams): Promise { - const sha = await resolvePackageRegistryHeadSha(params.assetId, params.ref); - if (sha === null) return assetService.readAssetBlob(params); - const key = `${params.assetId}:${sha}:${params.path}`; - const cached = blobCache.get(key); - if (cached !== undefined) return cached; - const blob = await assetService.readAssetBlob(params); - blobCache.set(key, blob, blob.byteLength); - return blob; - } - - async function listAssetBlobs(params: ListAssetBlobsParams): Promise { - const sha = await resolvePackageRegistryHeadSha(params.assetId, params.ref); - // No resolvable `main` means no tarballs yet. Match the tarball REST - // list, which returns [] on this same not_found rather than failing - // the launch. Pins against a missing tarball still fail as unknown - // package. - if (sha === null) return []; - const key = `${params.assetId}:${sha}:${params.dir}`; - const cached = listCache.get(key); - if (cached !== undefined) return cached; - const list = await assetService.listAssetBlobs(params); - const bytes = list.reduce((sum, name) => sum + utf16Bytes(name), 0); - listCache.set(key, list, bytes); - return list; - } - - const cachedAssetService: AssetService = { - createAsset: assetService.createAsset, - populateAsset: assetService.populateAsset, - readAssetBlob, - listAssetBlobs, - }; - - async function createPack( - principal: Principal, - repoId: RepoId, - ref: string, - ): Promise<{ pack: Uint8Array; commitSha: string; ref: string }> { - const sha = await repoStore.resolveRef(principal, repoId, ref); - if (sha === null) return repoStore.createPack(principal, repoId, ref); - const key = `${repoId.kind}/${repoId.id}:${sha}`; - const cached = packCache.get(key); - if (cached !== undefined) return cached; - const built = await repoStore.createPack(principal, repoId, ref); - packCache.set(key, built, built.pack.byteLength); - return built; - } - - const cachedRepoStore: RepoStore = { - initRepo: repoStore.initRepo, - writeTree: repoStore.writeTree, - writeTreePreservingPrefix: repoStore.writeTreePreservingPrefix, - writeTreeDelta: repoStore.writeTreeDelta, - receivePack: repoStore.receivePack, - createPack, - commitPackedTip: repoStore.commitPackedTip, - resolveRef: repoStore.resolveRef, - listRefs: repoStore.listRefs, - resolveHead: repoStore.resolveHead, - getRepoDir: repoStore.getRepoDir, - openCommittedReads: repoStore.openCommittedReads, - openCommittedReadsAtCommit: repoStore.openCommittedReadsAtCommit, - subscribe: repoStore.subscribe, - }; - - return { assetService: cachedAssetService, repoStore: cachedRepoStore }; -} diff --git a/apps/hub/src/mail-redelivery.ts b/apps/hub/src/mail-redelivery.ts deleted file mode 100644 index ab6ba14e4f..0000000000 --- a/apps/hub/src/mail-redelivery.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Redelivers mail the sidecar wire layer could not route locally. -// `rawMessage` is the same full MIME bytes `routeMail` accepts for a -// first delivery attempt — confirmed against -// `vendor/intx/hub-sessions/src/ws/sidecar-handler.ts`'s -// `mail.outbound.undelivered` emit sites, every one of which carries -// the original `mail.inbound` frame's `rawMessage`, never a stripped -// notification — so, unlike a payload naming only the recipients, this -// one genuinely supports a retry rather than only a log line. -// -// `ensureAwake` is the chat platform's own wake primitive (it has no -// generic host-agnostic equivalent across chat, tasks, and any other -// future resident kind). A recipient it rejects -// for — not a chat resident at all, or a task-launched resident whose -// `wake` is deliberately unreachable once its one-shot turn has run — -// still gets the `routeMail` retry, since an address can go from -// unroutable to routable for reasons outside this handler's knowledge -// (e.g. a reconnect that lands between the original drop and this -// handler running). -import { getLogger } from "@intx/log"; - -export type MailRedeliveryEventBus = { - on( - type: "mail.outbound.undelivered", - listener: (event: { rawMessage: string; recipients: string[] }) => void, - ): () => void; -}; - -export type MailRedeliverySidecarRouter = { - events: MailRedeliveryEventBus; - getRoutableAddresses(): readonly string[]; - routeMail(agentAddress: string, rawMessage: string): boolean; -}; - -export type MailRedeliveryChatPlatform = { - ensureAwake(address: string): Promise; -}; - -export type WireMailRedeliveryDeps = { - sidecarRouter: MailRedeliverySidecarRouter; - chatPlatform: MailRedeliveryChatPlatform; -}; - -export function wireMailRedelivery(deps: WireMailRedeliveryDeps): () => void { - const log = getLogger(["hub", "mail-redelivery"]); - - return deps.sidecarRouter.events.on("mail.outbound.undelivered", ({ rawMessage, recipients }) => { - for (const recipient of recipients) { - void (async () => { - if (!deps.sidecarRouter.getRoutableAddresses().includes(recipient)) { - try { - await deps.chatPlatform.ensureAwake(recipient); - } catch (cause) { - log.warn`could not wake ${recipient} ahead of redelivery: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - } - } - const delivered = deps.sidecarRouter.routeMail(recipient, rawMessage); - if (!delivered) { - log.warn`redelivery to ${recipient} failed: still unroutable after wake`; - } - })(); - } - }); -} diff --git a/apps/hub/src/mcp-credential-bindings.ts b/apps/hub/src/mcp-credential-bindings.ts deleted file mode 100644 index 6f7c07c708..0000000000 --- a/apps/hub/src/mcp-credential-bindings.ts +++ /dev/null @@ -1,34 +0,0 @@ -// The composition root's own port supplying dynamic MCP credential -// bindings. `@corbits/mcp-tools`' credential handles are dynamic (one -// `mcp.` per tenant-connected server), so this builds one -// `CredentialBinding` per connection `@corbits/connections`' own -// `listMcpServerConnections` lists for the tenant: `handle` mirrors the -// `mcp.` convention `@corbits/mcp-tools`' `mcpCredentialHandle(slug)` -// resolves against (conforms to `@intx/types`' `ToolCredentialHandle` -// grammar), while `provider` names the stored `mcp:` provider row -// `listMcpServerConnections` read it from — a separate, unconstrained -// namespace. -// -// `McpCredentialBindingsFor`'s shape is declared locally rather than -// imported: it names nothing chat-specific (`CredentialBinding` is native -// `@intx/types`), so the hub owns its own copy of the port instead of -// depending on `@corbits/chat` for it. -import type { DB } from "@intx/db"; -import type { CredentialBinding } from "@intx/types"; -import { listMcpServerConnections } from "@corbits/connections"; - -export type McpCredentialBindingsFor = (tenantId: string) => Promise; - -const MCP_TOOLS_PACKAGE = "@corbits/mcp-tools"; - -export function createMcpCredentialBindingsFor(db: DB["db"]): McpCredentialBindingsFor { - return async (tenantId: string) => { - const connections = await listMcpServerConnections(db, tenantId); - return connections.map((connection): CredentialBinding => ({ - package: MCP_TOOLS_PACKAGE, - handle: `mcp.${connection.slug}`, - provider: `mcp:${connection.slug}`, - locator: "tenant", - })); - }; -} diff --git a/apps/hub/src/pinned-package-credential-bindings.test.ts b/apps/hub/src/pinned-package-credential-bindings.test.ts deleted file mode 100644 index 96a3c59ba1..0000000000 --- a/apps/hub/src/pinned-package-credential-bindings.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - bindingsForConnectedPins, - createPinnedPackageCredentialBindingsFor, -} from "./pinned-package-credential-bindings"; - -const MANUS_PIN = { name: "@corbits/manus-tools", version: "*" }; -const GRANOLA_PIN = { name: "@corbits/granola-tools", version: "*" }; - -describe("bindingsForConnectedPins", () => { - test("emits a manus tenant binding when manus-tools is pinned and Manus is connected", () => { - expect(bindingsForConnectedPins([MANUS_PIN], ["manus"])).toEqual([ - { - package: "@corbits/manus-tools", - handle: "manus", - provider: "manus", - locator: "tenant", - }, - ]); - }); - - test("returns none when manus-tools is pinned but Manus is not connected", () => { - expect(bindingsForConnectedPins([MANUS_PIN], ["granola"])).toEqual([]); - }); - - test("returns none when Manus is connected but manus-tools is not pinned", () => { - expect(bindingsForConnectedPins([GRANOLA_PIN], ["manus"])).toEqual([]); - }); - - test("emits a granola binding when granola-tools is pinned and Granola is connected", () => { - expect(bindingsForConnectedPins([GRANOLA_PIN], ["granola"])).toEqual([ - { - package: "@corbits/granola-tools", - handle: "granola", - provider: "granola", - locator: "tenant", - }, - ]); - }); - - test("returns none for empty pins even when connectors are connected", () => { - expect(bindingsForConnectedPins([], ["manus", "granola"])).toEqual([]); - }); -}); - -describe("createPinnedPackageCredentialBindingsFor", () => { - test("emits a manus tenant binding when isConnectorConnected is true for manus", async () => { - const bindingsFor = createPinnedPackageCredentialBindingsFor( - async (_tenantId, connectorId) => connectorId === "manus", - ); - expect(await bindingsFor("tenant-1", [MANUS_PIN])).toEqual([ - { - package: "@corbits/manus-tools", - handle: "manus", - provider: "manus", - locator: "tenant", - }, - ]); - }); - - test("emits none when only a catalog-style inference connector is connected", async () => { - const bindingsFor = createPinnedPackageCredentialBindingsFor( - async (_tenantId, connectorId) => connectorId === "anthropic", - ); - expect(await bindingsFor("tenant-1", [MANUS_PIN])).toEqual([]); - }); -}); diff --git a/apps/hub/src/pinned-package-credential-bindings.ts b/apps/hub/src/pinned-package-credential-bindings.ts deleted file mode 100644 index e8eca5c645..0000000000 --- a/apps/hub/src/pinned-package-credential-bindings.ts +++ /dev/null @@ -1,63 +0,0 @@ -// The composition root's own port for a provisioned agent's pinned tool -// packages: a tenant binding only when a pin names a package a -// `CONNECTOR_REGISTRY` entry `feedsTools` AND the tenant already has a -// connected credential for that connector (`isConnectorConnected`, the -// same owning check `createWorkflowConnectionRoutes` uses). Static-handle -// packages (`@corbits/manus-tools`, granola-tools, …) declare -// `interchange.credentials`, but requiring those binds unconditionally -// would throw `MissingCredentialError` on signup / first launch. -// -// `PinnedPackageCredentialBindingsFor`'s shape is declared locally rather -// than imported: it names nothing chat-specific (`CredentialBinding`, -// `ToolPackagePin` are both native `@intx/types`), so the hub owns its own -// copy of the port instead of depending on `@corbits/chat` for it. -import type { CredentialBinding } from "@intx/types"; -import type { ToolPackagePin } from "@intx/types/tool-packages"; -import { CONNECTOR_REGISTRY } from "./native-connector-registry"; - -export type PinnedPackageCredentialBindingsFor = ( - tenantId: string, - pins: readonly ToolPackagePin[], -) => Promise; - -export type IsConnectorConnected = (tenantId: string, connectorId: string) => Promise; - -export function bindingsForConnectedPins( - pins: readonly ToolPackagePin[], - connectedConnectorIds: readonly string[], -): readonly CredentialBinding[] { - const pinNames = new Set(pins.map((pin) => pin.name)); - if (pinNames.size === 0) return []; - const connected = new Set(connectedConnectorIds); - const bindings: CredentialBinding[] = []; - for (const descriptor of Object.values(CONNECTOR_REGISTRY)) { - if (!connected.has(descriptor.id)) continue; - for (const toolPackageName of descriptor.feedsTools) { - if (!pinNames.has(toolPackageName)) continue; - bindings.push({ - package: toolPackageName, - handle: descriptor.id, - provider: descriptor.id, - locator: "tenant", - }); - } - } - return bindings; -} - -export function createPinnedPackageCredentialBindingsFor( - isConnectorConnected: IsConnectorConnected, -): PinnedPackageCredentialBindingsFor { - return async (tenantId, pins) => { - const pinNames = new Set(pins.map((pin) => pin.name)); - if (pinNames.size === 0) return []; - const connectedConnectorIds: string[] = []; - for (const descriptor of Object.values(CONNECTOR_REGISTRY)) { - if (!descriptor.feedsTools.some((name) => pinNames.has(name))) continue; - if (await isConnectorConnected(tenantId, descriptor.id)) { - connectedConnectorIds.push(descriptor.id); - } - } - return bindingsForConnectedPins(pins, connectedConnectorIds); - }; -} diff --git a/apps/hub/src/shutdown.test.ts b/apps/hub/src/shutdown.test.ts deleted file mode 100644 index 3e0e8b7837..0000000000 --- a/apps/hub/src/shutdown.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { expect, test } from "bun:test"; -import { drainHubServer, drainWithTimeout, shutdownHub } from "./shutdown"; - -test("drainWithTimeout resolves drained when the drain completes inside the bound", async () => { - const outcome = await drainWithTimeout(() => Promise.resolve(), 1_000); - expect(outcome).toEqual({ kind: "drained" }); -}); - -test("drainWithTimeout resolves timed-out when the drain outlives the bound", async () => { - const outcome = await drainWithTimeout(() => new Promise(() => undefined), 10); - expect(outcome).toEqual({ kind: "timed-out" }); -}); - -test("drainWithTimeout resolves failed with the thrown error when the drain throws", async () => { - const error = new Error("drain fault"); - const outcome = await drainWithTimeout(() => Promise.reject(error), 1_000); - expect(outcome).toEqual({ kind: "failed", error }); -}); - -test("shutdownHub exits 0 when the drain resolves inside the bound", async () => { - let exitCode: number | undefined; - const reported: unknown[] = []; - await shutdownHub({ - drain: () => Promise.resolve(), - timeoutMs: 1_000, - exit: (code) => { - exitCode = code; - }, - report: (error) => { - reported.push(error); - return "unused-ref-id"; - }, - }); - expect(exitCode).toBe(0); - expect(reported).toEqual([]); -}); - -test("shutdownHub exits non-zero and reports the cause when the drain never settles", async () => { - let exitCode: number | undefined; - const reported: unknown[] = []; - await shutdownHub({ - drain: () => new Promise(() => undefined), - timeoutMs: 10, - exit: (code) => { - exitCode = code; - }, - report: (error) => { - reported.push(error); - return "unused-ref-id"; - }, - }); - expect(exitCode).toBe(1); - expect(reported).toHaveLength(1); - expect(reported[0]).toBeInstanceOf(Error); - expect((reported[0] as Error).message).toContain("exceeded 10ms"); -}); - -test("shutdownHub exits non-zero and reports the cause when the drain rejects", async () => { - let exitCode: number | undefined; - const reported: unknown[] = []; - const error = new Error("close fault"); - await shutdownHub({ - drain: () => Promise.reject(error), - timeoutMs: 1_000, - exit: (code) => { - exitCode = code; - }, - report: (reportedError) => { - reported.push(reportedError); - return "unused-ref-id"; - }, - }); - expect(exitCode).toBe(1); - expect(reported).toEqual([error]); -}); - -test("drainHubServer waits for idle handlers then force-stops", async () => { - const order: string[] = []; - await drainHubServer({ - whenRequestsIdle: async () => { - order.push("idle"); - }, - stop: (force) => { - order.push(force ? "stop-force" : "stop"); - }, - close: async () => { - order.push("close"); - }, - }); - expect(order).toEqual(["idle", "stop-force", "close"]); -}); diff --git a/apps/hub/src/shutdown.ts b/apps/hub/src/shutdown.ts deleted file mode 100644 index 6fa08dee0e..0000000000 --- a/apps/hub/src/shutdown.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Bounded drain for process shutdown, mirroring apps/sidecar/src/shutdown.ts's -// shape. Kept as a local copy rather than a shared import or package: it's a -// ~15-line primitive, and the two apps already diverge on exit semantics -// (the sidecar treats a timeout as a clean-enough exit; the hub treats a -// timeout as a fault worth reporting), so sharing it would need a parameter -// immediately. -// -// The platform sends SIGTERM and expects a prompt exit; a drain that hangs -// would turn every deploy into an apparent crash, so the bound cuts it off -// and reports the cause instead of leaving an unhandled rejection. - -import { reportError } from "@corbits/error-sink"; - -export type DrainOutcome = - | { kind: "drained" } - | { kind: "timed-out" } - | { kind: "failed"; error: unknown }; - -export async function drainWithTimeout( - drain: () => Promise, - timeoutMs: number, -): Promise { - let timer: ReturnType | undefined; - const timedOut = new Promise((resolve) => { - timer = setTimeout(() => { - resolve({ kind: "timed-out" }); - }, timeoutMs); - }); - const drained = (async (): Promise => { - try { - await drain(); - return { kind: "drained" }; - } catch (error) { - return { kind: "failed", error }; - } - })(); - const outcome = await Promise.race([drained, timedOut]); - clearTimeout(timer); - return outcome; -} - -export type DrainHubServerArgs = { - whenRequestsIdle: () => Promise; - stop: (force: boolean) => void | Promise; - close: () => void | Promise; -}; - -/** - * Wait for every in-flight Hono handler to return, then force-stop the - * listener so lingering SSE/websocket connections cannot hang `server.stop()`, - * then close hub resources. - */ -export async function drainHubServer({ - whenRequestsIdle, - stop, - close, -}: DrainHubServerArgs): Promise { - await whenRequestsIdle(); - await stop(true); - await close(); -} - -export type ShutdownHubDeps = { - drain: () => Promise; - timeoutMs: number; - exit: (code: number) => void; - report?: typeof reportError; -}; - -/** - * Drains the hub within `timeoutMs` and always exits: 0 on a clean drain, - * non-zero with the cause reported through `reportError` on a throw or a - * timeout. - */ -export async function shutdownHub({ - drain, - timeoutMs, - exit, - report = reportError, -}: ShutdownHubDeps): Promise { - const outcome = await drainWithTimeout(drain, timeoutMs); - if (outcome.kind === "drained") { - exit(0); - return; - } - const error = - outcome.kind === "timed-out" - ? new Error(`Hub shutdown drain exceeded ${timeoutMs}ms`) - : outcome.error; - report(error, { operation: "hub.shutdown" }); - exit(1); -} diff --git a/apps/hub/src/sign-in-rate-limit.test.ts b/apps/hub/src/sign-in-rate-limit.test.ts deleted file mode 100644 index b634aa53d3..0000000000 --- a/apps/hub/src/sign-in-rate-limit.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createSignInAttemptLimiter } from "./sign-in-rate-limit.ts"; - -describe("createSignInAttemptLimiter", () => { - test("the Nth failure against one account past the configured max is rejected", () => { - const limiter = createSignInAttemptLimiter(60, 2); - - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - const throttled = limiter.recordFailure("victim@example.com"); - - expect(throttled.allowed).toBe(false); - }); - - test("a rejected failure reports how many seconds remain in the window", () => { - const limiter = createSignInAttemptLimiter(60, 1); - - limiter.recordFailure("victim@example.com"); - const throttled = limiter.recordFailure("victim@example.com"); - - expect(throttled.allowed).toBe(false); - if (!throttled.allowed) { - expect(throttled.retryAfterSeconds).toBeGreaterThan(0); - expect(throttled.retryAfterSeconds).toBeLessThanOrEqual(60); - } - }); - - test("rotating the caller-supplied identity per attempt does not grow the budget for the targeted account", () => { - // The whole point of keying on the account instead of client IP: - // a caller that varies some other, attacker-chosen value per request - // (a forged IP header, in production) still can't outrun the budget - // for the one email it's actually attacking, because the key is the - // email — nothing about a rotated header changes it. - const limiter = createSignInAttemptLimiter(60, 3); - - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - expect(limiter.recordFailure("victim@example.com").allowed).toBe(false); - expect(limiter.recordFailure("victim@example.com").allowed).toBe(false); - }); - - test("two distinct accounts get independent budgets", () => { - const limiter = createSignInAttemptLimiter(60, 1); - - expect(limiter.recordFailure("alice@example.com").allowed).toBe(true); - expect(limiter.recordFailure("alice@example.com").allowed).toBe(false); - - // Bob's own budget is untouched by Alice's exhausted one. - expect(limiter.recordFailure("bob@example.com").allowed).toBe(true); - }); - - test("email matching is case- and whitespace-insensitive, so it can't be sidestepped by casing/padding", () => { - const limiter = createSignInAttemptLimiter(60, 1); - - expect(limiter.recordFailure("Victim@Example.com").allowed).toBe(true); - expect(limiter.recordFailure(" victim@example.com ").allowed).toBe(false); - }); - - test("a successful sign-in clears the account's budget, so a prior run of failures never carries over", () => { - const limiter = createSignInAttemptLimiter(60, 1); - - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - expect(limiter.recordFailure("victim@example.com").allowed).toBe(false); - - limiter.recordSuccess("victim@example.com"); - - // The next failure is treated as a fresh first attempt, not a - // continuation of the exhausted budget from before the success. - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - }); - - test("recordSuccess is case- and whitespace-insensitive, matching the key recordFailure uses", () => { - const limiter = createSignInAttemptLimiter(60, 1); - - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - limiter.recordSuccess(" Victim@Example.com "); - - expect(limiter.recordFailure("victim@example.com").allowed).toBe(true); - }); -}); diff --git a/apps/hub/src/sign-in-rate-limit.ts b/apps/hub/src/sign-in-rate-limit.ts deleted file mode 100644 index 36d2713e00..0000000000 --- a/apps/hub/src/sign-in-rate-limit.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Account-keyed sign-in attempt limiter (CL-6494, hardened CL-6521). -// -// better-auth's own rate limiter keys solely on client IP (falling back to -// one shared bucket when no IP resolves), configured via -// `advanced.ipAddress` + `rateLimit.customRules`. That key cannot be this -// deployment's sign-in defense: Railway's private networking lets any -// same-project service — including sidecars that run agent-driven shell -// commands — reach this hub directly at `.railway.internal`, -// bypassing Railway's edge entirely. better-auth's `getIPFromHeader` trusts -// a single-value IP header verbatim whenever no `trustedProxies` is -// configured, and Railway's anycast edge publishes no stable CIDR list to -// populate one with. A caller on the private network can therefore send a -// fresh forged IP header on every request and get an independent -// rate-limit bucket each time — a complete bypass of brute-force -// protection, reachable from inside our own trust boundary. -// -// better-auth has no native extension point that fixes this: `customRules` -// can only override a matched path's `window`/`max` (or opt the path out -// entirely via `false`) — it never gets to change the key. `customStorage` -// only ever receives the already-computed `ip|path` key. Neither sees the -// request body, so neither can key on anything but that IP. This limiter -// is therefore deliberately separate from better-auth's engine — -// `index.ts` sets `customRules["/sign-in/email"]` to `false`, fully -// disabling better-auth's native, IP-keyed enforcement for this one path, -// rather than attempting to bend its extension points to a job they don't -// reach. Upstream note: better-auth would need a `customRules` (or -// pre-consume) hook that can see the parsed request body, or override the -// key itself, to express account-keyed limiting natively. -// -// Keyed on the normalized target email instead: that value isn't -// attacker-chosen the way a header is. An attacker rotating IP headers -// still cannot exceed the budget for the one account they're actually -// trying to break into, which is the threat brute-force limiting exists to -// stop. Client IP is deliberately not composed into the key: this -// deployment has no way to tell an edge-forwarded request from one that -// arrived over the private network with a forged header, so a -// header-derived IP is not a genuinely trustworthy signal here, and -// folding it into the key would only let the same forged-header trick -// defeat this limiter too, exactly as it defeats better-auth's. Once -// Railway traffic can be verifiably split (a stable edge CIDR list, or -// private networking segregated away from `/api/auth`), an IP-composed -// *secondary* per-source budget could be layered on top of this one, to -// also blunt one source spraying many different accounts. -// -// Only failed attempts consume budget, and a successful sign-in clears the -// bucket outright. This is deliberate, not incidental: an account-keyed -// limiter that also counted (or blocked) successful attempts would let an -// attacker who never learns the password still deny the real owner access -// indefinitely — send a handful of wrong-password guesses against a known -// email every window, forever, at near-zero cost, and the genuine holder -// of the account is locked out by the same mechanism meant to protect -// them. Counting only failures closes that: the caller in `index.ts` -// always lets a sign-in attempt reach `auth.handler` and inspects the -// real outcome before touching this limiter, so a correct password is -// never rejected on account of someone else's prior wrong guesses, -// however many there were. A distributed password spray against one -// account — many source IPs, one target email — is still bounded: once -// `max` wrong guesses have been recorded inside `windowSeconds`, every -// further failure in that window is turned into a generic 429 instead of -// a distinguishing auth failure, regardless of which IP it came from, -// because the key is the email, never the source. - -const MAX_TRACKED_EMAILS = 50_000; - -export type SignInAttemptDecision = - | { readonly allowed: true } - | { readonly allowed: false; readonly retryAfterSeconds: number }; - -export type SignInAttemptLimiter = { - /** - * Records one failed sign-in attempt against `email`'s budget. Returns - * `allowed: false` once that account has already exhausted its budget - * for the current window — the caller should surface that as a 429 - * instead of the underlying auth failure. Never called for an attempt - * that succeeded. - */ - recordFailure(email: string): SignInAttemptDecision; - /** - * Clears `email`'s bucket entirely. Called after a successful sign-in - * so a prior run of wrong guesses — an attacker's or the account - * owner's own mistyping — never carries over to block the next - * legitimate attempt. - */ - recordSuccess(email: string): void; -}; - -function normalizeEmail(email: string): string { - return email.trim().toLowerCase(); -} - -export function createSignInAttemptLimiter( - windowSeconds: number, - max: number, -): SignInAttemptLimiter { - const windowMs = windowSeconds * 1000; - const buckets = new Map(); - - function pruneExpiredAndOverflow(now: number): void { - for (const [key, bucket] of buckets) { - if (now - bucket.windowStart >= windowMs) buckets.delete(key); - } - if (buckets.size <= MAX_TRACKED_EMAILS) return; - let overflow = buckets.size - MAX_TRACKED_EMAILS; - for (const key of buckets.keys()) { - if (overflow <= 0) break; - buckets.delete(key); - overflow -= 1; - } - } - - return { - recordFailure(email: string): SignInAttemptDecision { - const now = Date.now(); - pruneExpiredAndOverflow(now); - const key = normalizeEmail(email); - const bucket = buckets.get(key); - if (!bucket || now - bucket.windowStart >= windowMs) { - buckets.set(key, { count: 1, windowStart: now }); - return { allowed: true }; - } - if (bucket.count >= max) { - return { - allowed: false, - retryAfterSeconds: Math.ceil((bucket.windowStart + windowMs - now) / 1000), - }; - } - bucket.count += 1; - return { allowed: true }; - }, - recordSuccess(email: string): void { - buckets.delete(normalizeEmail(email)); - }, - }; -} diff --git a/apps/hub/src/tool-grants.test.ts b/apps/hub/src/tool-grants.test.ts deleted file mode 100644 index f184e9abba..0000000000 --- a/apps/hub/src/tool-grants.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -// CL-6149: proves the hub's `toolGrantsForPins` port turns a launch's -// `toolPackagePins` into the exact `tool:` grants the -// workflow child's authz gate matches against — derived, since CL-7582, -// from the installed `corbits-tools` asset's packed manifests rather -// than any source import. -import { describe, expect, test } from "bun:test"; -import type { AssetWithOrigin } from "@intx/db"; -import type { AssetService, ListAssetBlobsParams, ReadAssetBlobParams } from "@intx/hub-sessions"; -import { packToolPackageTarball, tarballFilenameFor } from "@corbits/tool-registry-publish"; -import { createToolGrantsForPins } from "./tool-grants"; - -async function fakeRegistryAssetService( - tarballs: Awaited>[], -): Promise> { - const blobs = new Map( - tarballs.map((tarball) => [ - `tarballs/${tarballFilenameFor(tarball.name, tarball.version)}`, - tarball.bytes, - ]), - ); - return { - listAssetBlobs: (params: ListAssetBlobsParams) => - Promise.resolve([...blobs.keys()].filter((path) => path.startsWith(`${params.dir}/`))), - readAssetBlob: (params: ReadAssetBlobParams) => { - const bytes = blobs.get(params.path); - if (bytes === undefined) { - return Promise.reject(new Error(`no ${params.path}`)); - } - return Promise.resolve(bytes); - }, - }; -} - -function assetRow(id: string, direct: boolean): AssetWithOrigin { - return { - id, - name: "corbits-tools", - origin: { tenantId: "tenant_root", direct }, - } as AssetWithOrigin; -} - -const CAPABILITY_DIR = new URL("../../../packages/capability-tools", import.meta.url).pathname; -const MANUS_DIR = new URL("../../../packages/manus-tools", import.meta.url).pathname; - -async function grantsFor( - assetService: Pick, - assets: readonly AssetWithOrigin[], - pins: readonly { name: string; version: string }[], -) { - return createToolGrantsForPins({ - listAssets: (tenantId, kind) => { - expect(tenantId).toBe("tenant_child"); - expect(kind).toBe("package-registry"); - return Promise.resolve(assets); - }, - assetService, - })("tenant_child", pins); -} - -describe("createToolGrantsForPins", () => { - test("mints tool:/invoke for every tool the installed manifest declares", async () => { - const assetService = await fakeRegistryAssetService([ - await packToolPackageTarball(CAPABILITY_DIR), - ]); - const grants = await grantsFor( - assetService, - [assetRow("asset_1", true)], - [{ name: "@corbits/capability-tools", version: "^1" }], - ); - expect(grants.map((grant) => grant.resource)).toEqual([ - "tool:@corbits/capability-tools/cap:request_capability", - ]); - expect(grants.every((grant) => grant.action === "invoke")).toBe(true); - }); - - test('floors an unmarked tool at allow and a `approval: "ask"` tool at ask', async () => { - const assetService = await fakeRegistryAssetService([await packToolPackageTarball(MANUS_DIR)]); - const grants = await grantsFor( - assetService, - [assetRow("asset_1", true)], - [{ name: "@corbits/manus-tools", version: "*" }], - ); - expect(grants.find((grant) => grant.resource.endsWith(":webhook_create"))?.effect).toBe("ask"); - expect(grants.find((grant) => grant.resource.endsWith(":create_slides"))?.effect).toBe("allow"); - expect(grants.find((grant) => grant.resource.endsWith(":task_list"))?.effect).toBe("allow"); - }); - - test("unions grants across every pinned package and skips unknown pins", async () => { - const assetService = await fakeRegistryAssetService([ - await packToolPackageTarball(CAPABILITY_DIR), - await packToolPackageTarball(MANUS_DIR), - ]); - const grants = await grantsFor( - assetService, - [assetRow("asset_1", true)], - [ - { name: "@corbits/capability-tools", version: "^1" }, - { name: "@corbits/manus-tools", version: "*" }, - { name: "@corbits/unknown-tools", version: "^1" }, - ], - ); - expect(grants.some((grant) => grant.resource.includes("capability-tools"))).toBe(true); - expect(grants.some((grant) => grant.resource.includes("manus-tools"))).toBe(true); - expect(grants.some((grant) => grant.resource.includes("unknown"))).toBe(false); - }); - - test("resolves an inherited registry, not only a direct one", async () => { - const assetService = await fakeRegistryAssetService([ - await packToolPackageTarball(CAPABILITY_DIR), - ]); - const grants = await grantsFor( - assetService, - [assetRow("asset_root", false)], - [{ name: "@corbits/capability-tools", version: "^1" }], - ); - expect(grants.length).toBeGreaterThan(0); - }); - - test("a tenant with no corbits-tools asset yields no grants, never throws", async () => { - const assetService = await fakeRegistryAssetService([]); - const grants = await grantsFor( - assetService, - [], - [{ name: "@corbits/capability-tools", version: "^1" }], - ); - expect(grants).toEqual([]); - }); - - test("no pins yields no grants", async () => { - const assetService = await fakeRegistryAssetService([ - await packToolPackageTarball(CAPABILITY_DIR), - ]); - expect(await grantsFor(assetService, [assetRow("asset_1", true)], [])).toEqual([]); - }); -}); diff --git a/apps/hub/src/tool-grants.ts b/apps/hub/src/tool-grants.ts deleted file mode 100644 index b748413d71..0000000000 --- a/apps/hub/src/tool-grants.ts +++ /dev/null @@ -1,82 +0,0 @@ -// CL-6149: a launch's pinned tool packages (`toolPackagePins`) carry no -// grants of their own — the deploy-time capability walk -// (`vendor/intx/workflow-deploy/src/capability-walk.ts`) only derives -// `tool:` grants for inline tool factories, so a pinned package's tools -// failed every call closed with "No matching grants". Given a launch's -// pins, this reads the tenant-resolved `corbits-tools` package-registry -// asset's packed tarballs through `readToolSurfaceManifests` and mints -// one `tool:` / `invoke` declaration per tool, floored at -// `ask` for a tool the package itself marks `approval: "ask"` — the -// grants come from the installed asset's own manifest, never from a -// source import. -import { - CORBITS_TOOLS_REGISTRY, - readToolSurfaceManifests, - type ToolSurfaceBlobSource, -} from "@corbits/tool-registry-publish"; -import type { AssetWithOrigin } from "@intx/db"; -import type { AssetService } from "@intx/hub-sessions"; -import type { GrantEffect } from "@intx/types"; -import type { ToolPackagePin } from "@intx/types/tool-packages"; - -/** - * Declared locally rather than imported from `@corbits/chat`: nothing - * about a pinned tool package's grants is chat-specific, and the shape - * names only native `@intx/types` (`GrantEffect`, `ToolPackagePin`). - */ -export type PinnedToolGrantDeclaration = { - readonly resource: string; - readonly action: "invoke"; - readonly effect: GrantEffect; -}; - -export type ToolGrantsForPins = ( - tenantId: string, - pins: readonly ToolPackagePin[], -) => Promise; - -export type CreateToolGrantsForPinsDeps = { - /** - * Resolves the `package-registry` assets a tenant sees, local first, - * shadows-wins down the ancestor chain. The hub binds - * `@intx/db`'s `listAssetsForTenant` to its own db handle. - */ - listAssets: (tenantId: string, kind: string) => Promise; - /** The launch-path asset service — the launch caches' SHA-keyed wrapper. */ - assetService: Pick; -}; - -export function createToolGrantsForPins(deps: CreateToolGrantsForPinsDeps): ToolGrantsForPins { - return async (tenantId, pins) => { - const assets = await deps.listAssets(tenantId, "package-registry"); - const registry = assets.find((row) => row.name === CORBITS_TOOLS_REGISTRY); - if (registry === undefined) return []; - - const source: ToolSurfaceBlobSource = { - listBlobs: (dir) => deps.assetService.listAssetBlobs({ assetId: registry.id, dir }), - readBlob: (path) => deps.assetService.readAssetBlob({ assetId: registry.id, path }), - rootDir: "tarballs", - }; - const manifests = await readToolSurfaceManifests(source); - const toolsByPackageName = new Map( - manifests.map((manifest) => [ - manifest.name, - manifest.surface.filter((entry) => entry.kind === "tool"), - ]), - ); - - const grants: PinnedToolGrantDeclaration[] = []; - for (const pin of pins) { - const tools = toolsByPackageName.get(pin.name); - if (tools === undefined) continue; - for (const tool of tools) { - grants.push({ - resource: `tool:${tool.qualifiedId}`, - action: "invoke", - effect: tool.approval === "ask" ? "ask" : "allow", - }); - } - } - return grants; - }; -} diff --git a/apps/hub/src/turn-part-content-default.test.ts b/apps/hub/src/turn-part-content-default.test.ts deleted file mode 100644 index 1bc3d40a0f..0000000000 --- a/apps/hub/src/turn-part-content-default.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { turnPart, inferenceTurn } from "@intx/db/schema"; -import type { DB } from "@intx/db"; - -import { withTurnPartWriteDefaults } from "./turn-part-content-default"; - -function fakeDb(): { - db: DB["db"]; - calls: { table: unknown; values: unknown }[]; -} { - const calls: { table: unknown; values: unknown }[] = []; - const db = { - insert: (table: unknown) => ({ - values: (values: unknown) => { - calls.push({ table, values }); - return Promise.resolve(); - }, - }), - } as unknown as DB["db"]; - return { db, calls }; -} - -describe("withTurnPartWriteDefaults", () => { - test("fills content: '' for a tool part insert that omits content", async () => { - const { db, calls } = fakeDb(); - const wrapped = withTurnPartWriteDefaults(db); - - await wrapped.insert(turnPart).values({ - id: "part_1", - turnId: "turn_1", - sessionId: "session_1", - type: "tool", - ordinal: 0, - metadata: { kind: "call", callId: "call_1", name: "echo", arguments: {} }, - }); - - expect(calls).toHaveLength(1); - expect(calls[0]?.values).toMatchObject({ content: "" }); - }); - - test("leaves an explicit content value untouched", async () => { - const { db, calls } = fakeDb(); - const wrapped = withTurnPartWriteDefaults(db); - - await wrapped.insert(turnPart).values({ - id: "part_2", - turnId: "turn_1", - sessionId: "session_1", - type: "text", - ordinal: 1, - content: "hello", - }); - - expect(calls[0]?.values).toMatchObject({ content: "hello" }); - }); - - test("does not touch inserts into other tables", async () => { - const { db, calls } = fakeDb(); - const wrapped = withTurnPartWriteDefaults(db); - - await wrapped.insert(inferenceTurn).values({ - id: "turn_1", - sessionId: "session_1", - runId: "run_1", - tenantId: "tenant_1", - model: "claude", - startedAt: new Date(), - }); - - expect(calls[0]?.values).not.toHaveProperty("content"); - }); -}); diff --git a/apps/hub/src/turn-part-content-default.ts b/apps/hub/src/turn-part-content-default.ts deleted file mode 100644 index 5bef653983..0000000000 --- a/apps/hub/src/turn-part-content-default.ts +++ /dev/null @@ -1,48 +0,0 @@ -// Interchange's event-collector (`@intx/hub-sessions`) inserts `turn_part` -// rows for tool-call/tool-result parts with `content: null`, which -// `insertPart` then omits from the insert entirely — Drizzle emits the SQL -// `DEFAULT` keyword for that column. That is only safe when the deployed -// `turn_part.content` column carries a real default; on a deployment where -// it does not (see the "hub·event-collector-registry: Failed to persist -// event" log), every tool part fails to persist and the Runs panel goes -// blank for tool-call turns. This wraps the `db` handle passed to -// `createEventCollectorRegistry` so a `turn_part` insert that omits -// `content` gets the historical empty-string value instead of relying on a -// database-level default that may not exist. Only `turn_part` inserts are -// touched — every other table's insert passes through untouched. -import { turnPart } from "@intx/db/schema"; -import type { DB } from "@intx/db"; - -type InsertBuilder = { values: (...args: unknown[]) => unknown }; - -function fillContent(value: unknown): unknown { - if (value === null || typeof value !== "object" || "content" in value) { - return value; - } - return { ...value, content: "" }; -} - -export function withTurnPartWriteDefaults(db: DB["db"]): DB["db"] { - return new Proxy(db as object, { - get(target, prop, receiver) { - if (prop !== "insert") { - return Reflect.get(target, prop, receiver); - } - const insert = Reflect.get(target, prop, target) as (table: unknown) => InsertBuilder; - return (table: unknown) => { - const builder = insert.call(target, table); - if (table !== turnPart) { - return builder; - } - // The event-collector always calls `.values(...)` directly and - // awaits the result without further chaining, so only that method - // needs interception here. - const originalValues = builder.values.bind(builder); - return { - values: (values: unknown) => - originalValues(Array.isArray(values) ? values.map(fillContent) : fillContent(values)), - }; - }; - }, - }) as DB["db"]; -} diff --git a/apps/hub/src/turn-text-snapshot.test.ts b/apps/hub/src/turn-text-snapshot.test.ts deleted file mode 100644 index 844f37c790..0000000000 --- a/apps/hub/src/turn-text-snapshot.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { snapshotTextFromParts } from "./turn-text-snapshot"; - -describe("snapshotTextFromParts", () => { - test("concatenates text parts within a turn by ordinal", () => { - const text = snapshotTextFromParts( - ["turn_1"], - [ - { turnId: "turn_1", type: "text", content: "world", ordinal: 1 }, - { turnId: "turn_1", type: "text", content: "hello ", ordinal: 0 }, - ], - ); - expect(text).toBe("hello world"); - }); - - test("concatenates across turns oldest first, ignoring turns not passed", () => { - const text = snapshotTextFromParts( - ["turn_1", "turn_2"], - [ - { turnId: "turn_2", type: "text", content: "second", ordinal: 0 }, - { turnId: "turn_1", type: "text", content: "first", ordinal: 0 }, - { turnId: "turn_3", type: "text", content: "unreferenced", ordinal: 0 }, - ], - ); - expect(text).toBe("firstsecond"); - }); - - test("skips non-text parts and null content", () => { - const text = snapshotTextFromParts( - ["turn_1"], - [ - { turnId: "turn_1", type: "tool", content: "ignored", ordinal: 0 }, - { turnId: "turn_1", type: "text", content: null, ordinal: 1 }, - { turnId: "turn_1", type: "text", content: "kept", ordinal: 2 }, - ], - ); - expect(text).toBe("kept"); - }); - - test("a turn with no text parts contributes nothing, not a gap", () => { - const text = snapshotTextFromParts(["turn_1"], []); - expect(text).toBe(""); - }); -}); diff --git a/apps/hub/src/turn-text-snapshot.ts b/apps/hub/src/turn-text-snapshot.ts deleted file mode 100644 index c3543f3110..0000000000 --- a/apps/hub/src/turn-text-snapshot.ts +++ /dev/null @@ -1,80 +0,0 @@ -// Reads the visible text already streamed for a chat turn straight off the -// platform's own inference_turn / turn_part tables (CL-6380) — the same -// tables `trace-reader.ts` reads, no new storage. A chat `AgentTurn`'s -// `childRunId` names the workflow_run that produced it, and `inferenceTurn` -// rows key on that same run id (one inference_turn per LLM call within the -// turn, e.g. one per tool-loop step); `turn_part` rows of type "text" only -// land once their inference call reaches `inference.done` — mid-call token -// deltas never get an intermediate row — so a turn still streaming its -// first inference call reads back an empty snapshot, not an error: the -// caller's live tail (the SSE stream reattaching) is what carries the rest. -import { and, asc, eq, inArray } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { inferenceTurn, turnPart } from "@intx/db/schema"; - -export interface TurnTextSnapshotReader { - /** The visible text this turn has committed to `turn_part` so far, in - * inference-call then ordinal order — the "part cursor" catch-up a - * client reattaching mid-turn replays before the live tail resumes. - * `null` for a run with no turns yet (nothing to reconstruct), never for - * one that simply hasn't produced text yet (that reads as `""`). */ - read(input: { readonly tenantId: string; readonly runId: string }): Promise; -} - -/** Pure so the reconstruction rule is testable without a database: text - * parts only, concatenated turn-by-turn (oldest first) and, within a turn, - * ordinal-by-ordinal — mirrors the collector's own `accumulatedText` - * (`vendor/intx/hub-sessions/src/event-collector.ts`) without depending on - * it. */ -export function snapshotTextFromParts( - turnIdsOldestFirst: readonly string[], - parts: readonly { - readonly turnId: string; - readonly content: string | null; - readonly type: string; - readonly ordinal: number; - }[], -): string { - const byTurn = new Map(); - for (const part of parts) { - if (part.type !== "text" || part.content === null) continue; - const bucket = byTurn.get(part.turnId); - const entry = { content: part.content, ordinal: part.ordinal }; - if (bucket === undefined) byTurn.set(part.turnId, [entry]); - else bucket.push(entry); - } - let text = ""; - for (const turnId of turnIdsOldestFirst) { - const bucket = byTurn.get(turnId); - if (bucket === undefined) continue; - for (const part of [...bucket].sort((a, b) => a.ordinal - b.ordinal)) { - text += part.content; - } - } - return text; -} - -export function createDrizzleTurnTextSnapshotReader(db: DB["db"]): TurnTextSnapshotReader { - return { - async read({ tenantId, runId }) { - const turns = await db.query.inferenceTurn.findMany({ - where: and(eq(inferenceTurn.runId, runId), eq(inferenceTurn.tenantId, tenantId)), - orderBy: asc(inferenceTurn.startedAt), - }); - if (turns.length === 0) return null; - - const parts = await db.query.turnPart.findMany({ - where: inArray( - turnPart.turnId, - turns.map((turn) => turn.id), - ), - orderBy: asc(turnPart.ordinal), - }); - - return snapshotTextFromParts( - turns.map((turn) => turn.id), - parts, - ); - }, - }; -} diff --git a/apps/hub/src/workflow-run-tenant-auth.ts b/apps/hub/src/workflow-run-tenant-auth.ts deleted file mode 100644 index ca881faaad..0000000000 --- a/apps/hub/src/workflow-run-tenant-auth.ts +++ /dev/null @@ -1,104 +0,0 @@ -// A workflow child has no browser session — only its sidecar bearer token -// and its own run address. `@intx/hub-api` already knows how to turn that -// pair into a `principal` + `tenant` context (`createWorkflowRunDeployAuth`), -// but mounts it on the single `/api/tenants/:tenantId/workflows/deployments` -// path, so every other stock tenant route 401s a run-authenticated caller. -// -// This composition root widens that mount to the whole tenant subtree, so an -// agent tool package can read and write stock tenant routes (principals, -// grants, credentials, providers, offerings, workflow deployments) with the -// run bearer instead of a Workbench-specific `/api/workflow-*` mirror. -// -// It has to be an outer wrap rather than an `app.use()` on the built app: -// `createApp()` registers `createResolveTenant` on `/api/tenants/:tenantId/*` -// before it returns, and Hono composes middleware in registration order — the -// same reasoning `./tenant-create-guard.ts` and `./dispatch-tenant-guard.ts` -// give for their own wraps. -// -// The middleware is a no-op for a request that presents no bearer credential -// (it calls `next()` with nothing set), so session traffic, the git-token -// smart-HTTP mounts, and the existing deploy mirror all behave exactly as -// before. The `:tenantId` path segment is never trusted: the middleware sets -// the tenant from the authenticated run's own scope, so a run cannot widen -// its reach by naming another tenant in the URL. -import { eq } from "drizzle-orm"; -import { Hono } from "hono"; -import { sha256 } from "@intx/crypto"; -import type { DB } from "@intx/db"; -import { sidecar, workflowRun } from "@intx/db/schema"; -import { - createWorkflowRunDeployAuth, - type AppEnv, - type WorkflowRunAuthenticator, -} from "@intx/hub-api"; - -export type WorkflowRunTenantAuthDeps = { - db: DB["db"]; - authenticator: WorkflowRunAuthenticator; -}; - -/** - * The tenant + principal + run a presented sidecar token and run address - * resolve to. This is the one concrete implementation of the - * `WorkflowRunAuthenticator` shape every workflow-run-authenticated surface - * in this app takes structurally (agent-directory, chat, connections, - * memory-hub, `@corbits/artifacts`' `mountWorkflowArtifacts`, and the - * `withWorkflowRunTenantAuth` wrap below) — a single sidecar-token + - * run-address check, reused everywhere rather than re-verified per surface. - */ -export type ResolvedWorkflowRunScope = { - readonly tenantId: string; - readonly principalId: string; - readonly runId: string; -}; - -export type CreateWorkflowRunAuthenticatorDeps = { - db: DB["db"]; -}; - -export type ConcreteWorkflowRunAuthenticator = { - resolve(token: string, runAddress: string): Promise; -}; - -export function createWorkflowRunAuthenticator( - deps: CreateWorkflowRunAuthenticatorDeps, -): ConcreteWorkflowRunAuthenticator { - return { - async resolve(token, runAddress) { - if (token === "" || runAddress === "") return null; - - const tokenHash = await sha256(token); - const sidecarRow = await deps.db.query.sidecar.findFirst({ - where: eq(sidecar.tokenHashSha256, tokenHash), - }); - if (sidecarRow === undefined) return null; - - const run = await deps.db.query.workflowRun.findFirst({ - where: eq(workflowRun.address, runAddress), - }); - if (run === undefined || run.principalId === null) return null; - - return { - tenantId: run.tenantId, - principalId: run.principalId, - runId: run.id, - }; - }, - }; -} - -export function withWorkflowRunTenantAuth( - nativeApp: Hono, - deps: WorkflowRunTenantAuthDeps, -): Hono { - const wrapped = new Hono(); - wrapped.use( - "/api/tenants/:tenantId/*", - createWorkflowRunDeployAuth({ - db: deps.db, - authenticator: deps.authenticator, - }), - ); - wrapped.route("/", nativeApp); - return wrapped; -} diff --git a/apps/hub/test/boot-does-not-seed.test.ts b/apps/hub/test/boot-does-not-seed.test.ts index fc908f0b90..cdb8315d87 100644 --- a/apps/hub/test/boot-does-not-seed.test.ts +++ b/apps/hub/test/boot-does-not-seed.test.ts @@ -154,7 +154,6 @@ describeIfDb("createHub on a scratch database inserts no tenant", () => { hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, allowPlaintextSecrets: true, sidecarProvisioners: [], @@ -320,7 +319,6 @@ describeIfDb("boot with provider env vars plants nothing (CL-7579)", () => { hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, allowPlaintextSecrets: true, sidecarProvisioners: [], diff --git a/apps/hub/test/chat-mount.test.ts b/apps/hub/test/chat-mount.test.ts index 706391defe..16af81eaaf 100644 --- a/apps/hub/test/chat-mount.test.ts +++ b/apps/hub/test/chat-mount.test.ts @@ -30,7 +30,6 @@ const config: HubConfig = { hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the // credential-cipher seam, so the dev opt-in keeps boot working. diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 7c3484ec22..11964c6738 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -32,7 +32,6 @@ const config: HubConfig = { hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the // credential-cipher seam, so the dev opt-in keeps boot working. @@ -123,147 +122,6 @@ describeIfDb("extension mounting", () => { }); }); -describeIfDb( - "sign-in rate limiting is keyed on the target account, not client IP (CL-6494)", - () => { - // A forged/rotating `x-forwarded-for` is exactly what a caller reaching - // this hub over Railway's private network (bypassing the edge) can - // send on every request — the header this suite deliberately varies - // per attempt below to prove it buys the attacker nothing. - function signInAttempt( - hub: Awaited>, - email: string, - forgedIp: string, - password = "wrong-password", - ) { - return hub.app.request("/api/auth/sign-in/email", { - method: "POST", - headers: { - "content-type": "application/json", - "x-forwarded-for": forgedIp, - }, - body: JSON.stringify({ email, password }), - }); - } - - async function signUpUser( - hub: Awaited>, - email: string, - password: string, - ) { - const signUp = await hub.app.request("/api/auth/sign-up/email", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ email, password, name: "Test User" }), - }); - expect(signUp.status).toBe(200); - } - - test("a forged, rotating IP header per attempt cannot exceed the per-account budget", async () => { - const hub = await createHub({ - ...config, - signInRateLimit: { windowSeconds: 60, max: 2 }, - }); - closers.push(hub.close); - - // Same targeted account, a distinct forged source IP every attempt. - await signInAttempt(hub, "victim@example.com", "203.0.113.10"); - await signInAttempt(hub, "victim@example.com", "203.0.113.20"); - const throttled = await signInAttempt(hub, "victim@example.com", "203.0.113.30"); - - expect(throttled.status).toBe(429); - }); - - test("two genuinely different accounts get independent budgets", async () => { - const hub = await createHub({ - ...config, - signInRateLimit: { windowSeconds: 60, max: 1 }, - }); - closers.push(hub.close); - - // Exhausts alice's budget (max: 1), from the same source IP. - await signInAttempt(hub, "alice@example.com", "203.0.113.40"); - const throttledAlice = await signInAttempt(hub, "alice@example.com", "203.0.113.40"); - expect(throttledAlice.status).toBe(429); - - // bob's very first attempt is untouched by alice's exhausted budget. - const freshBob = await signInAttempt(hub, "bob@example.com", "203.0.113.40"); - expect(freshBob.status).not.toBe(429); - }); - - test("a rate-limited sign-in carries a retry hint and a human-readable message", async () => { - const hub = await createHub({ - ...config, - signInRateLimit: { windowSeconds: 60, max: 1 }, - }); - closers.push(hub.close); - - await signInAttempt(hub, "throttle-me@example.com", "203.0.113.50"); - const throttled = await signInAttempt(hub, "throttle-me@example.com", "203.0.113.60"); - - expect(throttled.status).toBe(429); - expect(throttled.headers.get("retry-after")).not.toBeNull(); - const body = (await throttled.json()) as { message: string }; - expect(body.message).toMatch(/\S/); - }); - - test("an attacker exhausting the account's budget with wrong guesses never blocks the owner's correct password, and a successful sign-in resets the budget", async () => { - const hub = await createHub({ - ...config, - signInRateLimit: { windowSeconds: 60, max: 2 }, - }); - closers.push(hub.close); - - const email = `owner-${crypto.randomUUID()}@example.com`; - const password = "correct-horse-battery"; - await signUpUser(hub, email, password); - - // Two wrong guesses exhaust the account's failure budget — exactly - // what an attacker who doesn't know the password sends. - await signInAttempt(hub, email, "203.0.113.70"); - await signInAttempt(hub, email, "203.0.113.71"); - const thirdWrongGuess = await signInAttempt(hub, email, "203.0.113.72"); - expect(thirdWrongGuess.status).toBe(429); - - // The account owner's correct password still succeeds: only - // failures ever consume budget, so a correct attempt is never - // gated on how many wrong guesses came before it. - const genuineSignIn = await signInAttempt(hub, email, "203.0.113.73", password); - expect(genuineSignIn.status).toBe(200); - - // That success cleared the bucket: the very next wrong guess is a - // fresh first failure, not an immediate 429 carried over from the - // attacker's earlier attempts. - const freshFailureAfterSuccess = await signInAttempt(hub, email, "203.0.113.74"); - expect(freshFailureAfterSuccess.status).not.toBe(429); - }); - - test("malformed sign-in bodies are never rate-limited together and never block a real account", async () => { - const hub = await createHub({ - ...config, - signInRateLimit: { windowSeconds: 60, max: 1 }, - }); - closers.push(hub.close); - - // No `email` field at all: doesn't parse, so it never touches the - // limiter — unrelated malformed requests must not share one bucket. - for (let attempt = 0; attempt < 5; attempt += 1) { - const malformed = await hub.app.request("/api/auth/sign-in/email", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ password: "whatever" }), - }); - expect(malformed.status).not.toBe(429); - } - - // A real account's first attempt is untouched by the malformed - // traffic above. - const fresh = await signInAttempt(hub, "never-touched@example.com", "203.0.113.90"); - expect(fresh.status).not.toBe(429); - }); - }, -); - describeIfDb("stock signup carries no email-verification gate", () => { test("a fresh self-serve signup can create a tenant without verifying anything", async () => { const hub = await createHub(config); diff --git a/apps/hub/test/config.test.ts b/apps/hub/test/config.test.ts index 228a7789fa..7a4495a2d9 100644 --- a/apps/hub/test/config.test.ts +++ b/apps/hub/test/config.test.ts @@ -30,7 +30,6 @@ describe("readHubConfig", () => { hubStaticDir: validEnv.HUB_STATIC_DIR, socialProviders: {}, signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, allowPlaintextSecrets: false, sidecarProvisioners: [{ id: "process" }], defaultSidecarProvisionerId: "process", @@ -55,7 +54,6 @@ describe("readHubConfig", () => { "hubDataDir", "hubStaticDir", "sessionSecret", - "signInRateLimit", "signupRateLimit", "socialProviders", "sidecarProvisioners", @@ -129,19 +127,6 @@ describe("readHubConfig", () => { expect(config.signupRateLimit).toEqual({ windowSeconds: 30, max: 2 }); }); - test("the sign-in rate limit is configurable and defaults well above better-auth's built-in 3-per-10-seconds special rule (CL-6494)", () => { - expect(readHubConfig(validEnv).signInRateLimit).toEqual({ - windowSeconds: 60, - max: 10, - }); - const config = readHubConfig({ - ...validEnv, - SIGNIN_RATE_LIMIT_WINDOW_SECONDS: "30", - SIGNIN_RATE_LIMIT_MAX: "2", - }); - expect(config.signInRateLimit).toEqual({ windowSeconds: 30, max: 2 }); - }); - test("huggingfaceOAuthClientId is absent by default", () => { expect(readHubConfig(validEnv).huggingfaceOAuthClientId).toBeUndefined(); }); diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index 2c144f5365..9d498cdb32 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -19,7 +19,6 @@ const baseConfig: HubConfig = { hubDataDir: ".data/hub", hubStaticDir: "apps/hub/public", signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, allowPlaintextSecrets: false, sidecarProvisioners: [], diff --git a/apps/hub/test/git-fixture.ts b/apps/hub/test/git-fixture.ts deleted file mode 100644 index 5bde502842..0000000000 --- a/apps/hub/test/git-fixture.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Shared git-fixture helper for apps/hub's own tests (CL-7372): -// `asset-service-factory.test.ts` and `hub-data-dir-git-safety.test.ts` -// each duplicated an mkdtemp-scratch-dir-plus-`git()` helper before this — -// this is the one shared copy. Every fixture repo lives in its own -// mkdtemp scratch directory outside this repository's own work tree, and -// every git invocation runs with GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE -// unset so it can never pick up an inherited pointer at real history. -import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; - -const scratch: string[] = []; -let repositoryRoot: string | undefined; - -function findRepositoryRoot(): string { - if (repositoryRoot === undefined) { - const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { - cwd: process.cwd(), - stdout: "pipe", - stderr: "pipe", - }); - if (result.exitCode !== 0) { - throw new Error(`git rev-parse --show-toplevel failed: ${result.stderr.toString()}`); - } - repositoryRoot = realpathSync(result.stdout.toString().trim()); - } - return repositoryRoot; -} - -/** Throws if `dir` sits inside this repository's own work tree — a git - * fixture must live in an mkdtemp scratch dir outside any real work tree, - * never inside the checkout a stray commit could actually corrupt. */ -function assertOutsideRepository(dir: string): void { - const resolved = realpathSync(dir); - const root = findRepositoryRoot(); - if (resolved === root || resolved.startsWith(`${root}${path.sep}`)) { - throw new Error( - `git fixture directory ${resolved} is inside the repository root ` + - `${root} — fixtures must live in an mkdtemp scratch dir outside ` + - `any work tree.`, - ); - } -} - -/** Creates a fresh mkdtemp scratch directory for a git fixture and tracks - * it for `cleanupGitFixtures`. */ -export function scratchGitFixtureDir(prefix: string): string { - const dir = mkdtempSync(path.join(tmpdir(), prefix)); - scratch.push(dir); - return dir; -} - -/** Runs `git` synchronously against `cwd`, guarded against ever touching - * this repository's own history: GIT_DIR/GIT_WORK_TREE/GIT_INDEX_FILE are - * stripped from the child's env, and `cwd` is asserted outside the - * repository root before the process ever spawns. */ -export function gitFixture(cwd: string, args: readonly string[]): void { - mkdirSync(cwd, { recursive: true }); - assertOutsideRepository(cwd); - const env = { ...process.env }; - delete env["GIT_DIR"]; - delete env["GIT_WORK_TREE"]; - delete env["GIT_INDEX_FILE"]; - env["GIT_AUTHOR_NAME"] = "safety-test"; - env["GIT_AUTHOR_EMAIL"] = "safety@test"; - env["GIT_COMMITTER_NAME"] = "safety-test"; - env["GIT_COMMITTER_EMAIL"] = "safety@test"; - // Test fixtures author their own throwaway identities and must never - // inherit the operator's ambient config: `core.hooksPath=` above, and - // `commit.gpgsign=false` here so a global `commit.gpgsign = true` can't - // hang (or sign) a fixture commit (CL-7492). - const result = Bun.spawnSync( - ["git", "-c", "core.hooksPath=", "-c", "commit.gpgsign=false", ...args], - { - cwd, - stdout: "pipe", - stderr: "pipe", - env, - }, - ); - if (result.exitCode !== 0) { - throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString()}`); - } -} - -/** Removes every scratch directory this module created. Call from the - * test file's own `afterAll`. */ -export function cleanupGitFixtures(): void { - for (const dir of scratch.splice(0)) { - rmSync(dir, { recursive: true, force: true }); - } -} diff --git a/apps/hub/test/mail-redelivery.test.ts b/apps/hub/test/mail-redelivery.test.ts deleted file mode 100644 index 0d2151f81e..0000000000 --- a/apps/hub/test/mail-redelivery.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - wireMailRedelivery, - type MailRedeliveryChatPlatform, - type MailRedeliverySidecarRouter, -} from "../src/mail-redelivery"; - -function createFakeSidecarRouter(opts: { - routableAddresses?: string[]; - routeMailResult?: boolean; -}): MailRedeliverySidecarRouter & { - emit(event: { rawMessage: string; recipients: string[] }): void; - routeMailCalls: { agentAddress: string; rawMessage: string }[]; -} { - const listeners = new Set<(event: { rawMessage: string; recipients: string[] }) => void>(); - const routeMailCalls: { agentAddress: string; rawMessage: string }[] = []; - return { - routeMailCalls, - events: { - on(_type, listener) { - listeners.add(listener); - return () => listeners.delete(listener); - }, - }, - getRoutableAddresses: () => opts.routableAddresses ?? [], - routeMail: (agentAddress, rawMessage) => { - routeMailCalls.push({ agentAddress, rawMessage }); - return opts.routeMailResult ?? true; - }, - emit(event) { - for (const listener of listeners) listener(event); - }, - }; -} - -function createFakeChatPlatform(opts: { - ensureAwakeShouldReject?: boolean; -}): MailRedeliveryChatPlatform & { ensureAwakeCalls: string[] } { - const ensureAwakeCalls: string[] = []; - return { - ensureAwakeCalls, - async ensureAwake(address) { - ensureAwakeCalls.push(address); - if (opts.ensureAwakeShouldReject) { - throw new Error("not a chat resident"); - } - }, - }; -} - -async function flush(): Promise { - await new Promise((resolve) => setTimeout(resolve, 0)); - await new Promise((resolve) => setTimeout(resolve, 0)); -} - -describe("wireMailRedelivery", () => { - test("does nothing extra for an already-routable recipient, but still retries delivery", async () => { - const sidecarRouter = createFakeSidecarRouter({ - routableAddresses: ["ins_1@ten1.test"], - }); - const chatPlatform = createFakeChatPlatform({}); - wireMailRedelivery({ sidecarRouter, chatPlatform }); - - sidecarRouter.emit({ - rawMessage: "raw-mail-bytes", - recipients: ["ins_1@ten1.test"], - }); - await flush(); - - expect(chatPlatform.ensureAwakeCalls).toEqual([]); - expect(sidecarRouter.routeMailCalls).toEqual([ - { agentAddress: "ins_1@ten1.test", rawMessage: "raw-mail-bytes" }, - ]); - }); - - test("wakes a non-routable recipient before retrying delivery", async () => { - const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); - const chatPlatform = createFakeChatPlatform({}); - wireMailRedelivery({ sidecarRouter, chatPlatform }); - - sidecarRouter.emit({ - rawMessage: "raw-mail-bytes", - recipients: ["ins_2@ten1.test"], - }); - await flush(); - - expect(chatPlatform.ensureAwakeCalls).toEqual(["ins_2@ten1.test"]); - expect(sidecarRouter.routeMailCalls).toEqual([ - { agentAddress: "ins_2@ten1.test", rawMessage: "raw-mail-bytes" }, - ]); - }); - - test("still retries delivery when the recipient is not a chat resident ensureAwake can wake", async () => { - const sidecarRouter = createFakeSidecarRouter({ routableAddresses: [] }); - const chatPlatform = createFakeChatPlatform({ - ensureAwakeShouldReject: true, - }); - wireMailRedelivery({ sidecarRouter, chatPlatform }); - - sidecarRouter.emit({ - rawMessage: "raw-mail-bytes", - recipients: ["run_task_1@ten1.test"], - }); - await flush(); - - expect(chatPlatform.ensureAwakeCalls).toEqual(["run_task_1@ten1.test"]); - expect(sidecarRouter.routeMailCalls).toEqual([ - { agentAddress: "run_task_1@ten1.test", rawMessage: "raw-mail-bytes" }, - ]); - }); - - test("handles multiple recipients on the same event independently", async () => { - const sidecarRouter = createFakeSidecarRouter({ - routableAddresses: ["ins_routable@ten1.test"], - }); - const chatPlatform = createFakeChatPlatform({}); - wireMailRedelivery({ sidecarRouter, chatPlatform }); - - sidecarRouter.emit({ - rawMessage: "raw-mail-bytes", - recipients: ["ins_routable@ten1.test", "ins_asleep@ten1.test"], - }); - await flush(); - - expect(chatPlatform.ensureAwakeCalls).toEqual(["ins_asleep@ten1.test"]); - expect(sidecarRouter.routeMailCalls.map((c) => c.agentAddress).sort()).toEqual([ - "ins_asleep@ten1.test", - "ins_routable@ten1.test", - ]); - }); -}); diff --git a/apps/hub/test/relaunch-sweep-close.test.ts b/apps/hub/test/relaunch-sweep-close.test.ts index 751cf74407..0707d37018 100644 --- a/apps/hub/test/relaunch-sweep-close.test.ts +++ b/apps/hub/test/relaunch-sweep-close.test.ts @@ -34,7 +34,6 @@ const config: HubConfig = { hubDataDir: path.join(root, "data"), hubStaticDir: staticDir, signupRateLimit: { windowSeconds: 60, max: 5 }, - signInRateLimit: { windowSeconds: 60, max: 10 }, socialProviders: {}, sidecarProvisioners: [], allowPlaintextSecrets: true, diff --git a/apps/hub/test/shutdown-drain.test.ts b/apps/hub/test/shutdown-drain.test.ts deleted file mode 100644 index 9b6f44e4c4..0000000000 --- a/apps/hub/test/shutdown-drain.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -// A real Bun.serve() with a live websocket or SSE stream reproduces the -// drain hang that unit tests on drainWithTimeout (src/shutdown.test.ts) -// cannot see: `server.stop()` with no argument waits for those connections -// to close on their own, and they never do. The hub drain waits for the -// Hono in-flight request counter instead, then force-stops so lingering -// streams cannot turn a deploy into a timeout fault. -import { afterEach, describe, expect, test } from "bun:test"; -import { Hono } from "hono"; -import { upgradeWebSocket, websocket } from "hono/bun"; -import { streamSSE } from "hono/streaming"; - -import { - createInFlightRequestTracker, - withInFlightRequestTracking, -} from "../src/in-flight-requests"; -import { drainHubServer, drainWithTimeout, shutdownHub } from "../src/shutdown"; - -type Serving = { - port: number; - stop: (force?: boolean) => Promise; -}; - -const cleanups: (() => void)[] = []; - -afterEach(() => { - for (const cleanup of cleanups.splice(0)) cleanup(); -}); - -function serve(app: Hono): Serving { - const server = Bun.serve({ - port: 0, - idleTimeout: 0, - fetch: app.fetch, - websocket, - }); - const port = server.port; - if (port === undefined) { - throw new Error("Bun.serve did not bind a port"); - } - cleanups.push(() => { - void server.stop(true); - }); - return { - port, - stop: (force = false) => server.stop(force), - }; -} - -function startTracked(): { - inner: Hono; - tracker: ReturnType; - serve: () => Serving; -} { - const tracker = createInFlightRequestTracker(); - const inner = new Hono(); - return { - inner, - tracker, - serve: () => serve(withInFlightRequestTracking(inner, tracker)), - }; -} - -async function openWebSocket(port: number, path = "/ws"): Promise { - const client = new WebSocket(`ws://localhost:${String(port)}${path}`); - cleanups.push(() => client.close()); - await new Promise((resolve, reject) => { - client.addEventListener("open", () => resolve(), { once: true }); - client.addEventListener("error", reject, { once: true }); - }); - return client; -} - -describe("hub shutdown drain against a real server", () => { - test("server.stop() with no argument never drains while a websocket stays open", async () => { - const app = new Hono(); - app.get( - "/ws", - upgradeWebSocket(() => ({ - onMessage() { - // The socket just stays open. - }, - })), - ); - const server = serve(app); - await openWebSocket(server.port); - - const outcome = await drainWithTimeout(() => server.stop(), 200); - expect(outcome).toEqual({ kind: "timed-out" }); - }); - - test("drainHubServer force-stops after in-flight handlers finish, even with a live websocket", async () => { - const { inner, tracker, serve: start } = startTracked(); - let handlerFinished = false; - inner.get("/slow", async () => { - await new Promise((resolve) => setTimeout(resolve, 80)); - handlerFinished = true; - return new Response("ok"); - }); - inner.get( - "/ws", - upgradeWebSocket(() => ({ - onMessage() { - // The socket just stays open. - }, - })), - ); - const server = start(); - await openWebSocket(server.port); - - const inFlight = fetch(`http://localhost:${String(server.port)}/slow`); - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(tracker.pending).toBe(1); - - const outcome = await drainWithTimeout( - () => - drainHubServer({ - whenRequestsIdle: () => tracker.whenIdle(), - stop: (force) => server.stop(force), - close: () => Promise.resolve(), - }), - 1_000, - ); - expect(outcome).toEqual({ kind: "drained" }); - expect(handlerFinished).toBe(true); - expect(tracker.pending).toBe(0); - - await inFlight.catch(() => undefined); - }); - - test("a live SSE stream does not hold the in-flight count or fail drain", async () => { - const { inner, tracker, serve: start } = startTracked(); - inner.get("/stream", (c) => - streamSSE(c, async (stream) => { - await stream.writeSSE({ data: "hello" }); - await new Promise(() => undefined); - }), - ); - const server = start(); - const response = await fetch(`http://localhost:${String(server.port)}/stream`); - expect(response.ok).toBe(true); - expect(tracker.pending).toBe(0); - - let exitCode: number | undefined; - const reported: unknown[] = []; - await shutdownHub({ - drain: () => - drainHubServer({ - whenRequestsIdle: () => tracker.whenIdle(), - stop: (force) => server.stop(force), - close: () => Promise.resolve(), - }), - timeoutMs: 1_000, - exit: (code) => { - exitCode = code; - }, - report: (error) => { - reported.push(error); - return "unused-ref-id"; - }, - }); - expect(exitCode).toBe(0); - expect(reported).toEqual([]); - - await response.body?.cancel().catch(() => undefined); - }); -}); diff --git a/packages/evals/src/index.ts b/packages/evals/src/index.ts index d762e5a803..69c66df163 100644 --- a/packages/evals/src/index.ts +++ b/packages/evals/src/index.ts @@ -64,7 +64,7 @@ export { export type { EvalRunRecord, EvalRunStore } from "./store/store.ts"; export { createPostgresEvalRunStore } from "./store/pg-store.ts"; export { applyEvalsMigrations } from "./store/migrations.ts"; -export { bootMyraTarget } from "./targets/real-target.ts"; +export { bootMyraTarget, createEvalAssetService } from "./targets/real-target.ts"; export type { EvalApiResult, EvalHubHandle, diff --git a/packages/evals/src/targets/real-target.ts b/packages/evals/src/targets/real-target.ts index 4c58b633d3..a8ca0141d5 100644 --- a/packages/evals/src/targets/real-target.ts +++ b/packages/evals/src/targets/real-target.ts @@ -25,6 +25,9 @@ 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, @@ -106,8 +109,8 @@ export interface MyraTargetInfra { * 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 `apps/hub`'s - * `createBootAssetWiring`); `fakeReceipts` hands back every call 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: { @@ -121,6 +124,24 @@ export interface MyraTargetInfra { * 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 diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index 5d3fc499aa..aaf3ca5179 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -19,11 +19,7 @@ # `occurrence` is this evidence string's 1-based rank among matches in # the same file — line numbers aren't used as the key because they # drift as unrelated code around a catch changes. -apps/hub/src/grant-allowance.ts 1 return null; -apps/hub/src/index.ts 1 email = undefined; apps/hub/src/index.ts 1 sidecarAllocationLog.error`Sidecar allocation reconciliation failed: ${error instanceof Error ? error.message : String(error)}`; -apps/hub/src/mail-redelivery.ts 1 log.warn`could not wake ${recipient} ahead of redelivery: ${ -apps/hub/src/shutdown.ts 1 return { kind: "failed", error }; apps/sidecar/src/atomic-write.ts 1 logger.warn`parent-dir fsync failed for ${path}; durability is degraded but the file is renamed and fsynced — ${err instanceof Error ? err.message : String(err)}`; apps/sidecar/src/concurrency.ts 1 failures.push({ item, error }); apps/sidecar/src/conversation-state.ts 1 logger.warn`connector route for ${opts.agentKey} could not parse the inbound sender; leaving the thread unadvanced: ${cause instanceof Error ? cause.message : String(cause)}`; diff --git a/scripts/evals-run.ts b/scripts/evals-run.ts index 2882c3440f..cd6226c4e5 100644 --- a/scripts/evals-run.ts +++ b/scripts/evals-run.ts @@ -27,6 +27,7 @@ import { applyEvalsMigrations, bootMyraTarget, captureWorldSnapshot, + createEvalAssetService, createPostgresEvalRunStore, GITHUB_MCP_FAKE_RECORDING, renderResultsMarkdown, @@ -37,7 +38,6 @@ import { type RunConfig, } from "@corbits/evals"; import { createDB } from "@intx/db"; -import { createBootAssetWiring } from "../apps/hub/src/asset-service-factory.ts"; import { resetSchema, setupDatabase } from "./db-setup.ts"; import { api, @@ -82,14 +82,14 @@ const infra: MyraTargetInfra = { startHub, startSidecar, // World scorers read real state (CL-6404): an `AssetService` over the - // scratch hub's own data dir — built by the hub's OWN boot factory - // (`createBootAssetWiring`), never a parallel implementation — plus a - // drizzle handle on the same scratch database. + // 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 createBootAssetWiring({ + const assetService = await createEvalAssetService({ db, dataDir: hubDataDir, });