diff --git a/apps/hub/package.json b/apps/hub/package.json index af65b309b6..8b50861412 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -14,16 +14,13 @@ "dependencies": { "@chat-adapter/state-memory": "^4.35.0", "@corbits/agent-directory": "workspace:*", - "@corbits/agent-runtime": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#5309a15d4aa504f132c54b4da08bb672b4903873", "@corbits/attio-task-agent-workflow": "workspace:*", - "@corbits/chat": "workspace:*", "@corbits/code-review-workflow": "workspace:*", "@corbits/codex-provider": "workspace:*", "@corbits/collateral-generation-workflow": "workspace:*", - "@corbits/commands": "workspace:*", "@corbits/connections": "workspace:*", "@corbits/credential-providers": "workspace:*", "@corbits/cron": "workspace:*", @@ -49,7 +46,6 @@ "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", - "@corbits/turn-artifacts": "workspace:*", "@corbits/url-path": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@corbits/workbench-digest-workflow": "workspace:*", diff --git a/apps/hub/src/crypto-provider-cache-wiring.test.ts b/apps/hub/src/crypto-provider-cache-wiring.test.ts deleted file mode 100644 index ceb57c4dce..0000000000 --- a/apps/hub/src/crypto-provider-cache-wiring.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Pins CL-7284: createHub constructs one CryptoProviderCache and hands -// that same reference to every mail sender. A second constructor would -// still typecheck; this scan is the pin. crypto-cache.test.ts covers the -// Map; platform-adapter.test.ts covers chat injection only. -// -// CL-4455 removed the custom routine scheduler's `createHubRoutineLauncher` -// call site: a native ScheduleTrigger now fires through -// `triggerNativeWorkflowRoutineRun` (native-workflow-routine-launch.ts), -// which signs its trigger mail with a freshly generated per-call keypair -// rather than the shared cache — there is no longer a fourth mail-sender -// call site to pin here. -import { describe, expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "node:fs"; -import path from "node:path"; - -const HUB_DIR = path.join(import.meta.dir, ".."); -const HUB_INDEX = path.join(import.meta.dir, "index.ts"); - -function productionTsFilesUnder(dir: string): string[] { - return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) { - if (entry.name === "node_modules") return []; - return productionTsFilesUnder(full); - } - return entry.name.endsWith(".ts") && !entry.name.endsWith(".test.ts") ? [full] : []; - }); -} - -/** The full `callee(...)` text of the first call in `source`. */ -function firstCall(source: string, callee: string): string { - const token = `${callee}(`; - const start = source.indexOf(token); - if (start < 0) { - throw new Error(`expected ${callee}(...) in ${path.relative(HUB_DIR, HUB_INDEX)}`); - } - let depth = 1; - let i = start + token.length; - while (i < source.length && depth > 0) { - const ch = source[i]; - if (ch === "(") depth += 1; - else if (ch === ")") depth -= 1; - i += 1; - } - return source.slice(start, i); -} - -describe("hub crypto-provider cache wiring", () => { - test("createHub constructs one cache and passes it to every mail sender", () => { - const sites = productionTsFilesUnder(HUB_DIR).flatMap((file) => { - const matches = readFileSync(file, "utf8").match(/createCryptoProviderCache\s*\(/g); - return matches === null ? [] : matches.map(() => file); - }); - expect(sites).toEqual([HUB_INDEX]); - - const source = readFileSync(HUB_INDEX, "utf8"); - const assigned = /const\s+(\w+)\s*=\s*createCryptoProviderCache\s*\(\s*\)/.exec(source)?.[1]; - if (assigned === undefined) { - throw new Error("createHub must assign createCryptoProviderCache() to a const"); - } - - expect(firstCall(source, "createHubChatPlatform")).toContain(assigned); - expect(firstCall(source, "launchWebhookTrigger")).toContain(`cryptoProviderCache: ${assigned}`); - // The planner draft route's `runOneShotPrompt` mail sender was cut - // with the stock-hub cutover (its inventory read through the deleted - // memory/skills mounts), so only the two senders above remain pinned. - }); -}); diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index ed7dabec03..c5b3ba2d45 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -8,7 +8,6 @@ import { mkdirSync } from "node:fs"; import path from "node:path"; import { - createApprovalStore, createDB, createGrantStore, createPrincipalKeyStore, @@ -16,14 +15,8 @@ import { createWorkflowRunDispatchStore, type DB, } from "@intx/db"; -import { - sidecar, - tenant as tenantTable, - user as userTable, - workflowDefinition, - workflowRun, -} from "@intx/db/schema"; -import { and, eq, inArray } from "drizzle-orm"; +import { sidecar, tenant as tenantTable, workflowDefinition, workflowRun } from "@intx/db/schema"; +import { and, eq } from "drizzle-orm"; import { createEnvKeyCredentialCipher, createNoopCredentialCipher, sha256 } from "@intx/crypto"; import type { CredentialCipher } from "@intx/types"; import { @@ -37,48 +30,7 @@ import { // projection `installAndApproveWorkflowSource` returns on `grants_not_approved` // — the gate itself only stamps this hash on the `ok:true` arm. -import { isPlannerCreatedDefinitionName } from "@corbits/agent-directory"; - -import { - DEFAULT_TURN_CLAIM_TTL_MS, - createWorkbenchHostInferencePreferencesResolver, - createWorkbenchSubscriberRegistry, - createTurnCancelRegistry, - createChatRoutes, - createDrizzleBlockResponseStore, - createDrizzleChatStore, - createDrizzleNativePrincipalStore, - createDrizzlePinStore, - createDrizzleReactionStore, - createDrizzleRoomMessageStore, - createDrizzleThreadStore, - createHubChatPlatform, - createRelaunchNoticePoster, - createRunTriggerClient, - createWorkflowParticipantRoutes, - isWorkbenchHostDefinitionName, - listConnectedProviders, - listDefaultInferencePreferences, - startWorkflowCommand, - settleConnectedService, - createCryptoProviderCache, - tagCredentialCipher, - verifyInternalRunTriggerToken, -} from "@corbits/chat"; -import { - createArtifactDeliveryHandler, - createChatOrchestrator, - createDrizzleAgentTurnStore, - createDrizzleClientIdStore, - createDrizzleTurnMailCorrelationStore, - createDrizzleWriteClaimStore, - createInMemoryTurnClaimStore, - createWorkbenchTurnQueue, -} from "@corbits/agent-runtime"; -import { createDrizzleMailboxWriter, type MailboxFanoutDeps } from "@corbits/chat/mailbox-fanout"; -import type { RelaunchNoticePort } from "@corbits/chat"; import { reportError } from "@corbits/error-sink"; -import type { FinalizedTurnToolCall } from "@corbits/turn-artifacts"; import { decodedOrNull } from "@corbits/url-path"; import { createInMemoryMailboxEventBus, @@ -91,18 +43,14 @@ import { createHubMailboxAuthorizeSender, createHubPersistMailWithSessionEnsure, } from "./mailbox-persist"; -import { - createCommandRegistry, - createCommandRoutes, - createWorkflowCommandPlugin, -} from "@corbits/commands"; import { createDrizzleWebhookTriggerStore, createWebhookIngressRoutes, createWebhookTriggerRoutes, launchWebhookTrigger, + createCryptoProviderCache, } from "@corbits/webhook-triggers"; -import { ensureRunSession, isConversationalWorkflowName } from "@corbits/workflows"; +import { ensureRunSession } from "@corbits/workflows"; import { createSidecarProvisioner as createE2BSidecarProvisioner, readProvisionerConfig as readE2BProvisionerConfig, @@ -156,7 +104,6 @@ import { } from "@corbits/artifact-ui/kind-filter"; import { createConnectionRoutes, - isInferenceProvider, createMcpOAuthRoutes, createMcpServerRoutes, createOAuthConnectRoutes, @@ -166,12 +113,8 @@ import { DEFAULT_RETURN_PATH_ALLOWLIST, listMcpServerConnections, } from "@corbits/connections"; -import type { ServiceConnectedHook } from "@corbits/connections"; import { CONNECTOR_REGISTRY, MCP_PRESETS } from "./native-connector-registry"; -import { - createProviderHealthPort, - createProviderHealthStore, -} from "@corbits/connections/provider-health"; +import { createProviderHealthStore } from "@corbits/connections/provider-health"; import { createWorkflowAuthorRegistry, createWorkflowAuthorRoutes } from "@corbits/workflows"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; @@ -375,7 +318,7 @@ export function hubCredentialCipher( config: HubConfig, log: ReturnType, ): CredentialCipher { - return tagCredentialCipher(credentialCipherFrom(config, log)); + return credentialCipherFrom(config, log); } /** @@ -536,10 +479,6 @@ export async function createHub(config: HubConfig) { principalKeyStore, grantStore: createGrantStore(db), }); - // Hoisted ahead of their other uses below (chat routes, the room - // timeline store at CL-6327). - const chatStore = createDrizzleChatStore(db); - const roomMessages = createDrizzleRoomMessageStore(db); const lookups = { ...baseLookups, materializeMailTriggeredRunGrants: mailTriggeredRunGrants, @@ -597,13 +536,6 @@ export async function createHub(config: HubConfig) { }); const isSidecarRoutable = (address: string) => sidecarRouter.getRoutableAddresses().includes(address); - // A finalized turn's persisted-artifact tool-call results become - // delivery file parts (CL-6000) via `createArtifactDeliveryHandler`, - // built once `chatStore`/`chatPlatform` exist further down this - // composition. `onTurnFinalized` itself must be supplied at - // `createEventCollectorRegistry` construction time, before those - // deps exist, so this indirection ref is set once they do and every - // call before that point is a harmless no-op. // Process-lifetime provider-health signal (CL-6092): the one store // both the chat orchestrator's classified-failure port and // `GET .../connections/provider-health` read/write, so a runtime @@ -611,16 +543,6 @@ export async function createHub(config: HubConfig) { // very next poll. In-memory by design — see `provider-health.ts`'s own // header for why this never needs to survive a restart. const providerHealthStore = createProviderHealthStore(); - const artifactDeliveryHandlerRef: { - current?: ( - agentAddress: string, - turn: { - turnId: string; - toolCalls: FinalizedTurnToolCall[]; - errors: readonly { category: string; message: string }[]; - }, - ) => void; - } = {}; // Package-owned artifacts tables (CL-8188): idempotent, advisory-locked, // safe on every boot of every replica — see @corbits/artifacts' README. await runArtifactMigrations(db); @@ -638,9 +560,6 @@ export async function createHub(config: HubConfig) { // Stamping the anchor terminal per fire would brick the schedule, // so a warm-kept fire settles through the runOutcomeStatus / // FIRE_RUNNING_WINDOW_MS display-side reading instead. - onTurnFinalized: (agentAddress, turn) => { - artifactDeliveryHandlerRef.current?.(agentAddress, turn); - }, }); // Wraps `dispatch` for CL-7480: a run's turn events can start arriving // before anything else ever recorded its session — the first inbound @@ -690,24 +609,6 @@ export async function createHub(config: HubConfig) { db, eventCollectors, }); - // 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 @@ -832,42 +733,7 @@ export async function createHub(config: HubConfig) { workflowDispatchService, credentialCipher, principalKeyStore, - // `@intx/hub-api`'s `GetSession` is a deliberately pluggable seam - // (its own doc comment: "so a third-party identity provider can be - // plugged in"), and this composition root owns it. `@corbits/chat`'s - // run-trigger client (CL-7490) has no browser cookie to present for - // most of its calls — mention fan-out, a relaunch resend, the - // mailbox-fanout persist seam all run with no inbound HTTP request - // in scope — so it signs a short-lived internal token instead (see - // `@corbits/chat`'s `run-trigger-internal-auth.ts`) naming the - // better-auth user it authenticates as. This checks that token - // FIRST and only falls through to the real cookie-session path when - // the header is absent; a PRESENT-but-invalid token fails closed - // (401 via a null session) rather than silently retrying as a - // session, the same posture `workflow-run-deploy-auth` documents for - // its own bearer mirror in `vendor/intx/hub-api`. getSession: async (headers) => { - const internalRunTriggerToken = headers.get("x-corbits-internal-run-trigger"); - if (internalRunTriggerToken !== null) { - const userId = verifyInternalRunTriggerToken(config.sessionSecret, internalRunTriggerToken); - if (userId === null) return null; - const userRow = await db.query.user.findFirst({ - where: eq(userTable.id, userId), - }); - if (userRow === undefined) return null; - const now = new Date(); - return { - user: userRow, - session: { - id: `internal_run_trigger_${userRow.id}`, - createdAt: now, - updatedAt: now, - userId: userRow.id, - expiresAt: new Date(now.getTime() + 30_000), - token: internalRunTriggerToken, - }, - }; - } const result = await auth.api.getSession({ headers }); return result ? { user: result.user, session: result.session } : null; }, @@ -909,357 +775,8 @@ export async function createHub(config: HubConfig) { // 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); - const threadStore = createDrizzleThreadStore(db); - const blockResponseStore = createDrizzleBlockResponseStore(db); - const reactionStore = createDrizzleReactionStore(db); - const pinStore = createDrizzlePinStore(db); - // Durable redelivery-dedup for the finalized-turn write surfaces - // (CL-6039) — see `WriteClaimStore`'s own doc comment. Same `db` - // handle as every other Drizzle store above, never a second - // connection. - const writeClaims = createDrizzleWriteClaimStore(db); - // Durable dispatch-mail -> source-message correlation (CL-6314) — the - // record the reply path reads back to thread an agent's answer under - // the message that woke its turn. Same `db` handle, same reasoning. - const turnMailCorrelation = createDrizzleTurnMailCorrelationStore(db); - // The chat platform's invite-launch fallback: a definition with no - // model requirements of its own resolves the tenant-catalog default. - const chatHostInferencePreferencesResolver = createWorkbenchHostInferencePreferencesResolver( - (tenantId) => listDefaultInferencePreferences(db, tenantId), - ); - // Where a relaunch announces itself in the room (see `@corbits/chat`'s - // `relaunch-notice.ts`). Armed further down, once the room-message - // store the poster writes through exists — the platform that fires - // notices has to be constructed first, since the sweep that triggers - // most of them hangs off it. - const relaunchNoticeRef: RelaunchNoticePort = {}; - // One CryptoProviderCache for the whole hub process (CL-7284). Chat - // sendMail keys by chat id; webhook, routine, and agent-definition - // drafting first-turn mail key by the launched run's instance id. New - // chats and run ids are `run_` (`generateId("workflowRun")`); - // older chats are `ins_` (`generateId("instance")`). They share a - // string shape — a second cache for the same id would mint a different - // signing key. generateId uniqueness keeps distinct entities from - // colliding; sharing the cache keeps the same entity from rotating keys - // across consumers. TTL-bounded by `createCryptoProviderCache` itself - // (CL-7223). + const grantStore = createGrantStore(db); const cryptoProviders = createCryptoProviderCache(); - // Delivers chat's outbound run mail through Interchange's own - // workflow-run mail-trigger route (CL-7490), in-process against this - // same `app` — see `@corbits/chat`'s `run-trigger-client.ts` and the - // `getSession` internal-token check above for how it authenticates. - const runTrigger = createRunTriggerClient({ - app, - internalAuthSecret: config.sessionSecret, - }); - const chatPlatform = createHubChatPlatform({ - db, - runTrigger, - repoStore: agentRepoStore.repoStore, - sidecarRouter, - eventCollectors, - credentialCipher, - toolGrantsForPins, - cryptoProviders, - workflowAllocationService, - // 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 - // tenant-catalog default, instead of 409ing `not_launchable`. - workbenchHostInferencePreferences: chatHostInferencePreferencesResolver, - relaunchNotice: relaunchNoticeRef, - }); - // 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 - // router bridges it onto `/workbenches/:id/stream`, the - // workflow-command path publishes through the same instance so a - // command-started workflow's join event reaches an open stream - // immediately (exactly like `POST .../invite`'s does), and the - // orchestrator publishes every message it posts onto a chat's - // timeline. - const chatSubscribers = createWorkbenchSubscriberRegistry(); - // One in-flight turn per chat (CL-6331), shared by every send - // surface below the same way `chatSubscribers` is: the chat - // router and the workflow-participant router (a workflow child's own - // sends) all route through this one queue, - // so a burst arriving through any of them for the same chat - // still serializes against the others rather than each queue only - // seeing its own slice of the traffic. - const turnQueue = createWorkbenchTurnQueue({ - claims: createInMemoryTurnClaimStore({ ttlMs: DEFAULT_TURN_CLAIM_TTL_MS }), - publish: chatSubscribers.publish, - }); - // The live abort seam a running turn is reachable through (CL-7201) — - // shared the same way `turnQueue` above is, so a cancel request lands - // wherever a chat's turn was actually dispatched from. - const turnCancellation = createTurnCancelRegistry(); - relaunchNoticeRef.current = createRelaunchNoticePoster({ - store: chatStore, - roomMessages, - publish: chatSubscribers.publish, - }); - // Built once, beside the platform, for the process's lifetime: turns - // an invited agent's `connector.reply` events into chat messages, - // and a gate-blocked run's approval park into an in-chat approve - // block, by subscribing to the sidecar's own event stream, replacing - // the old per-agent reply-bridge machinery armed (and re-armed) from - // inside the routes. `approvals` is the same `ApprovalStore` the - // platform's own approve/reject routes read and write — this - // orchestrator only ever reads it. - const agentTurns = createDrizzleAgentTurnStore(db); - const chatOrchestratorDeps: Parameters[0] = { - db, - agentTurns, - store: chatStore, - roomMessages, - publish: chatSubscribers.publish, - platform: chatPlatform, - events: sidecarRouter.events, - approvals: createApprovalStore(db), - claims: writeClaims, - threads: threadStore, - turnMailCorrelation, - connectorRegistry: CONNECTOR_REGISTRY, - }; - const chatOrchestrator = createChatOrchestrator(chatOrchestratorDeps); - // CL-6644: a loud, unconditional boot confirmation that message intake - // is actually wired — a composition-root mistake here (an import - // dropped, a construction reordered, an argument omitted) type-checks - // fine but produces a hub that accepts messages into a void: no - // dispatch, no error, no notice, just a message that persists and is - // never asked of anyone. This can't detect every such mistake (the - // pieces below are non-optional local bindings, not feature-flagged), - // but it turns "intake is wired" from an assumption nothing checks - // into a line every boot log carries — the next investigation starts - // by grepping for this instead of re-deriving the whole call chain. - getLogger(["hub", "chat-intake"]).info( - "Chat message intake wired: turnQueue={hasTurnQueue} " + - "chatOrchestrator={hasOrchestrator} chatPlatform={hasPlatform}", - { - hasTurnQueue: turnQueue !== undefined, - hasOrchestrator: chatOrchestrator !== undefined, - hasPlatform: chatPlatform !== undefined, - }, - ); - // A room participant that died with its sidecar is otherwise silently - // dead until somebody writes into it, and the turn the crash - // interrupted never surfaces at all — the run that died never sends - // the `message.run.ended` the orchestrator's turn-drop notice hangs - // off. The sweep finds those runs and relaunches each one, posting - // its notice. - // - // A series of passes rather than one, because "this run is dead" is - // not knowable at the instant the execution plane comes back: the - // terminal event is committed to the run's durable log by the dying - // sidecar and reaches `workflow_run.status` only once the restarted - // sidecar has packed it back to the hub, seconds later. The series is - // bounded and re-armed by a sidecar disconnect, which is the one - // event that can newly orphan a room. - const relaunchSweepLog = getLogger(["chat", "relaunch-sweep"]); - const RELAUNCH_SWEEP_DELAYS_MS = [0, 2_000, 5_000, 15_000, 45_000]; - // Bumped by every reschedule so a pass still in flight from the - // previous series retires instead of continuing beside the new one. - let relaunchSweepSeries = 0; - let relaunchSweepTimer: ReturnType | undefined; - function runNextRelaunchSweepPass(series: number, pass: number): void { - const delay = RELAUNCH_SWEEP_DELAYS_MS[pass]; - if (delay === undefined || series !== relaunchSweepSeries) return; - const timer = setTimeout(() => { - void chatPlatform - .sweepTerminalRuns() - .catch((cause: unknown) => { - relaunchSweepLog.error`relaunch sweep pass failed: ${ - cause instanceof Error ? cause.message : String(cause) - }`; - }) - .finally(() => { - runNextRelaunchSweepPass(series, pass + 1); - }); - }, delay); - timer.unref?.(); - relaunchSweepTimer = timer; - } - function scheduleRelaunchSweep(): void { - clearTimeout(relaunchSweepTimer); - relaunchSweepSeries += 1; - runNextRelaunchSweepPass(relaunchSweepSeries, 0); - } - scheduleRelaunchSweep(); - sidecarRouter.events.on("sidecar.disconnect", () => { - scheduleRelaunchSweep(); - }); - // Now that `chatStore`/`chatPlatform` exist, arm the finalized-turn - // artifact-delivery ref declared beside `eventCollectors` above. - const artifactDeliveryHandlerDeps: Parameters[0] = { - db, - store: chatStore, - roomMessages, - publish: chatSubscribers.publish, - platform: chatPlatform, - events: sidecarRouter.events, - approvals: createApprovalStore(db), - claims: writeClaims, - agentTurns, - threads: threadStore, - turnMailCorrelation, - providerHealth: createProviderHealthPort(providerHealthStore), - listConnectedProviders: (tenantId) => listConnectedProviders(db, tenantId), - }; - artifactDeliveryHandlerRef.current = createArtifactDeliveryHandler(artifactDeliveryHandlerDeps); - // The "/name args" and "@name args" command registry: every tenant's - // invitable workflow definitions, exposed as commands by - // `createWorkflowCommandPlugin`, resolved fresh on every list/lookup - // so a newly-deployed definition is a command on its very next use — - // no re-registration step. `startWorkflow` is `@corbits/chat`'s own - // `startWorkflowCommand`, sharing the exact invite-then-send core - // `POST .../invite` uses, including its live `publish` — bound to - // `chatSubscribers` above, the same registry `createChatRoutes` - // is given below. - const commandRegistry = createCommandRegistry(); - commandRegistry.registerCommandPlugin( - createWorkflowCommandPlugin({ - listInvitableDefinitions: (tenantId) => chatPlatform.listInvitableDefinitions(tenantId), - startWorkflow: (input) => - startWorkflowCommand( - { - store: chatStore, - platform: chatPlatform, - roomMessages, - publish: chatSubscribers.publish, - }, - input, - ), - }), - ); - - // The one "is this a conversational agent?" ruling, shared by every - // picker that offers agents to a person and by a routine's `"agent"`-kind - // trigger-field validation below: a catalog workflow whose entry says - // `conversational: false` (routine/automation material — Echo, "Last 30 - // days research report", …) and chat-host anchor definitions - // (chat's own plumbing, never a person-facing agent) belong in neither. - // `isConversationalWorkflowName`, not `isAutomatableWorkflowName`: a - // non-automatable utility workflow (Echo, the research report a routine - // delivers) is still not conversational, and the old automatable-only - // check let both leak into every agent picker (CL-6649). - const isConversationalAgentDefinition = (definition: { name: string }) => - isConversationalWorkflowName(definition.name) && - !isWorkbenchHostDefinitionName(definition.name); - - // A second, narrower ruling layered on top of the ruling above, for - // LISTING/PICKER surfaces only. A planner-created agent (the - // now-deleted tasks primitive's planner `{create}` branch, CL-6051; - // see `@corbits/agent-directory`'s `stale-task-agent-naming.ts`) - // existed for exactly one now-retired task; any that still linger - // must stay out of a picker meant for agents a person deliberately - // keeps around. Wired into the picker surface: chat's invite/new-chat - // dialogs (`chatDeps.isInvitableDefinition` below). - const isPickerListableDefinition = (definition: { name: string }) => - isConversationalAgentDefinition(definition) && !isPlannerCreatedDefinitionName(definition.name); - - const chatDeps: Parameters[0] = { - store: chatStore, - roomMessages, - platform: chatPlatform, - principals: createDrizzleNativePrincipalStore(db), - threads: threadStore, - turnMailCorrelation, - agentTurns, - blockResponses: blockResponseStore, - reactions: reactionStore, - pins: pinStore, - clientIds: createDrizzleClientIdStore(db), - workbenchSubscribers: chatSubscribers, - turnQueue, - turnCancellation, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: grantConditionRegistry, - }), - isInvitableDefinition: isPickerListableDefinition, - turnTimeoutMs: DEFAULT_TURN_CLAIM_TTL_MS, - resolvePrincipalName: async (_tenantId, principalId) => { - const principalRow = await db.query.principal.findFirst({ - where: (p, { eq: equals }) => equals(p.id, principalId), - columns: { kind: true, refId: true }, - }); - if (principalRow === undefined || principalRow.kind !== "user") { - return undefined; - } - const userRow = await db.query.user.findFirst({ - where: (u, { eq: equals }) => equals(u.id, principalRow.refId), - columns: { name: true }, - }); - return userRow?.name ?? undefined; - }, - commands: commandRegistry, - // The native undeploy call this hub uses to tear an invited agent's - // instance down when it is removed from a chat's participants, - // rather than leaving it deployed with nothing routing messages to - // it. - releaseAgentInstance: (address, reason) => sidecarRouter.sendAgentUndeploy(address, reason), - // CL-7450: fans a sent human message into every human participant's - // `@corbits/mailbox` inbox, on the same `mailboxDb`/`mailboxBus` every - // other mailbox consumer in this file shares. `resolveKnownPrincipalIds` - // reads the control plane's own `principal` table directly (the - // authoritative "is this a real principal in this tenant" check), - // rather than `@corbits/mailbox`'s FK, so an unknown participant is a - // reported skip, not a database error deep in a transaction. - mailbox: { - writer: createDrizzleMailboxWriter(mailboxDb, mailboxBus), - resolveKnownPrincipalIds: async (tenantId, candidateIds) => { - if (candidateIds.length === 0) return new Set(); - const rows = await db.query.principal.findMany({ - where: (p, { eq: equals, and: andAll }) => - andAll(equals(p.tenantId, tenantId), inArray(p.id, candidateIds)), - columns: { id: true }, - }); - return new Set(rows.map((row) => row.id)); - }, - // A row's Message-ID always addresses under the row's OWN tenant's - // domain, never the acting caller's — see `mailbox-fanout.ts`'s - // `MailboxFanoutDeps.resolveTenantDomain` doc comment. Same - // `tenant` lookup `workflowDeployer.deploy` below uses for the - // identical reason (an instance's trigger address, minted against - // its own tenant's domain). - resolveTenantDomain: async (tenantId) => { - const tenantRow = await db.query.tenant.findFirst({ - where: eq(tenantTable.id, tenantId), - }); - if (tenantRow === undefined) { - throw new Error(`no tenant "${tenantId}" to address a mailbox from`); - } - return tenantRow.domain; - }, - } satisfies MailboxFanoutDeps, - }; - app.route(`${TENANT_PREFIX}/chat`, createChatRoutes(chatDeps)); - // Myra's workflow-run chat surfaces (`@corbits/agent-directory-tools`' - // `create_agent` default mint-dm + invite for non-chat kinds): the - // workflow-run-authenticated counterpart to browser chat routes, - // self-WORKBENCH scoped — see `@corbits/chat`'s - // `workflow-participant-routes.ts` for the [Intx/repo gap] this resolves - // around (no direct run-address -> chat index; resolved by scanning - // the tenant's chat participant lists). - app.route( - "/api/workflow-chat", - createWorkflowParticipantRoutes({ - store: chatStore, - platform: chatPlatform, - roomMessages, - publish: chatSubscribers.publish, - turnQueue, - turnCancellation, - turnMailCorrelation, - authenticator: createWorkflowRunAuthenticator({ db }), - }), - ); // The `@corbits/inbox` product-inbox routes (triage groups, mark-all-read, // clear-done) are retired (CL-8209): they sat on `@corbits/mailbox` APIs // (`listUserMailbox`, refs, classification) removed in the native 1.0 @@ -1280,7 +797,7 @@ export async function createHub(config: HubConfig) { db, contentStore: artifactContentStore, requireGrant: createRequireGrant({ - grantStore: chatGrantStore, + grantStore: grantStore, conditionRegistry: grantConditionRegistry, }), countSegments: Object.fromEntries( @@ -1372,7 +889,7 @@ export async function createHub(config: HubConfig) { // — matching `mailboxApp` above — routed at "/" rather than // `TENANT_PREFIX`, which would double the tenant-route prefix. // Guarded by the hub's own `requireGrant`: the same - // `chatGrantStore`/`grantConditionRegistry` every other tenant-scoped + // `grantStore`/`grantConditionRegistry` every other tenant-scoped // mount above uses. `loadMemoryConfig` reads // `DATABASE_URL`/`EMBED_BASE_URL`/`EMBED_MODEL` (and friends) from // env itself; nothing else in this file constructs the memory plane. @@ -1380,7 +897,7 @@ export async function createHub(config: HubConfig) { createMemory({ app: memoryApp, config: loadMemoryConfig(), - grantStore: chatGrantStore, + grantStore: grantStore, conditionRegistry: grantConditionRegistry, }); app.route("/", memoryApp); @@ -1394,7 +911,7 @@ export async function createHub(config: HubConfig) { // writing the asset repo's trees — which no run-bearer credential can do // against stock git smart-HTTP today (CL-8171). Unlike // `/api/workflow-skills` above, every write here runs a real - // `chatGrantStore` authorization check (`asset:*`/create, + // `grantStore` authorization check (`asset:*`/create, // `asset:`/write, `workflow:*`/create) before reaching `RepoStore`, // because authoring source is a side effect, not a markdown skill edit. app.route( @@ -1405,24 +922,11 @@ export async function createHub(config: HubConfig) { db, assetService, repoStore: agentRepoStore.repoStore, - grantStore: chatGrantStore, + grantStore: grantStore, conditionRegistry: grantConditionRegistry, }), }), ); - app.route( - `${TENANT_PREFIX}/chat`, - createCommandRoutes({ - registry: commandRegistry, - requireGrant: createRequireGrant({ - grantStore: chatGrantStore, - conditionRegistry: grantConditionRegistry, - }), - workbenchBelongsToTenant: async (tenantId, chatId) => - (await chatStore.getWorkbenchSettings(tenantId, chatId)) !== undefined || - (await chatStore.hasLaunchedInstance(tenantId, chatId)), - }), - ); // Webhook triggers: tenant-scoped management (create/list/rotate/ // enable/disable/delete) mounts under the tenant prefix like chat, @@ -1441,7 +945,7 @@ export async function createHub(config: HubConfig) { createWebhookTriggerRoutes({ store: webhookTriggerStore, requireGrant: createRequireGrant({ - grantStore: chatGrantStore, + grantStore: grantStore, conditionRegistry: grantConditionRegistry, }), workflowDefinitionInTenant: async (tenantId, definitionId) => { @@ -1477,54 +981,6 @@ export async function createHub(config: HubConfig) { ), }), ); - // A connection completing through ANY door below — OAuth callback, - // pasted key, MCP OAuth, keyless MCP preset — settles every room - // waiting on that connector: the room's `connections/pending` entry - // clears (flipping the in-room connect card via `chat.settings`). - // Rooms with `connections/pending` still wake the host agent via - // `dispatchTurn` without a forged signed-in-user timeline row; - // code-review template rooms are not woken on PAT settle. - // - // An inference provider's credential landing also re-checks every - // live participant's deployed inference chain (CL-6687): a rotated - // key only ever reaches an agent at deploy time, so the relaunch has - // to be kicked here, not left for the next message. A tool-package - // connector (`feedsTools`, e.g. Manus) is the same shape for a - // different payload: `pinnedPackageCredentialBindingsFor` only folds - // at deploy, so a live Myra launched at signup before the key was - // pasted stays on a snapshot that cannot `resolve("manus")` until - // this pass relaunches it. Not awaited — a relaunch is a sidecar - // deploy round-trip, and the connect response must not wait on it. - const settleServiceConnection: ServiceConnectedHook = async (info) => { - await settleConnectedService( - { - store: chatStore, - platform: chatPlatform, - roomMessages, - publish: chatSubscribers.publish, - agentTurns, - }, - { - tenantId: info.tenantId, - principalId: info.principalId, - connectorId: info.connectorId, - displayName: info.displayName, - }, - ); - if (isInferenceProvider(info.connectorId)) { - void chatPlatform - .reconcileInferenceSources(info.tenantId) - .then(({ scanned, relaunched }) => { - log.info`inference credential ${info.connectorId} changed on tenant ${info.tenantId}: re-checked ${String(scanned)} live agents, relaunched ${String(relaunched)}`; - }) - .catch((cause: unknown) => { - reportError(cause, { - operation: "connections.reconcile-inference-sources", - tenantId: info.tenantId, - }); - }); - } - }; // Connections: the settings surface's tenant-scoped credential // test-and-store, mounted under the same tenant prefix and reusing // the same grant store/condition registry every other credential- @@ -1535,7 +991,7 @@ export async function createHub(config: HubConfig) { hubUrl: config.baseUrl, registry: CONNECTOR_REGISTRY, requireGrant: createRequireGrant({ - grantStore: chatGrantStore, + grantStore: grantStore, conditionRegistry: grantConditionRegistry, }), log: (line) => log.info`${line}`, @@ -1550,7 +1006,6 @@ export async function createHub(config: HubConfig) { gmailClientSecret: config.gmailClientSecret, }, providerHealth: providerHealthStore, - listConnectedProviders: (tenantId) => listConnectedProviders(db, tenantId), // CL-6403: an operator-set GITHUB_API_BASE_URL lets a fake server // stand in for api.github.com for the `github` connector's PAT // probe and stored provider origin; unset in every real deployment, @@ -1558,7 +1013,6 @@ export async function createHub(config: HubConfig) { // fixed production origin. probeBaseUrls: config.githubApiBaseUrl !== undefined ? { github: config.githubApiBaseUrl } : {}, - onConnected: settleServiceConnection, }), ); // Connections' own OAuth connect flow (CL-6389): `createOAuthConnectRoutes` @@ -1591,7 +1045,6 @@ export async function createHub(config: HubConfig) { registry: CONNECTOR_REGISTRY, providerHealth: providerHealthStore, }), - onConnected: settleServiceConnection, defaultReturnPath: "/settings/connections", // `/w/` is the chat room prefix: the in-room connect card // (CL-6393) starts OAuth from a room and must land back in it. @@ -1622,12 +1075,11 @@ export async function createHub(config: HubConfig) { createMcpServerRoutes({ hubUrl: config.baseUrl, requireGrant: createRequireGrant({ - grantStore: chatGrantStore, + grantStore: grantStore, conditionRegistry: grantConditionRegistry, }), log: (line) => log.info`${line}`, presets: MCP_PRESETS, - onConnected: settleServiceConnection, }), ); // MCP servers' OAuth connect flow (CL-6152): discovers and drives a @@ -1639,13 +1091,12 @@ export async function createHub(config: HubConfig) { createMcpOAuthRoutes({ hubUrl: config.baseUrl, requireGrant: createRequireGrant({ - grantStore: chatGrantStore, + grantStore: grantStore, conditionRegistry: grantConditionRegistry, }), log: (line) => log.info`${line}`, credentialCipher, presets: MCP_PRESETS, - onConnected: settleServiceConnection, // `/w/` for the same reason as the connections/oauth mount above. returnPathAllowlist: [...DEFAULT_RETURN_PATH_ALLOWLIST, "/plugins", "/w/"], }), @@ -1697,22 +1148,6 @@ export async function createHub(config: HubConfig) { if (sidecarAllocationReconciliationTimer !== undefined) { clearTimeout(sidecarAllocationReconciliationTimer); } - // Retire the relaunch sweep's series so any in-flight pass's - // `.finally` reschedule is a no-op, and cancel whatever pass is - // currently pending. Without this the sweep outlives `close()` - // entirely (it's only ever re-armed, never torn down) and keeps - // querying `chat.workbench_launch` on a timer this function is - // about to end — including, once `close()` below tears down the - // db pool, querying a pool that's already shut down. In a test - // suite that boots many hubs back to back (CL-7453) those leaked - // timers pile up across the whole `bun test` process and contend with later - // tests' own boots for Postgres connections, which is what - // surfaced as `chat·relaunch-sweep: relaunch sweep pass failed: - // Failed query: select ... from chat.workbench_launch` and an - // intermittent test timeout. - relaunchSweepSeries += 1; - clearTimeout(relaunchSweepTimer); - chatOrchestrator.dispose(); await closeMailbox(); // The pool end waits on in-flight queries; a query whose socket // died with the process must never stall shutdown, so bound it. diff --git a/apps/hub/test/chat-mount.test.ts b/apps/hub/test/chat-mount.test.ts deleted file mode 100644 index 16af81eaaf..0000000000 --- a/apps/hub/test/chat-mount.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -// Proves the chat mount exists: an unauthenticated request to a chat -// route under the tenant prefix answers with the platform's own -// auth-error envelope, not a 404 (no route) or a crash (bad deps -// literal). Mirrors `composition.test.ts`'s echo-mount proof; chat's -// own request/response behavior belongs to `packages/chat`'s tests. - -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -// DB-gated: skipped when DATABASE_URL is unset, matching this repo's -// convention for tests that talk to a real Postgres. -const databaseUrl = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -const root = mkdtempSync(path.join(tmpdir(), "hub-chat-mount-")); -const staticDir = path.join(root, "static"); -mkdirSync(staticDir, { recursive: true }); -writeFileSync(path.join(staticDir, "index.html"), "shell"); -mkdirSync(path.join(root, "data"), { recursive: true }); - -const config: HubConfig = { - databaseUrl, - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - signupRateLimit: { windowSeconds: 60, max: 5 }, - socialProviders: {}, - // No CREDENTIAL_ENCRYPTION_KEY here: this suite never touches the - // credential-cipher seam, so the dev opt-in keeps boot working. - allowPlaintextSecrets: true, - sidecarProvisioners: [], -}; - -const closers: (() => Promise)[] = []; - -afterAll(async () => { - for (const close of closers) await close(); - rmSync(root, { recursive: true, force: true }); -}); - -describeIfDb("chat mount", () => { - test("chat routes mount inside the native tenant middleware", async () => { - const hub = await createHub(config); - closers.push(hub.close); - - const gated = await hub.app.request("/api/tenants/some-tenant/chat/workbenches", { - method: "GET", - }); - expect(gated.status).toBe(401); - expect(await gated.json()).toEqual({ - error: { code: "unauthorized", message: "Authentication required" }, - }); - - // The route exists only under the tenant scope; outside it the - // path falls through to the interface shell. - const outside = await hub.app.request("/chat/workbenches"); - expect(await outside.text()).toBe("shell"); - }); -}); diff --git a/apps/hub/test/composition.test.ts b/apps/hub/test/composition.test.ts index 11964c6738..c13f3b1947 100644 --- a/apps/hub/test/composition.test.ts +++ b/apps/hub/test/composition.test.ts @@ -104,12 +104,12 @@ describeIfDb("shutdown", () => { }); describeIfDb("extension mounting", () => { - test("chat mounts inside the native tenant middleware", async () => { + test("the mailbox mounts inside the native tenant middleware", async () => { const hub = await bootHub(); // Anonymous request to an extension route: the platform's tenant // middleware answers 401 before the extension's handler runs. - const gated = await hub.app.request("/api/tenants/some-tenant/chat/workbenches"); + const gated = await hub.app.request("/api/tenants/some-tenant/mailbox/me/inbox"); expect(gated.status).toBe(401); expect(await gated.json()).toEqual({ error: { code: "unauthorized", message: "Authentication required" }, @@ -117,7 +117,7 @@ describeIfDb("extension mounting", () => { // The route exists only under the tenant scope; outside it the // path falls through to the interface shell. - const outside = await hub.app.request("/chat/workbenches"); + const outside = await hub.app.request("/mailbox/me/inbox"); expect(await outside.text()).toBe("shell"); }); }); diff --git a/apps/hub/test/credential-cipher.test.ts b/apps/hub/test/credential-cipher.test.ts index 9d498cdb32..bb3db43d23 100644 --- a/apps/hub/test/credential-cipher.test.ts +++ b/apps/hub/test/credential-cipher.test.ts @@ -1,12 +1,11 @@ // The composition root's credential-cipher gate: a self-hosting operator who // never sets CREDENTIAL_ENCRYPTION_KEY must not silently end up with -// webhook-trigger signing secrets and onboarding OAuth connect state stored +// webhook-trigger signing secrets stored // unencrypted. A missing key hard-fails boot unless the operator explicitly // opts into dev/test behavior with ALLOW_PLAINTEXT_SECRETS. import { describe, expect, test } from "bun:test"; import { getLogger } from "@intx/log"; -import { tagCredentialCipher } from "@corbits/chat"; import type { HubConfig } from "../src/config.ts"; import { credentialCipherFrom, hubCredentialCipher } from "../src/index.ts"; @@ -59,26 +58,6 @@ describe("credentialCipherFrom", () => { }); describe("hubCredentialCipher", () => { - test("boot tags the cipher built from a configured key", async () => { - const cipher = hubCredentialCipher( - { - ...baseConfig, - credentialEncryptionKeyHex: "a".repeat(64), - }, - log, - ); - expect(cipher).toBe(tagCredentialCipher(cipher)); - const encrypted = await cipher.encrypt("secret-value", "aad"); - expect(encrypted).not.toBe("secret-value"); - }); - - test("boot tags the noop cipher when ALLOW_PLAINTEXT_SECRETS is set", async () => { - const cipher = hubCredentialCipher({ ...baseConfig, allowPlaintextSecrets: true }, log); - expect(cipher).toBe(tagCredentialCipher(cipher)); - const encrypted = await cipher.encrypt("secret-value", "aad"); - expect(encrypted).toBe("secret-value"); - }); - test("boot still hard-fails when the key is missing and plaintext is not opted in", () => { expect(() => hubCredentialCipher(baseConfig, log)).toThrow(/CREDENTIAL_ENCRYPTION_KEY/); }); diff --git a/apps/hub/test/relaunch-sweep-close.test.ts b/apps/hub/test/relaunch-sweep-close.test.ts deleted file mode 100644 index 0707d37018..0000000000 --- a/apps/hub/test/relaunch-sweep-close.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Regression for CL-7453: createHub's relaunch sweep re-arms itself on -// a fixed [0, 2_000, 5_000, 15_000, 45_000] ms schedule (see -// `scheduleRelaunchSweep` in ../src/index.ts) and is never otherwise -// torn down. Before this fix, `hub.close()` stopped every other -// background loop (workflow scheduler, credential-expiry sweep, -// inbox-unsnooze sweep, bench provisioner, sidecar-allocation -// reconciliation) but left the relaunch sweep's timer running, so it -// kept firing — and logging `relaunch sweep pass failed: ...` when it -// queried a pool `close()` had already shut down — for the rest of the -// `bun test` process. A suite that boots several hubs back to back -// accumulated these leaked loops, contending with later tests' own boots for -// Postgres connections and surfacing as an intermittent test timeout. -import { afterAll, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import type { HubConfig } from "../src/config.ts"; -import { createHub } from "../src/index.ts"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -const DATABASE_URL = process.env["DATABASE_URL"] ?? ""; -const describeIfDb = dbGate(DATABASE_URL, import.meta.path); - -const root = mkdtempSync(path.join(tmpdir(), "hub-relaunch-sweep-close-")); -const staticDir = path.join(root, "static"); -mkdirSync(staticDir, { recursive: true }); -writeFileSync(path.join(staticDir, "index.html"), "shell"); -mkdirSync(path.join(root, "data"), { recursive: true }); - -const config: HubConfig = { - databaseUrl: DATABASE_URL, - baseUrl: "http://localhost:3000", - sessionSecret: "insecure-test-only-session-secret-0000", - hubDataDir: path.join(root, "data"), - hubStaticDir: staticDir, - signupRateLimit: { windowSeconds: 60, max: 5 }, - socialProviders: {}, - sidecarProvisioners: [], - allowPlaintextSecrets: true, -}; - -afterAll(() => { - rmSync(root, { recursive: true, force: true }); -}); - -describeIfDb("relaunch sweep teardown", () => { - test("does not run after hub.close()", async () => { - const hub = await createHub(config); - await hub.close(); - - const errors: string[] = []; - const originalError = console.error; - console.error = (...args: unknown[]) => { - errors.push(args.map(String).join(" ")); - }; - try { - // The sweep's own schedule reschedules at 0ms then 2_000ms; wait - // past both so a leaked timer would have fired (and, querying a - // pool `close()` already shut down, logged) at least once. - await new Promise((resolve) => setTimeout(resolve, 2_200)); - } finally { - console.error = originalError; - } - - const leaked = errors.filter((line) => line.includes("relaunch sweep pass failed")); - expect(leaked).toEqual([]); - }, 10_000); -}); diff --git a/bun.lock b/bun.lock index 0a4da98167..c4417b1478 100644 --- a/bun.lock +++ b/bun.lock @@ -92,16 +92,13 @@ "dependencies": { "@chat-adapter/state-memory": "^4.35.0", "@corbits/agent-directory": "workspace:*", - "@corbits/agent-runtime": "workspace:*", "@corbits/approvals": "workspace:*", "@corbits/artifact-ui": "workspace:*", "@corbits/artifacts": "github:corbitsdev/corbits-artifacts#5309a15d4aa504f132c54b4da08bb672b4903873", "@corbits/attio-task-agent-workflow": "workspace:*", - "@corbits/chat": "workspace:*", "@corbits/code-review-workflow": "workspace:*", "@corbits/codex-provider": "workspace:*", "@corbits/collateral-generation-workflow": "workspace:*", - "@corbits/commands": "workspace:*", "@corbits/connections": "workspace:*", "@corbits/credential-providers": "workspace:*", "@corbits/cron": "workspace:*", @@ -127,7 +124,6 @@ "@corbits/reddit-opportunity-scanner-workflow": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", - "@corbits/turn-artifacts": "workspace:*", "@corbits/url-path": "workspace:*", "@corbits/webhook-triggers": "workspace:*", "@corbits/workbench-digest-workflow": "workspace:*", @@ -246,8 +242,6 @@ "version": "0.0.1", "dependencies": { "@corbits/agent-events": "workspace:*", - "@corbits/agent-runtime": "workspace:*", - "@corbits/chat": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", @@ -1002,10 +996,11 @@ "name": "@corbits/webhook-triggers", "version": "0.0.1", "dependencies": { - "@corbits/agent-runtime": "workspace:*", + "@corbits/collections": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/migration-runner": "workspace:*", "@corbits/workflows": "workspace:*", + "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", @@ -1018,7 +1013,6 @@ "postgres": "catalog:", }, "devDependencies": { - "@intx/crypto": "0.3.0", "@types/bun": "catalog:", "typescript": "catalog:", }, @@ -3286,11 +3280,11 @@ "@corbits/artifact-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], - "@corbits/assistant-workflow/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/diligence-brief-workflow/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], - "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#e85b81e", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-e85b81e", "sha512-ATPP/pll1sYG1DLKV1HvtboK8q1tFkqayQqRXdUmRUIsFJx5wqzd8YA1XELjXzLu1P2f2uCMe2CjsJHGv6E90Q=="], - "@corbits/scout-agent/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], @@ -3308,6 +3302,10 @@ "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#5309a15", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.3.0", "@intx/types": "^0.3.0", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-5309a15", "sha512-zGHkq++juclUrOd0O8/Z5ziTldmJZ6gqyH/c9cs5S10UPU4gqn8CHwq174SiJXY0ujJ3cNpJXnrr+0fcqlfQ1w=="], + "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#e85b81e", { "dependencies": { "@hono/standard-validator": "0.2.3", "@intx/crypto": "0.3.0", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-e85b81e", "sha512-ATPP/pll1sYG1DLKV1HvtboK8q1tFkqayQqRXdUmRUIsFJx5wqzd8YA1XELjXzLu1P2f2uCMe2CjsJHGv6E90Q=="], + + "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], + "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -3352,6 +3350,8 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3396,6 +3396,8 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@workbench/hub/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/packages/agent-directory/package.json b/packages/agent-directory/package.json index 35b49652c4..e0c46d157f 100644 --- a/packages/agent-directory/package.json +++ b/packages/agent-directory/package.json @@ -15,8 +15,6 @@ }, "dependencies": { "@corbits/agent-events": "workspace:*", - "@corbits/agent-runtime": "workspace:*", - "@corbits/chat": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/skills": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", diff --git a/packages/agent-directory/src/client.test.ts b/packages/agent-directory/src/client.test.ts index 233868b9fa..b4ecab51e3 100644 --- a/packages/agent-directory/src/client.test.ts +++ b/packages/agent-directory/src/client.test.ts @@ -19,10 +19,10 @@ const researcher = { description: "Answers research questions", }; -const workbenchHostDefinition = { +const slugDefinition = { ...researcher, id: "wfd_2", - name: "ins-0f1e2d3c4b5a69788796a5b4c3d2e1f0", + name: "research-analyst", description: null, }; @@ -33,13 +33,6 @@ const instance = { address: "ins_1@acme.localhost", }; -const workbenchHostInstance = { - ...instance, - id: "ins_2", - definitionId: "wfd_2", - definitionName: "ins-0f1e2d3c4b5a69788796a5b4c3d2e1f0", -}; - const dailyDigestDefinition = { ...researcher, id: "wfd_3", @@ -55,11 +48,6 @@ const last30DaysResearchDefinition = { }; describe("purposeAgentDefinitions", () => { - test("drops the chat anchor machinery's workbench-host definitions", () => { - const result = purposeAgentDefinitions([researcher, workbenchHostDefinition]); - expect(result).toEqual([researcher]); - }); - test("drops routine-only workflow catalog utilities (Daily digest, Last 30 days research) — they are non-conversational, seeded as routines, and belong on the Routines page, not the Agents list", () => { const result = purposeAgentDefinitions([ researcher, @@ -78,11 +66,6 @@ const invitedAgentInstance = { }; describe("purposeAgentInstances", () => { - test("drops workbench-host instances", () => { - const result = purposeAgentInstances([instance, workbenchHostInstance]); - expect(result).toEqual([instance]); - }); - test("with no excludeRunIds set, leaves an ordinary top-level deployment alone", () => { const result = purposeAgentInstances([instance]); expect(result).toEqual([instance]); @@ -96,14 +79,6 @@ describe("purposeAgentInstances", () => { expect(result).toEqual([instance]); }); - test("still drops a workbench host when excludeRunIds is also given", () => { - const result = purposeAgentInstances( - [instance, workbenchHostInstance, invitedAgentInstance], - new Set([invitedAgentInstance.id]), - ); - expect(result).toEqual([instance]); - }); - test("does not exclude an ordinary deployment merely because a different id is in the set", () => { const result = purposeAgentInstances([instance], new Set([invitedAgentInstance.id])); expect(result).toEqual([instance]); @@ -228,10 +203,10 @@ describe("withDisplayName / withDisplayNames", () => { }); test("maps over a list", () => { - const result = withDisplayNames([researcher, workbenchHostDefinition]); + const result = withDisplayNames([researcher, slugDefinition]); expect(result.map((d) => d.displayName)).toEqual([ researcher.description, - humanizeSlug(workbenchHostDefinition.name), + humanizeSlug(slugDefinition.name), ]); }); }); diff --git a/packages/agent-directory/src/client.ts b/packages/agent-directory/src/client.ts index 7d043cc623..f61e4e64f2 100644 --- a/packages/agent-directory/src/client.ts +++ b/packages/agent-directory/src/client.ts @@ -7,14 +7,13 @@ // to identify that plumbing; a host injects only its raw definition/ // instance lists, already fetched from wherever it gets them. -import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; import { deriveDisplayName, humanizeSlug, withDisplayName, withDisplayNames, type WithDisplayName, -} from "@corbits/chat/display-name"; +} from "./display-name"; import { isConversationalWorkflowName } from "@corbits/workflows/catalog"; // `deriveDisplayName`/`humanizeSlug` (CL-6413) live in `@corbits/chat` @@ -59,9 +58,7 @@ export type UserFacingAgentInstance = { export function purposeAgentDefinitions( definitions: readonly T[], ): readonly T[] { - return definitions.filter( - (d) => !isWorkbenchHostDefinitionName(d.name) && isConversationalWorkflowName(d.name), - ); + return definitions.filter((d) => isConversationalWorkflowName(d.name)); } /** @@ -75,10 +72,7 @@ export function purposeAgentInstances( instances: readonly T[], excludeRunIds: ReadonlySet = new Set(), ): readonly T[] { - return instances.filter( - (instance) => - !isWorkbenchHostDefinitionName(instance.definitionName) && !excludeRunIds.has(instance.id), - ); + return instances.filter((instance) => !excludeRunIds.has(instance.id)); } export function filterDefinitions( diff --git a/packages/chat/src/display-name.test.ts b/packages/agent-directory/src/display-name.test.ts similarity index 100% rename from packages/chat/src/display-name.test.ts rename to packages/agent-directory/src/display-name.test.ts diff --git a/packages/agent-directory/src/display-name.ts b/packages/agent-directory/src/display-name.ts new file mode 100644 index 0000000000..59a9c158ed --- /dev/null +++ b/packages/agent-directory/src/display-name.ts @@ -0,0 +1,100 @@ +// A workflow definition's person-facing display name, derived once here +// so every caller that decides "what does this agent look like to a +// person" reads it the same way — never a scattered `description ?? name` +// (or worse, a raw address/run id) reimplemented per call site. +// +// Lives in `@corbits/chat` rather than `@corbits/agent-directory` (which +// originated this logic, CL-6413) because `@corbits/agent-directory` +// itself depends on `@corbits/chat` (`workbench-host-naming`); a reverse +// dependency would be circular. `@corbits/agent-directory/client.ts` +// re-exports `deriveDisplayName`/`humanizeSlug` from here for its existing +// callers rather than keeping a second copy that could drift. +import { type } from "arktype"; +import { ID_LEAK_PATTERN } from "./id-leak-guard"; + +const DisplayNameSource = type({ + name: "string", + "description?": "string | null", +}); + +/** kebab-case identifier -> Title Case words: `"research-analyst"` -> + * `"Research Analyst"`. Words that aren't hyphen-separated (a name that + * already reads as prose) pass through with only their case fixed up, so + * this is safe to run over a definition's raw `name` unconditionally — + * PROVIDED that `name` is never an internal id in disguise; see + * `deriveDisplayName`'s own guard for why the raw run-id case never + * reaches this function at all. */ +export function humanizeSlug(slug: string): string { + return slug + .split(/[-_\s]+/) + .filter((word) => word.length > 0) + .map((word) => word.slice(0, 1).toUpperCase() + word.slice(1)) + .join(" "); +} + +/** + * The display name a definition should render as: its own description + * when one was set at creation, otherwise a humanized reading of its + * immutable slug. A whitespace-only description reads as absent — never + * a blank display name — since it carries nothing a person actually + * typed. Throws on a shape that isn't at least `{ name }` — this is a + * trust boundary, not a formatting helper, so a malformed record fails + * loudly rather than rendering "undefined". + * + * Also throws when `name` is itself an internal-id shape (`run_…`, + * `wfd_…`, …, see `./id-leak-guard`) — the product rule is that a person + * never sees an internal identifier, so a caller that reaches this + * function with a run id where a definition's slug belongs gets a loud + * failure instead of a Title-Cased leak like "Run 737a058d…" (CL-6471). + */ +export function deriveDisplayName(definition: { + readonly name: string; + readonly description?: string | null; +}): string { + const parsed = DisplayNameSource(definition); + if (parsed instanceof type.errors) { + throw new Error(`deriveDisplayName: invalid agent definition: ${parsed.summary}`); + } + const description = parsed.description?.trim(); + if (description !== undefined && description !== "") { + if (ID_LEAK_PATTERN.test(description)) { + throw new Error( + `deriveDisplayName: description "${description}" for definition ` + + `"${parsed.name}" carries an internal identifier; refusing to ` + + "render it as a display name", + ); + } + return description; + } + if (ID_LEAK_PATTERN.test(parsed.name)) { + throw new Error( + `deriveDisplayName: definition name "${parsed.name}" is an internal ` + + "identifier, not a slug; refusing to humanize it into a fake " + + "display name", + ); + } + return humanizeSlug(parsed.name); +} + +export type UserFacingAgentDefinition = { + readonly id: string; + readonly name: string; + readonly description?: string | null; +}; + +export type WithDisplayName = T & { readonly displayName: string }; + +/** Projects `deriveDisplayName` onto a definition, keeping every other + * field untouched — the read-boundary derivation done once here rather + * than as scattered `??` fallbacks in UI code. */ +export function withDisplayName( + definition: T, +): WithDisplayName { + return { ...definition, displayName: deriveDisplayName(definition) }; +} + +export function withDisplayNames( + definitions: readonly T[], +): readonly WithDisplayName[] { + return definitions.map(withDisplayName); +} diff --git a/packages/agent-directory/src/id-leak-guard.ts b/packages/agent-directory/src/id-leak-guard.ts new file mode 100644 index 0000000000..554cf37af7 --- /dev/null +++ b/packages/agent-directory/src/id-leak-guard.ts @@ -0,0 +1,44 @@ +// The systemic guard CL-6471 calls for: a person never sees an internal +// identifier — not as a participant's name, not in a join/system line, +// not in a greeting, not in a chat title. This has recurred repeatedly +// (a raw definitionId/runId in insights, a `writing_systems` id in a +// Myra reply, and CL-6471's own "Run 737a058d…" / "I'm run_737a…") each +// time as a one-off display-time patch; this module is the one place +// every id-generating prefix is named, so a new leak is a missed test +// run rather than a missed grep. +// +// Prefix words mirror `@intx/hub-common`'s `generateId` (vendored; +// upstream source: `packages/hub-common/src/ids.ts`'s `PREFIXES`) for +// the six kinds CL-6471 names explicitly — a workflow run, a workflow +// definition, a tenant, a principal, an asset, and a git token — each +// normally followed by `_` and 32 lowercase hex characters (`run_737a…`). +// +// Matched with either `_`, a space, or `-` as the separator, not just +// `_`: `humanizeSlug`'s Title Case reading of a leaked id (CL-6471's own +// "Run 737a058d48006e2bde12559576f422e0") replaces the underscore with a +// space, so the raw and the humanized-leak forms both need to trip this. +const ID_PREFIX_WORDS = ["run", "wfd", "tnt", "prn", "ast", "gtk"] as const; + +export const ID_LEAK_PATTERN = new RegExp( + `\\b(?:${ID_PREFIX_WORDS.join("|")})[_\\s-][0-9a-f]{16,}\\b`, + "i", +); + +/** + * Throws when `value` carries an internal identifier — a raw id, or a + * humanized reading of one (`"Run 737a058d…"`, produced by title-casing + * `run_737a058d…`'s underscore split). The humanized case is why this + * checks the ORIGINAL prefixes case-insensitively rather than requiring + * the literal lowercase prefix: `humanizeSlug` capitalizes the first + * letter of the leading word, so a leaked run id renders as "Run …", not + * "run …", by the time it would reach a person. + */ +export function assertNoLeakedInternalId(value: string, context: string): void { + if (ID_LEAK_PATTERN.test(value)) { + throw new Error( + `${context} carries an internal identifier ("${value}"); a person ` + + "must never see one — resolve the real name at the source instead " + + "of rendering the id (CL-6471)", + ); + } +} diff --git a/packages/agent-directory/src/routes.ts b/packages/agent-directory/src/routes.ts index b6b377bf14..0636d66e61 100644 --- a/packages/agent-directory/src/routes.ts +++ b/packages/agent-directory/src/routes.ts @@ -26,7 +26,6 @@ import { DEFAULT_ASSET_REF } from "@intx/hub-sessions"; import type { AssetService } from "@intx/hub-sessions"; import { type PinnedSkillIndexEntry } from "@corbits/skills"; -import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; import { createAgentDefinitionCore, @@ -132,7 +131,7 @@ function definitionNotFound(definitionId: string) { function hostGuardedRow( row: { readonly name: string; readonly assetId: string | null } | undefined, ): row is { readonly name: string; readonly assetId: string } { - return row !== undefined && row.assetId !== null && !isWorkbenchHostDefinitionName(row.name); + return row !== undefined && row.assetId !== null; } export function createAgentDefinitionRoutes({ diff --git a/packages/agent-directory/src/visible-definitions.ts b/packages/agent-directory/src/visible-definitions.ts index b9443bf26c..ce3b33f38f 100644 --- a/packages/agent-directory/src/visible-definitions.ts +++ b/packages/agent-directory/src/visible-definitions.ts @@ -10,7 +10,6 @@ import { and, eq } from "drizzle-orm"; import type { DB } from "@intx/db"; import { getAncestorChain, schema } from "@intx/db"; -import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; import { isConversationalWorkflowName } from "@corbits/workflows/catalog"; import { deriveDisplayName } from "./client"; @@ -66,7 +65,6 @@ export async function listVisibleAgentDefinitions( // automation, not a conversational agent — DMing it produces // nonsense, so only a genuinely conversational definition is listed. if (row.assetId === null) continue; - if (isWorkbenchHostDefinitionName(row.name)) continue; if (!isConversationalWorkflowName(row.name)) continue; // Leaf-to-root order means the closer tenant's definition for this // name was already recorded — an ancestor's same-name row never diff --git a/packages/agent-directory/src/workflow-capability-routes.ts b/packages/agent-directory/src/workflow-capability-routes.ts index bd377740fd..6220d9bfa9 100644 --- a/packages/agent-directory/src/workflow-capability-routes.ts +++ b/packages/agent-directory/src/workflow-capability-routes.ts @@ -46,8 +46,6 @@ import type { DB } from "@intx/db"; import { workflowDefinition, workflowRun } from "@intx/db/schema"; import type { AssetService } from "@intx/hub-sessions"; -import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; - import { commitAgentCapabilityAdd } from "./capability-add"; import { AddCapabilityInput, @@ -99,7 +97,7 @@ function definitionNotFound(definitionId: string) { function hostGuardedRow( row: { readonly name: string; readonly assetId: string | null } | undefined, ): row is { readonly name: string; readonly assetId: string } { - return row !== undefined && row.assetId !== null && !isWorkbenchHostDefinitionName(row.name); + return row !== undefined && row.assetId !== null; } export type CreateWorkflowCapabilityRoutesDeps = { diff --git a/packages/agent-directory/src/workflow-create-routes.ts b/packages/agent-directory/src/workflow-create-routes.ts index 0c097daf09..b5982e3def 100644 --- a/packages/agent-directory/src/workflow-create-routes.ts +++ b/packages/agent-directory/src/workflow-create-routes.ts @@ -40,7 +40,6 @@ import { tenant, workflowDefinition } from "@intx/db/schema"; import type { AssetService } from "@intx/hub-sessions"; import { skillNameSchema } from "@corbits/skills"; -import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; import { isAutomatableWorkflowName } from "@corbits/workflows/catalog"; import { @@ -90,9 +89,7 @@ const CreateWorkflowAgentDefinitionInput = type({ * anchor definition. */ function isConversationalAgentDefinition(definition: { readonly name: string }): boolean { - return ( - !isAutomatableWorkflowName(definition.name) && !isWorkbenchHostDefinitionName(definition.name) - ); + return !isAutomatableWorkflowName(definition.name); } export type CreateWorkflowAgentCreateRoutesDeps = { diff --git a/packages/agent-directory/src/workflow-skill-pin-routes.ts b/packages/agent-directory/src/workflow-skill-pin-routes.ts index ba8958bb67..7e56134c23 100644 --- a/packages/agent-directory/src/workflow-skill-pin-routes.ts +++ b/packages/agent-directory/src/workflow-skill-pin-routes.ts @@ -29,8 +29,6 @@ import type { DB } from "@intx/db"; import { workflowDefinition } from "@intx/db/schema"; import type { AssetService } from "@intx/hub-sessions"; -import { isWorkbenchHostDefinitionName } from "@corbits/chat/workbench-host-naming"; - import { readPinnedSkillNames, reindexPinnedSkills } from "./agent-workflow"; import { commitLatestAgentAssetSnapshot } from "./asset-write"; import { @@ -73,7 +71,7 @@ function definitionNotFound(definitionId: string) { function hostGuardedRow( row: { readonly name: string; readonly assetId: string | null } | undefined, ): row is { readonly name: string; readonly assetId: string } { - return row !== undefined && row.assetId !== null && !isWorkbenchHostDefinitionName(row.name); + return row !== undefined && row.assetId !== null; } const PinBody = type({ diff --git a/packages/webhook-triggers/package.json b/packages/webhook-triggers/package.json index 0097d1b101..b0344dc4f9 100644 --- a/packages/webhook-triggers/package.json +++ b/packages/webhook-triggers/package.json @@ -14,10 +14,11 @@ "test": "bun test" }, "dependencies": { - "@corbits/agent-runtime": "workspace:*", + "@corbits/collections": "workspace:*", "@corbits/error-sink": "workspace:*", "@corbits/migration-runner": "workspace:*", "@corbits/workflows": "workspace:*", + "@intx/crypto": "0.3.0", "@intx/db": "workspace:*", "@intx/hub-api": "workspace:*", "@intx/hub-common": "0.3.0", @@ -30,7 +31,6 @@ "postgres": "catalog:" }, "devDependencies": { - "@intx/crypto": "0.3.0", "@types/bun": "catalog:", "typescript": "catalog:" } diff --git a/packages/chat/src/crypto-cache.test.ts b/packages/webhook-triggers/src/crypto-cache.test.ts similarity index 100% rename from packages/chat/src/crypto-cache.test.ts rename to packages/webhook-triggers/src/crypto-cache.test.ts diff --git a/packages/webhook-triggers/src/crypto-cache.ts b/packages/webhook-triggers/src/crypto-cache.ts new file mode 100644 index 0000000000..3d6a785fc8 --- /dev/null +++ b/packages/webhook-triggers/src/crypto-cache.ts @@ -0,0 +1,36 @@ +// One `CryptoProvider` per cache key, minted once and reused while the +// key stays active. A caller picks its own key (a workbench id, a run +// id, ...); this module knows nothing about what the key means. +import { createEd25519Crypto, generateKeyPair } from "@intx/crypto"; +import { createExpiringMap } from "@corbits/collections"; +import type { CryptoProvider } from "@intx/types/runtime"; + +export type CryptoProviderCache = { + get(key: string): Promise; +}; + +/** A key untouched for this long is treated as gone rather than idle. */ +const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export function createCryptoProviderCache(options?: { + readonly ttlMs?: number; + readonly now?: () => number; +}): CryptoProviderCache { + const providers = createExpiringMap>({ + ttlMs: options?.ttlMs ?? DEFAULT_TTL_MS, + ...(options?.now !== undefined ? { now: options.now } : {}), + }); + + return { + get(key: string): Promise { + const pending = providers.get(key); + if (pending !== undefined) { + providers.set(key, pending); + return pending; + } + const minted = generateKeyPair().then((keyPair) => createEd25519Crypto(keyPair)); + providers.set(key, minted); + return minted; + }, + }; +} diff --git a/packages/webhook-triggers/src/index.ts b/packages/webhook-triggers/src/index.ts index 072484ae94..bb4f6c3d1f 100644 --- a/packages/webhook-triggers/src/index.ts +++ b/packages/webhook-triggers/src/index.ts @@ -39,3 +39,4 @@ export { createWebhookTriggerRoutes, type CreateWebhookTriggerRoutesDeps, } from "./management-routes"; +export { createCryptoProviderCache, type CryptoProviderCache } from "./crypto-cache"; diff --git a/packages/webhook-triggers/src/launch.ts b/packages/webhook-triggers/src/launch.ts index 208611ff41..6a25af0f6b 100644 --- a/packages/webhook-triggers/src/launch.ts +++ b/packages/webhook-triggers/src/launch.ts @@ -35,15 +35,12 @@ import { type SidecarRouter, type WorkflowAllocationService, } from "@intx/hub-sessions"; -import type { CryptoProvider } from "@intx/types/runtime"; import { and, eq } from "drizzle-orm"; import { renderInputTemplate } from "./mapping"; import type { WebhookTriggerRow } from "./schema"; -export type CryptoProviderCache = { - get(key: string): Promise; -}; +import type { CryptoProviderCache } from "./crypto-cache"; export type LaunchWebhookTriggerDeps = { db: DB["db"];