diff --git a/apps/hub/package.json b/apps/hub/package.json index 2a6b1893d3..2f695e79e6 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -13,7 +13,6 @@ }, "dependencies": { "@chat-adapter/state-memory": "^4.35.0", - "@corbits/access-tools": "workspace:*", "@corbits/agent-directory": "workspace:*", "@corbits/agent-lifecycle": "workspace:*", "@corbits/approvals": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index 41a6be3cce..97bb1d903b 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -17,7 +17,6 @@ import { createWorkflowRunDispatchStore, listAssetsForTenant, listVisibleOfferings, - resolveCredentialRequirement, } from "@intx/db"; import { asset as assetTable, @@ -125,7 +124,6 @@ import { createPostgresBenchModelPolicyStore, createWorkflowCatalogRoutes, } from "@corbits/inference-catalog"; -import { createWorkflowAccessRoutes } from "@corbits/access-tools/routes"; import { generateId } from "@intx/hub-common"; import { @@ -275,6 +273,7 @@ import { createInFlightRequestTracker, withInFlightRequestTracking, } from "./in-flight-requests"; +import { withWorkflowRunTenantAuth } from "./workflow-run-tenant-auth"; // Host policy constants, not configuration. const MAX_TARBALL_BYTES = 10 * 1024 * 1024; @@ -677,18 +676,6 @@ export async function createHub(config: HubConfig) { }, }; const hubPublicKey = hexEncode(signingKey.publicKey); - // Same owning check GET /connections uses — see - // `@corbits/connections`' `workflow-connection-routes.ts` and the - // `createWorkflowConnectionRoutes` wiring below. Not - // `listConnectedProviders` (catalog-only). - const isConnectorConnected = async (tenantId: string, connectorId: string) => - (await resolveCredentialRequirement( - db, - tenantId, - { providerName: connectorId, source: "tenant" }, - null, - null, - )) !== null; // One resolver serves both seams, exactly as @intx/hub-sessions's own // reference host wires them: `resolve` turns a presented bearer token // into a verified identity at the handshake, and `isCurrent` @@ -2269,10 +2256,6 @@ export async function createHub(config: HubConfig) { "/api/workflow-connections", createWorkflowConnectionRoutes({ authenticator: createWorkflowRunAuthenticator({ db }), - registry: CONNECTOR_REGISTRY, - // Same `isConnectorConnected` the pinned-package factory is wired - // with above (CL-6492). - isConnectorConnected, listMcpServers: (tenantId) => listMcpServerConnections(db, tenantId), }), ); @@ -2296,24 +2279,6 @@ export async function createHub(config: HubConfig) { getPolicy: (tenantId) => benchModelPolicy.store.getPolicy(tenantId), }), ); - // Myra's own principal/grant surface (`@corbits/access-tools`' - // `list_principals`/`list_grants`/`grant_access`/`revoke_access`): the - // workflow-run-authenticated counterpart to the tenant-session - // `/api/tenants/:tenantId/principals` and `/grants` routes - // `@intx/hub-api` mounts above. Reuses the SAME `chatGrantStore`/ - // `chatConditionRegistry` every other extension's own requireGrant - // check runs against, so a seeded principal's real grants (see - // the deleted seeding package's `SEED_GRANTS`) gate this surface exactly like - // every other write route. - app.route( - "/api/workflow-access", - createWorkflowAccessRoutes({ - db, - authenticator: createWorkflowRunAuthenticator({ db }), - grantStore: chatGrantStore, - conditionRegistry: chatConditionRegistry, - }), - ); // Notify-to-reconnect for an OAuth-connected credential whose token // expired (Hugging Face today — see docs/onboarding-huggingface-connect.md): // a light periodic sweep over `@corbits/notify`'s pure @@ -2350,8 +2315,15 @@ 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(app, inFlight); + const servingApp = withInFlightRequestTracking(runBearerApp, inFlight); return { app: servingApp, diff --git a/apps/hub/src/workflow-run-tenant-auth.ts b/apps/hub/src/workflow-run-tenant-auth.ts new file mode 100644 index 0000000000..bb6a8c9ff1 --- /dev/null +++ b/apps/hub/src/workflow-run-tenant-auth.ts @@ -0,0 +1,51 @@ +// 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 { Hono } from "hono"; +import type { DB } from "@intx/db"; +import { + createWorkflowRunDeployAuth, + type AppEnv, + type WorkflowRunAuthenticator, +} from "@intx/hub-api"; + +export type WorkflowRunTenantAuthDeps = { + db: DB["db"]; + authenticator: WorkflowRunAuthenticator; +}; + +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/sidecar/src/workflow-deployment-record.test.ts b/apps/sidecar/src/workflow-deployment-record.test.ts index 4e0a769c12..28c7e9c702 100644 --- a/apps/sidecar/src/workflow-deployment-record.test.ts +++ b/apps/sidecar/src/workflow-deployment-record.test.ts @@ -27,7 +27,9 @@ async function makeDataDir(): Promise { } const baseRecord: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: "run_parked-test@example.com", definitionId: "def_1", sources: {}, @@ -98,7 +100,9 @@ describe("scanWorkflowDeploymentRecords reaps pre-cutover records", () => { test("an unreadable old-format record (missing approvedWireHash/sourceRef) is reaped once, then boots quietly", async () => { const dataDir = await makeDataDir(); const preCutover = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: "run_old-format@example.com", definitionId: "def_1", sources: {}, diff --git a/apps/sidecar/src/workflow-deployment-record.ts b/apps/sidecar/src/workflow-deployment-record.ts index f012b71a59..46369da3f7 100644 --- a/apps/sidecar/src/workflow-deployment-record.ts +++ b/apps/sidecar/src/workflow-deployment-record.ts @@ -39,9 +39,17 @@ const RECORD_FILENAME = "deployment.json"; * blindly. Validated at read time (the boot scan) at the trust boundary. */ export const WorkflowDeploymentRecord = type({ - version: "1", + version: "2", agentAddress: "string > 0", definitionId: "string > 0", + // The run's own tenant and principal, from the hub's signed deploy frame. + // Required since version 2: the child threads them into its substrate + // config so a step tool can address stock `/api/tenants/:tenantId/*` with + // the run bearer, and a restore that could not name them would stand up a + // child whose tools cannot reach the hub. A version-1 record fails this + // parse and is redeployed rather than restored. + tenantId: "string > 0", + principalId: "string > 0", sources: { "[string]": InferenceSource.array().atLeastLength(1), }, diff --git a/apps/sidecar/src/workflow-host-wiring/index.ts b/apps/sidecar/src/workflow-host-wiring/index.ts index 26fe82a749..55048aaeef 100644 --- a/apps/sidecar/src/workflow-host-wiring/index.ts +++ b/apps/sidecar/src/workflow-host-wiring/index.ts @@ -779,6 +779,15 @@ export function createSidecarDeployRouter(deps: { sources: NonNullable["sources"]; /** Correlates the child's inference events to the deploy's session. */ sessionId: string | undefined; + /** + * The run's own tenant and principal, from the hub's signed deploy frame + * (`HarnessConfig`). Threaded into the child's substrate config so a step + * tool can address stock `/api/tenants/:tenantId/*` with the run bearer. + * Persisted on the deployment record so a boot-time restore reseeds the + * same pair rather than losing it. + */ + tenantId: string; + principalId: string; /** * Hub public key recorded at the head for deploy-pack verification and * inbound hub-frame verification. Required for a single-step @@ -835,9 +844,11 @@ export function createSidecarDeployRouter(deps: { // rather than assign `undefined` to it -- hence one conditional-spread // per optional field, folded into this single literal. return { - version: 1 as const, + version: 2 as const, agentAddress: spec.agentAddress, definitionId: spec.definition.id, + tenantId: spec.tenantId, + principalId: spec.principalId, sources, ...(spec.sessionId !== undefined ? { sessionId: spec.sessionId } : {}), ...(spec.hubPublicKey !== undefined @@ -928,6 +939,8 @@ export function createSidecarDeployRouter(deps: { const substrateEnv: Record = { ...multistepSubstrateEnv, WORKFLOW_DEFINITION_ID: spec.definition.id, + WORKFLOW_TENANT_ID: spec.tenantId, + WORKFLOW_PRINCIPAL_ID: spec.principalId, WORKFLOW_RUN_REPO_ID: deploymentId, WORKFLOW_RUN_REF: "refs/heads/main", CLOSURE_PACKAGE_DIR: spec.closurePackageDir, @@ -1511,6 +1524,8 @@ export function createSidecarDeployRouter(deps: { definition, sources: projection.sources, sessionId: frame.config.sessionId, + tenantId: frame.config.tenantId, + principalId: frame.config.principalId, hubPublicKey: definition.stepOrder.length === 1 ? frame.hubPublicKey : undefined, approvedWireHash: projection.approvedWireHash, @@ -1809,6 +1824,8 @@ export function createSidecarDeployRouter(deps: { definition, sources: record.sources, sessionId: record.sessionId, + tenantId: record.tenantId, + principalId: record.principalId, hubPublicKey: record.hubPublicKey, approvedWireHash: record.approvedWireHash, closurePackageDir: applied.packageDir, diff --git a/apps/sidecar/src/workflow-substrate-factory/config.ts b/apps/sidecar/src/workflow-substrate-factory/config.ts index 67205046cf..ed85237f1c 100644 --- a/apps/sidecar/src/workflow-substrate-factory/config.ts +++ b/apps/sidecar/src/workflow-substrate-factory/config.ts @@ -26,6 +26,8 @@ import { InferenceSource } from "@intx/types/runtime"; export const SIDECAR_SUBSTRATE_CONFIG_KEYS = [ "SIDECAR_DATA_DIR", "WORKFLOW_DEFINITION_ID", + "WORKFLOW_TENANT_ID", + "WORKFLOW_PRINCIPAL_ID", "WORKFLOW_RUN_REPO_ID", "WORKFLOW_RUN_REF", "SIDECAR_SIGNING_PUBLIC_KEY", @@ -46,6 +48,12 @@ export const SubstrateConfig = type({ // capabilities route a step tool calls. Identity only: the definition // itself is evaluated from the closure, never read from a repo. WORKFLOW_DEFINITION_ID: "string > 0", + // The run's own tenant and principal, taken from the hub's signed deploy + // frame (`HarnessConfig.tenantId` / `.principalId`). A step tool addresses + // stock `/api/tenants/:tenantId/*` with them and the run bearer; without + // them a tool package has no way to name the tenant it is running for. + WORKFLOW_TENANT_ID: "string > 0", + WORKFLOW_PRINCIPAL_ID: "string > 0", WORKFLOW_RUN_REPO_ID: "string > 0", WORKFLOW_RUN_REF: "string > 0", SIDECAR_SIGNING_PUBLIC_KEY: "string > 0", diff --git a/apps/sidecar/src/workflow-substrate-factory/index.ts b/apps/sidecar/src/workflow-substrate-factory/index.ts index a7ada16bae..12a4a1410a 100644 --- a/apps/sidecar/src/workflow-substrate-factory/index.ts +++ b/apps/sidecar/src/workflow-substrate-factory/index.ts @@ -396,6 +396,8 @@ export function createSidecarSubstrateFactory( hubArtifactsUrl: deriveHubHttpUrl(validated.HUB_WS_URL), sidecarToken: validated.SIDECAR_TOKEN, definitionId: validated.WORKFLOW_DEFINITION_ID, + tenantId: validated.WORKFLOW_TENANT_ID, + principalId: validated.WORKFLOW_PRINCIPAL_ID, }; const buildStepEnv = createSidecarStepBuildEnv( durableConversation !== undefined diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts index 004941e994..5a34ab0bf5 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.test.ts @@ -56,6 +56,8 @@ function buildEnvDeps(dataDir: string) { sidecarToken: "sc-token", adapters: { resolve: () => undefined } as never, definitionId: "wfd_capability_owner", + tenantId: "ten_test", + principalId: "prin_test", }; } @@ -103,6 +105,8 @@ test("a body-step env with no staged deploy tree still carries definitionId, so const buildEnv = createSidecarStepBuildEnv({ ...buildEnvDeps(dataDir), definitionId: "wfd_parent_definition", + tenantId: "ten_test", + principalId: "prin_test", }); const sourcesRef: SourcesSnapshotRef = { current: { diff --git a/apps/sidecar/src/workflow-substrate-factory/step-env.ts b/apps/sidecar/src/workflow-substrate-factory/step-env.ts index 3bf345007f..09d3ac9913 100644 --- a/apps/sidecar/src/workflow-substrate-factory/step-env.ts +++ b/apps/sidecar/src/workflow-substrate-factory/step-env.ts @@ -151,6 +151,18 @@ export interface SidecarStepBuildEnvDeps { * side by this field). */ definitionId: string; + /** + * The run's own tenant and principal, threaded from the hub's signed + * deploy frame (`HarnessConfig.tenantId` / `.principalId`) through the + * `WORKFLOW_TENANT_ID` / `WORKFLOW_PRINCIPAL_ID` substrate-config + * entries. Carried on the step env beyond `BaseEnv` exactly like + * `definitionId` above so a tool package can address stock + * `/api/tenants/:tenantId/*` with the run bearer — the tenant is a path + * segment on every stock tenant route, and a step tool has no other + * sanctioned way to learn it. + */ + tenantId: string; + principalId: string; /** * Adapter registry the step agent resolves inference adapters through. * The child builds this eagerly at boot from the validated @@ -372,6 +384,8 @@ export function createSidecarStepBuildEnv( hubAccessUrl: string; sidecarToken: string; definitionId: string; + tenantId: string; + principalId: string; } = { // An `InferenceSource` names a `credentialId`, not an inline secret: // the reactor fills the request's credential at send time through @@ -447,6 +461,11 @@ export function createSidecarStepBuildEnv( hubAccessUrl: deps.hubArtifactsUrl, sidecarToken: deps.sidecarToken, definitionId: deps.definitionId, + // The run's own tenant/principal, for the tool bundles that address + // stock `/api/tenants/:tenantId/*` routes with the run bearer + // (`@corbits/access-tools`, `@corbits/connections-tools`). + tenantId: deps.tenantId, + principalId: deps.principalId, }; // Carry the materialized tool runtime to the tool-bearing // `agentFactory` via the env's symbol-keyed slot. The step-invoker diff --git a/apps/sidecar/test/workflow-restore-defer-to-wake.test.ts b/apps/sidecar/test/workflow-restore-defer-to-wake.test.ts index 92d6036f88..6fe56181f6 100644 --- a/apps/sidecar/test/workflow-restore-defer-to-wake.test.ts +++ b/apps/sidecar/test/workflow-restore-defer-to-wake.test.ts @@ -195,7 +195,9 @@ test("a single-step deployment's frozen dead source is never replayed at restore }); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_1", // A single-step deployment's `sources` table has exactly one entry, @@ -243,7 +245,9 @@ test("a multi-step deployment still restores eagerly from its frozen sources, un }); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_multi", sources: { diff --git a/apps/sidecar/test/workflow-restore-quarantine.test.ts b/apps/sidecar/test/workflow-restore-quarantine.test.ts index 176bf536c1..829f1c4146 100644 --- a/apps/sidecar/test/workflow-restore-quarantine.test.ts +++ b/apps/sidecar/test/workflow-restore-quarantine.test.ts @@ -178,7 +178,9 @@ test("a permanently unrestorable record is quarantined after RESTORE_QUARANTINE_ // no future boot ever makes `deriveDeploymentId` agree with the directory. const mismatchedDeploymentId = "dep_mismatched-directory"; const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: "run_permanent-case@example.com", definitionId: "def_1", sources: {}, @@ -239,7 +241,9 @@ test("a transiently unbuildable provider is retried every boot and never quarant const deploymentId = deriveDeploymentId(agentAddress); stageClosureDefinition(deploymentId); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_1", // `step-1`'s unbuildable source throws before `step-2` is ever @@ -297,7 +301,9 @@ test("a missing/incomplete closure staging directory quarantines as a permanent }); const brokenRecord: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: brokenAgentAddress, definitionId: "def_1", sources: {}, @@ -311,7 +317,9 @@ test("a missing/incomplete closure staging directory quarantines as a permanent ); const healthyRecord: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress: healthyAgentAddress, definitionId: "def_1", // A buildable provider clears the source-admission gate and reaches diff --git a/apps/sidecar/test/workflow-restore-timeout-cancel.test.ts b/apps/sidecar/test/workflow-restore-timeout-cancel.test.ts index 201506251e..3e4fc4a1c5 100644 --- a/apps/sidecar/test/workflow-restore-timeout-cancel.test.ts +++ b/apps/sidecar/test/workflow-restore-timeout-cancel.test.ts @@ -101,7 +101,9 @@ test("a restore that outlasts its timeout corrects its record once the spawn act const agentAddress = "run_late-restore@example.com"; const deploymentId = deriveDeploymentId(agentAddress); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_1", sources: { @@ -177,7 +179,9 @@ test("a reclaiming teardown racing a dangling restore's boot-failure write is no const agentAddress = "run_teardown-race@example.com"; const deploymentId = deriveDeploymentId(agentAddress); const record: WorkflowDeploymentRecord = { - version: 1, + version: 2, + tenantId: "ten_test", + principalId: "prin_test", agentAddress, definitionId: "def_1", sources: { diff --git a/bun.lock b/bun.lock index f6c0eaef04..d191fe19fd 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,6 @@ "version": "0.0.1", "dependencies": { "@chat-adapter/state-memory": "^4.35.0", - "@corbits/access-tools": "workspace:*", "@corbits/agent-directory": "workspace:*", "@corbits/agent-lifecycle": "workspace:*", "@corbits/approvals": "workspace:*", @@ -201,18 +200,13 @@ }, "packages/access-tools": { "name": "@corbits/access-tools", - "version": "0.0.3", + "version": "0.0.4", "dependencies": { "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", "@intx/types": "workspace:*", "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:", }, "devDependencies": { "@corbits/agent-directory": "workspace:*", @@ -618,7 +612,7 @@ }, "packages/connections-tools": { "name": "@corbits/connections-tools", - "version": "0.0.9", + "version": "0.0.10", "dependencies": { "@corbits/connections": "workspace:*", "@intx/agent": "workspace:*", @@ -3502,12 +3496,8 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@corbits/inbox/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@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", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], - "@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/memory-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=="], - "@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=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -3528,12 +3518,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#118a2cd", { "dependencies": { "@hono/standard-validator": "0.2.3", "@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", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-118a2cd", "sha512-jVRl6/IH34JBPVUz7mqWyBm1w7uJxvYAI27gjw7zL1dFO4L+D0E5q/8b+mpwKMNOKKlGXU6UF5pdHnY8ilYx8g=="], - - "@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=="], - "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -3582,8 +3566,6 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@corbits/inbox/@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], - "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], @@ -3630,8 +3612,6 @@ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "@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=="], - "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/packages/access-tools/package.json b/packages/access-tools/package.json index 7d858870d2..7da816805f 100644 --- a/packages/access-tools/package.json +++ b/packages/access-tools/package.json @@ -1,13 +1,12 @@ { "name": "@corbits/access-tools", "private": true, - "description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, plus the workflow-run-authenticated routes they call — a thin wrapper over the tenant's real principal/grant rows and Interchange's own grant-store authorization, never a reimplementation of authz", - "version": "0.0.3", + "description": "Myra's principal and grant tools: list_principals, list_grants, grant_access, and revoke_access as an @intx/agent tool bundle, calling stock Interchange tenant routes with the workflow run's own bearer credential and enforcing a delegation ceiling before it grants", + "version": "0.0.4", "license": "LGPL-2.1-or-later", "type": "module", "exports": { - ".": "./src/index.ts", - "./routes": "./src/routes.ts" + ".": "./src/index.ts" }, "scripts": { "typecheck": "tsc --noEmit", @@ -17,13 +16,8 @@ "@corbits/error-sink": "workspace:*", "@intx/agent": "workspace:*", "@intx/authz": "0.3.0", - "@intx/db": "workspace:*", - "@intx/hub-api": "workspace:*", - "@intx/hub-common": "0.3.0", "@intx/types": "workspace:*", - "arktype": "catalog:", - "drizzle-orm": "catalog:", - "hono": "catalog:" + "arktype": "catalog:" }, "devDependencies": { "@corbits/agent-directory": "workspace:*", diff --git a/packages/access-tools/src/client.test.ts b/packages/access-tools/src/client.test.ts index 8e2059a5c9..274cf989ff 100644 --- a/packages/access-tools/src/client.test.ts +++ b/packages/access-tools/src/client.test.ts @@ -3,6 +3,7 @@ import { expect, test } from "bun:test"; import { AccessForbiddenError, AccessNotFoundError, + DelegationCeilingError, grantAccess, listGrants, listPrincipals, @@ -10,9 +11,13 @@ import { type AccessToolClientConfig, } from "./client"; +const TENANT_BASE = "https://hub.example.com/api/tenants/ten_1"; + function testConfig(fetchImpl: typeof fetch): AccessToolClientConfig { return { - hubAccessUrl: "https://hub.example.com/api/workflow-access", + hubAccessUrl: "https://hub.example.com", + tenantId: "ten_1", + principalId: "prin_caller", sidecarToken: "sc-token", address: "run_1@workflow", fetchImpl, @@ -30,7 +35,7 @@ function nativePrincipal(id: string) { }; } -function nativeGrant(id: string) { +function nativeGrant(id: string, overrides: Record = {}) { return { id, principalId: "prin_2", @@ -40,12 +45,20 @@ function nativeGrant(id: string) { origin: "invoker", conditions: null, expiresAt: null, + roleId: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", + ...overrides, }; } -test("listPrincipals reads the native page envelope and follows nextCursor", async () => { +/** The caller's own ceiling: whatever `firstActionOutsideCeiling` reads back + * for `principalId=prin_caller`. */ +function ceilingPage(grants: readonly unknown[]) { + return Response.json({ data: grants, nextCursor: null }); +} + +test("listPrincipals reads the stock page envelope and follows nextCursor", async () => { const seen: string[] = []; const fetchImpl = (async (url: string | URL | Request) => { const raw = String(url); @@ -64,16 +77,12 @@ test("listPrincipals reads the native page envelope and follows nextCursor", asy const principals = await listPrincipals(testConfig(fetchImpl)); expect(principals.map((p) => p.id)).toEqual(["prin_1", "prin_2", "prin_3"]); - expect(principals[0]).toMatchObject({ - kind: "workflow", - refId: "prin_1@example.test", - status: "active", - }); + expect(seen[0]).toStartWith(`${TENANT_BASE}/principals?`); expect(seen).toHaveLength(2); expect(seen[1]).toContain("cursor=prin_2"); }); -test("listGrants passes filters through and reads the native page envelope", async () => { +test("listGrants passes filters through and reads the stock page envelope", async () => { let seenUrl = ""; const fetchImpl = (async (url: string | URL | Request) => { seenUrl = String(url); @@ -84,9 +93,9 @@ test("listGrants passes filters through and reads the native page envelope", asy principalId: "prin_2", resource: "workflow-run:*", }); - expect(seenUrl).toContain( - "/grants?principalId=prin_2&resource=workflow-run%3A*", - ); + expect(seenUrl).toContain(`${TENANT_BASE}/grants?`); + expect(seenUrl).toContain("principalId=prin_2"); + expect(seenUrl).toContain("resource=workflow-run%3A*"); expect(grants).toHaveLength(1); expect(grants[0]).toMatchObject({ id: "grant_1", @@ -115,23 +124,24 @@ test("listGrants follows nextCursor so a full page plus one all return", async ( principalId: "prin_2", }); expect(grants).toHaveLength(51); - expect(grants.map((g) => g.id)).toEqual(all.map((g) => g.id)); expect(seen).toHaveLength(2); - expect(seen[0]).toContain("principalId=prin_2"); expect(seen[1]).toContain("cursor=page_2"); }); -test("grantAccess posts one native single-action body per action and parses single GrantResponse objects", async () => { +test("grantAccess reads the caller's own grants first, then posts one stock single-action body per action", async () => { const posted: unknown[] = []; const fetchImpl = (async ( url: string | URL | Request, init?: RequestInit, ) => { - expect(String(url)).toBe( - "https://hub.example.com/api/workflow-access/grants", - ); - expect(init?.method).toBe("POST"); - const body = JSON.parse(String(init?.body)) as Record; + if (init?.method !== "POST") { + expect(String(url)).toContain("principalId=prin_caller"); + return ceilingPage([ + nativeGrant("own_1", { principalId: "prin_caller", action: "*" }), + ]); + } + expect(String(url)).toBe(`${TENANT_BASE}/grants`); + const body = JSON.parse(String(init.body)) as Record; posted.push(body); return new Response( JSON.stringify({ @@ -166,14 +176,33 @@ test("grantAccess posts one native single-action body per action and parses sing expect(grants.map((g) => g.action)).toEqual(["read", "write"]); }); -test("a 403 with the native error envelope surfaces as AccessForbiddenError with the hub's message", async () => { +test("grantAccess refuses a pair outside the caller's own authority, before any write", async () => { + const methods: (string | undefined)[] = []; + const fetchImpl = (async ( + _url: string | URL | Request, + init?: RequestInit, + ) => { + methods.push(init?.method); + return ceilingPage([ + nativeGrant("own_1", { principalId: "prin_caller", action: "read" }), + ]); + }) as unknown as typeof fetch; + + await expect( + grantAccess(testConfig(fetchImpl), { + principalId: "prin_2", + resource: "workflow-run:*", + actions: ["read", "write"], + }), + ).rejects.toThrow(DelegationCeilingError); + expect(methods).toEqual([undefined]); +}); + +test("a 403 from the stock route surfaces as AccessForbiddenError with the hub's message", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ - error: { - code: "forbidden", - message: "Not a member of this tenant", - }, + error: { code: "forbidden", message: "Not a member of this tenant" }, }), { status: 403 }, )) as unknown as typeof fetch; @@ -185,20 +214,13 @@ test("a 403 with the native error envelope surfaces as AccessForbiddenError with actions: ["read"], }), ).rejects.toThrow(AccessForbiddenError); - await expect( - grantAccess(testConfig(fetchImpl), { - principalId: "prin_2", - resource: "workflow-run:*", - actions: ["read"], - }), - ).rejects.toThrow("Not a member of this tenant"); }); -test("a 404 with the native error envelope from revokeAccess surfaces as AccessNotFoundError", async () => { +test("a 404 looking up the grant to revoke surfaces as AccessNotFoundError", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ - error: { code: "not_found", message: "Grant not found" }, + error: { code: "not_found", message: "no such grant" }, }), { status: 404 }, )) as unknown as typeof fetch; @@ -208,11 +230,44 @@ test("a 404 with the native error envelope from revokeAccess surfaces as AccessN ).rejects.toThrow(AccessNotFoundError); }); -test("revokeAccess treats a native 204 with no body as success", async () => { - const fetchImpl = (async () => - new Response(null, { status: 204 })) as unknown as typeof fetch; +test("revokeAccess checks the ceiling against the grant it read back, then deletes", async () => { + const calls: string[] = []; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + calls.push(`${init?.method ?? "GET"} ${String(url)}`); + if (init?.method === "DELETE") return new Response(null, { status: 204 }); + if (String(url).includes("principalId=prin_caller")) { + return ceilingPage([ + nativeGrant("own_1", { principalId: "prin_caller", action: "*" }), + ]); + } + return Response.json(nativeGrant("grant_1")); + }) as unknown as typeof fetch; await revokeAccess(testConfig(fetchImpl), "grant_1"); + expect(calls[0]).toBe(`GET ${TENANT_BASE}/grants/grant_1`); + expect(calls.at(-1)).toBe(`DELETE ${TENANT_BASE}/grants/grant_1`); +}); + +test("revokeAccess refuses a grant outside the caller's own authority", async () => { + const methods: (string | undefined)[] = []; + const fetchImpl = (async ( + url: string | URL | Request, + init?: RequestInit, + ) => { + methods.push(init?.method); + if (String(url).includes("principalId=prin_caller")) { + return ceilingPage([]); + } + return Response.json(nativeGrant("grant_1")); + }) as unknown as typeof fetch; + + await expect(revokeAccess(testConfig(fetchImpl), "grant_1")).rejects.toThrow( + DelegationCeilingError, + ); + expect(methods).not.toContain("DELETE"); }); test("a grant body missing principalId is a shape error, never a null-linked grant", async () => { diff --git a/packages/access-tools/src/client.ts b/packages/access-tools/src/client.ts index db68b41cab..92ca1da483 100644 --- a/packages/access-tools/src/client.ts +++ b/packages/access-tools/src/client.ts @@ -1,22 +1,27 @@ -// Client for the workflow-run-authenticated counterpart of the native -// Interchange tenant routes (`@intx/hub-api`: `createPrincipalRoutes`, -// `createGrantRoutes`) — see `./routes.ts`, mounted at -// `/api/workflow-access`. Same auth-header shape, same error-handling, -// same arktype-response-parsing pattern as every other tool bundle's -// `client.ts` in this codebase (`@corbits/agent-directory-tools`, -// `@corbits/capability-tools`); only the base path differs. +// A minimal client for the STOCK `@intx/hub-api` principal and grant +// routes (`/api/tenants/:tenantId/principals`, `/grants`), spoken with the +// workflow run's own bearer credential: the sidecar token plus the run +// address, which the hub resolves to this run's principal and tenant before +// the stock handler's own `requireGrant` check runs. There is no +// Workbench-specific mirror of these routes any more. // -// Every request and response speaks the native Interchange contract: -// `{data, nextCursor}` pages for `GET /principals` and `GET /grants`, -// one single-action `POST /grants` body per grant (returning the single -// `GrantResponse` object), `DELETE /grants/:grantId` returning `204`, and -// the canonical `{error: {code, message}}` envelope on failures -// (`AccessForbiddenError` / `AccessNotFoundError` surface the hub's -// `message` verbatim so Myra can act on it). +// The tenant id is a path segment on every stock tenant route, so it rides +// in the client config; the sidecar threads it onto the step env from the +// hub's signed deploy frame. It is never trusted as authority — the hub sets +// the acting tenant from the authenticated run alone. +// +// Grants are NOT inherited across the tenant ancestor tree, so every read +// and write here is the run's own tenant and nothing above it. import { type } from "arktype"; +import { evaluateGrants, type GrantRule } from "@intx/authz"; export interface AccessToolClientConfig { + /** The hub's plain HTTP origin. */ readonly hubAccessUrl: string; + /** The run's own tenant — the `:tenantId` segment of every stock route. */ + readonly tenantId: string; + /** The run's own principal, whose authority bounds every grant it makes. */ + readonly principalId: string; readonly sidecarToken: string; readonly address: string; /** Override for tests; defaults to the global `fetch`. */ @@ -51,104 +56,161 @@ function authHeaders(config: AccessToolClientConfig): Record { }; } -/** Pulls `error.message` out of the canonical hub envelope - * (`{error: {code, message}}`, per `errorResponse` in - * `@intx/hub-common/errors`), if `body` matches that shape. */ +function tenantBase(config: AccessToolClientConfig): string { + return `${config.hubAccessUrl}/api/tenants/${encodeURIComponent(config.tenantId)}`; +} + +/** Pulls a message out of either error envelope the hub emits: the + * canonical `{error: {code, userMessage, refId}}` and the stock + * `{error: {code, message}}` shape the vendored routes use. */ function errorMessageFrom(body: unknown): string | undefined { if (body === null || typeof body !== "object" || !("error" in body)) { return undefined; } const error = (body as { error: unknown }).error; - if (error === null || typeof error !== "object" || !("message" in error)) { - return undefined; + if (error === null || typeof error !== "object") return undefined; + for (const key of ["userMessage", "message"] as const) { + if (key in error) { + const value = (error as Record)[key]; + if (typeof value === "string") return value; + } } - const message = (error as { message: unknown }).message; - return typeof message === "string" ? message : undefined; + return undefined; } -export class AccessForbiddenError extends Error { - override readonly name = "AccessForbiddenError"; +async function readErrorMessage( + response: Response, + fallback: string, +): Promise { + const body: unknown = await response.json().catch(() => undefined); + return errorMessageFrom(body) ?? fallback; } -export class AccessNotFoundError extends Error { - override readonly name = "AccessNotFoundError"; -} +/** Thrown when the hub rejects the request as forbidden — the caller's own + * principal holds no `principal:*`/`grant:*` grant — distinct from a bare + * transport/HTTP failure, so a caller can report honestly that a human must + * grant it access first. */ +export class AccessForbiddenError extends Error {} -async function throwForStatus( - operation: string, - response: Response, -): Promise { - const body: unknown = await response.json().catch(() => null); - const message = errorMessageFrom(body) ?? response.statusText; +/** Thrown for a not-found principal/grant id, distinct from a bare + * transport/HTTP failure. */ +export class AccessNotFoundError extends Error {} + +async function doRequest( + config: AccessToolClientConfig, + path: string, + init: RequestInit, + failureLabel: string, +): Promise { + const doFetch = config.fetchImpl ?? fetch; + const response = await doFetch(`${tenantBase(config)}${path}`, { + ...init, + headers: { ...authHeaders(config), ...(init.headers ?? {}) }, + }); if (response.status === 403) { - throw new AccessForbiddenError(`${operation} failed: ${message}`); + throw new AccessForbiddenError( + await readErrorMessage(response, `${failureLabel}: forbidden`), + ); } if (response.status === 404) { - throw new AccessNotFoundError(`${operation} failed: ${message}`); + throw new AccessNotFoundError( + await readErrorMessage(response, `${failureLabel}: not found`), + ); } - throw new Error(`${operation} failed: ${message}`); + if (!response.ok) { + throw new Error( + `${failureLabel}: ${response.status} ${response.statusText}`, + ); + } + return response; } -const ListedPrincipalsResponse = type({ +const PAGE_LIMIT = 100; + +const PrincipalsPage = type({ data: type({ id: "string", - kind: "'user' | 'agent' | 'workflow'", + kind: "'user'|'agent'|'workflow'", refId: "string", - status: "'active' | 'suspended' | 'invited' | 'deactivated'", + status: "'active'|'suspended'|'invited'|'deactivated'", }).array(), "nextCursor?": "string | null", }); -const ListedGrantsResponse = type({ +const GrantsPage = type({ data: type({ id: "string", principalId: "string | null", resource: "string", action: "string", - effect: "'allow' | 'deny' | 'ask'", + effect: "'allow'|'deny'|'ask'", + origin: "'system'|'role'|'creator'|'invoker'", + "roleId?": "string | null", + "conditions?": "Record | null", + "expiresAt?": "string | null", }).array(), "nextCursor?": "string | null", }); -const CreatedGrantResponse = type({ +const CreatedGrant = type({ id: "string", principalId: "string | null", resource: "string", action: "string", - effect: "'allow' | 'deny' | 'ask'", + effect: "'allow'|'deny'|'ask'", }); -/** Lists every principal in the run's tenant, following the native - * `nextCursor` pages to the end — Myra needs the full list to find the - * agent she just created before granting it access. */ -export async function listPrincipals( +type GrantRow = (typeof GrantsPage.infer)["data"][number]; + +/** Walks every page of a stock paginated collection. The tool surfaces a + * tenant's whole principal or grant list, so a truncated first page would + * read as "that principal has no grants" — a wrong answer, not a slow one. */ +async function readAllPages( config: AccessToolClientConfig, -): Promise { - const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); - const principals: ListedPrincipal[] = []; - let cursor: string | null | undefined; - do { - const url = - cursor === undefined || cursor === null - ? `${config.hubAccessUrl}/principals` - : `${config.hubAccessUrl}/principals?cursor=${encodeURIComponent(cursor)}`; - const response = await fetchImpl(url, { - headers: { ...authHeaders(config) }, - }); - if (!response.ok) { - await throwForStatus("Listing principals", response); - } - const body: unknown = await response.json(); - const parsed = ListedPrincipalsResponse(body); + path: string, + query: URLSearchParams, + failureLabel: string, + parse: ( + body: unknown, + ) => + | { data: readonly T[]; nextCursor?: string | null | undefined } + | type.errors, +): Promise { + const items: T[] = []; + let cursor: string | undefined; + for (;;) { + const params = new URLSearchParams(query); + params.set("limit", String(PAGE_LIMIT)); + if (cursor !== undefined) params.set("cursor", cursor); + const response = await doRequest( + config, + `${path}?${params.toString()}`, + {}, + failureLabel, + ); + const parsed = parse(await response.json()); if (parsed instanceof type.errors) { throw new Error( - `Principals response did not match the expected shape: ${parsed.summary}`, + `${failureLabel}: response did not match the expected shape: ${parsed.summary}`, ); } - principals.push(...parsed.data); - cursor = parsed.nextCursor ?? null; - } while (cursor !== null); - return principals; + items.push(...parsed.data); + const next = parsed.nextCursor; + if (next === undefined || next === null) return items; + cursor = next; + } +} + +export async function listPrincipals( + config: AccessToolClientConfig, +): Promise { + return await readAllPages( + config, + "/principals", + new URLSearchParams(), + "Listing principals failed", + (body) => PrincipalsPage(body), + ); } export interface ListGrantsFilter { @@ -156,92 +218,182 @@ export interface ListGrantsFilter { readonly resource?: string; } -/** Lists every grant in the run's tenant matching the given filters, - * following the native `nextCursor` pages to the end — the mirror honors - * the same `principalId`/`resource` query params as the native - * `GET /grants`. */ +async function listGrantRows( + config: AccessToolClientConfig, + filter?: ListGrantsFilter, +): Promise { + const params = new URLSearchParams(); + if (filter?.principalId !== undefined) { + params.set("principalId", filter.principalId); + } + if (filter?.resource !== undefined) params.set("resource", filter.resource); + return await readAllPages( + config, + "/grants", + params, + "Listing grants failed", + (body) => GrantsPage(body), + ); +} + export async function listGrants( config: AccessToolClientConfig, filter?: ListGrantsFilter, -): Promise { - const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); - const grants: ListedGrant[] = []; - let cursor: string | null | undefined; - do { - const params = new URLSearchParams(); - if (filter?.principalId !== undefined) - params.set("principalId", filter.principalId); - if (filter?.resource !== undefined) params.set("resource", filter.resource); - if (cursor !== undefined && cursor !== null) params.set("cursor", cursor); - const query = params.size === 0 ? "" : `?${params.toString()}`; - const response = await fetchImpl(`${config.hubAccessUrl}/grants${query}`, { - headers: { ...authHeaders(config) }, +): Promise { + const rows = await listGrantRows(config, filter); + return rows.map((row) => ({ + id: row.id, + principalId: row.principalId, + resource: row.resource, + action: row.action, + effect: row.effect, + })); +} + +function toGrantRule(row: GrantRow): GrantRule { + return { + id: row.id, + resource: row.resource, + action: row.action, + effect: row.effect, + origin: row.origin, + conditions: row.conditions ?? null, + expiresAt: + row.expiresAt === undefined || row.expiresAt === null + ? null + : new Date(row.expiresAt), + roleId: row.roleId ?? null, + principalId: row.principalId, + }; +} + +/** Thrown when the caller asks to grant or revoke authority it does not + * itself hold. */ +export class DelegationCeilingError extends Error {} + +/** + * The delegation ceiling: a caller may only grant (or revoke) authority it + * already holds itself, or `grant:*`/`create` alone would let any principal + * escalate past its own reach. + * + * Stock `POST /grants` has no such check, so the tool enforces it before + * calling: it reads the caller's own grants in its own tenant and evaluates + * each requested pair with `@intx/authz`'s own `evaluateGrants`, the same + * engine the hub authorizes with. A parent tenant's grant is never consulted + * — grants are not inherited across the ancestor tree, unlike credentials + * and tool packages. + * + * Returns the first action outside the ceiling, or null when every pair is + * within it. + * + * Two fidelity gaps against a server-side check, both recorded as upstream + * asks on CL-7575: role-derived grants are not visible through the stock + * principal-filtered listing, and the read-then-write is not atomic. + */ +export async function firstActionOutsideCeiling( + config: AccessToolClientConfig, + resource: string, + actions: readonly string[], +): Promise { + const own = await listGrantRows(config, { + principalId: config.principalId, + }); + const rules = own.map(toGrantRule); + for (const action of actions) { + const result = await evaluateGrants(rules, resource, action, { + principalId: config.principalId, + tenantId: config.tenantId, }); - if (!response.ok) { - await throwForStatus("Listing grants", response); - } - const body: unknown = await response.json(); - const parsed = ListedGrantsResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `List-grants response did not match the expected shape: ${parsed.summary}`, - ); - } - grants.push(...parsed.data); - cursor = parsed.nextCursor ?? null; - } while (cursor !== null); - return grants; + if (result.effect !== "allow") return action; + } + return null; } -/** Grants access by posting one native single-action `POST /grants` body - * per requested action — `effect: "allow"`, `origin: "invoker"` — and - * parsing each single `GrantResponse` object the hub returns. */ export async function grantAccess( config: AccessToolClientConfig, - request: GrantAccessRequest, -): Promise { - const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); + input: GrantAccessRequest, +): Promise { + const outside = await firstActionOutsideCeiling( + config, + input.resource, + input.actions, + ); + if (outside !== null) { + throw new DelegationCeilingError( + `Cannot grant "${input.resource}" "${outside}": exceeds the caller's own authority`, + ); + } + + // Stock `POST /grants` creates one resource/action pair per call, so a + // multi-action request is one call per action. A failure part-way leaves + // the already-created grants in place and surfaces loudly rather than + // rolling back behind the caller's back. const created: ListedGrant[] = []; - for (const action of request.actions) { - const response = await fetchImpl(`${config.hubAccessUrl}/grants`, { - method: "POST", - headers: { ...authHeaders(config), "content-type": "application/json" }, - body: JSON.stringify({ - principalId: request.principalId, - resource: request.resource, - action, - effect: "allow", - origin: "invoker", - }), - }); - if (!response.ok) { - await throwForStatus("Granting access", response); - } - const body: unknown = await response.json(); - const parsed = CreatedGrantResponse(body); + for (const action of input.actions) { + const response = await doRequest( + config, + "/grants", + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + principalId: input.principalId, + resource: input.resource, + action, + effect: "allow", + // The invoking human's authority delegated this grant: the tool + // declares `approval: "ask"`, so a human approved this exact + // principal/resource/actions triple before this call ran. + origin: "invoker", + }), + }, + "Granting access failed", + ); + const parsed = CreatedGrant(await response.json()); if (parsed instanceof type.errors) { throw new Error( `Grant-access response did not match the expected shape: ${parsed.summary}`, ); } - created.push(parsed); + created.push({ + id: parsed.id, + principalId: parsed.principalId, + resource: parsed.resource, + action: parsed.action, + effect: parsed.effect, + }); } return created; } -/** Revokes the grant with the given id. The native - * `DELETE /grants/:grantId` returns `204` with no body, so success is the - * absence of a throw. */ export async function revokeAccess( config: AccessToolClientConfig, grantId: string, ): Promise { - const fetchImpl = config.fetchImpl ?? ((...args) => fetch(...args)); - const response = await fetchImpl( - `${config.hubAccessUrl}/grants/${encodeURIComponent(grantId)}`, - { method: "DELETE", headers: { ...authHeaders(config) } }, + const response = await doRequest( + config, + `/grants/${encodeURIComponent(grantId)}`, + {}, + "Revoking access failed", ); - if (!response.ok) { - await throwForStatus("Revoking access", response); + const target = CreatedGrant(await response.json()); + if (target instanceof type.errors) { + throw new Error( + `Grant lookup response did not match the expected shape: ${target.summary}`, + ); + } + const outside = await firstActionOutsideCeiling(config, target.resource, [ + target.action, + ]); + if (outside !== null) { + throw new DelegationCeilingError( + `Cannot revoke "${target.resource}" "${target.action}": exceeds the caller's own authority`, + ); } + await doRequest( + config, + `/grants/${encodeURIComponent(grantId)}`, + { method: "DELETE" }, + "Revoking access failed", + ); } diff --git a/packages/access-tools/src/index.ts b/packages/access-tools/src/index.ts index e818babd64..7c4ea44ae9 100644 --- a/packages/access-tools/src/index.ts +++ b/packages/access-tools/src/index.ts @@ -5,6 +5,8 @@ export { revokeAccess, AccessForbiddenError, AccessNotFoundError, + DelegationCeilingError, + firstActionOutsideCeiling, type AccessToolClientConfig, type GrantAccessRequest, type ListedGrant, diff --git a/packages/access-tools/src/routes.ts b/packages/access-tools/src/routes.ts deleted file mode 100644 index 10a6a16d32..0000000000 --- a/packages/access-tools/src/routes.ts +++ /dev/null @@ -1,591 +0,0 @@ -// The workflow-run-authenticated counterpart of Interchange's native -// tenant principal/grant routes (`@intx/hub-api`'s `createPrincipalRoutes` -// / `createGrantRoutes`, served at `/api/tenants/:tenantId/principals` and -// `/grants`) — the server half of `@corbits/access-tools`' -// `list_principals`, `list_grants`, `grant_access`, and `revoke_access` -// tools. A workflow child has no browser session, only its sidecar bearer -// token and its own run address, so it authenticates through a -// `WorkflowRunAuthenticator` rather than the tenant-session pipeline — -// the same reasoning `@corbits/agent-directory`'s -// workflow-capability-routes and `@corbits/chat`'s -// workflow-participant-routes give for their own bearer-authenticated -// mirrors. Mounted OUTSIDE the tenant prefix for that reason, at -// `/api/workflow-access`. Every write is scoped to the authenticated -// run's own tenant/principal alone — identity never rides in a request -// body. -// -// Same contract as the native routes: `{data, nextCursor}` cursor pages -// for the two `GET` lists (same cursor algorithm, so cursors stay -// interchangeable), the native `CreateGrant` single-action body for -// `POST /grants` returning the single native `GrantResponse` object, -// `204` deletes, and the canonical `{error: {code, message}}` envelope on -// every failure. The ONLY deliberate deltas from native are the run-bearer -// auth above and the delegation ceiling below: a caller may only grant -// (or revoke) authority it already holds itself — otherwise -// `grant:*`/`create` alone would let any principal escalate past its own -// ceiling. That check reuses `@intx/authz`'s own `authorize` against the -// SAME grant store `requireGrant` checks against, per requested pair. -// -// Authorization for every route runs through `@intx/hub-api`'s own -// `createRequireGrant` against that same store and condition registry, -// resolved for the caller's real principal row (fetched once here, -// exactly like `@intx/hub-api`'s own `createWorkflowRunDeployAuth` -// resolves the deploy route's bearer mirror). A principal the tenant -// hasn't granted `principal:*`/`grant:*` gets a real 403 from the real -// grant store, not a bundle-local approximation. -import { and, desc, eq, lt, ne, or, sql } from "drizzle-orm"; -import { Hono } from "hono"; -import { type } from "arktype"; - -import { parseGrantRow, type DB } from "@intx/db"; -import { grant, principal, tenant } from "@intx/db/schema"; -import { generateId } from "@intx/hub-common"; -import { - createRequireGrant, - idResource, - resolveWorkflowPrincipalLabels, - type RequireGrant, - type TenantEnv, -} from "@intx/hub-api"; -import { authorize } from "@intx/authz"; -import { base64urlDecode, base64urlEncode, CreateGrant } from "@intx/types"; -import type { ConditionRegistry, GrantStore } from "@intx/types/authz"; - -export type WorkflowAccessRunScope = { - readonly tenantId: string; - readonly principalId: string; -}; - -export type WorkflowRunAuthenticator = { - resolve( - token: string, - runAddress: string, - ): Promise; -}; - -export type CreateWorkflowAccessRoutesDeps = { - db: DB["db"]; - authenticator: WorkflowRunAuthenticator; - grantStore: GrantStore; - conditionRegistry: ConditionRegistry; -}; - -/** Cursor paging identical to the native tenant lists - * (`vendor/intx/hub-api/src/pagination.ts`, reimplemented here because - * that module is not part of `@intx/hub-api`'s published surface): - * keyset on `(createdAt, id)` descending, opaque base64url cursor, default - * limit 50, max 100. Cursors minted here decode on the native routes and - * vice versa. */ -const DEFAULT_LIMIT = 50; -const MAX_LIMIT = 100; - -const CursorData = type({ - t: /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - id: "string", -}); - -function parsePageParams(query: { - cursor: string | undefined; - limit: string | undefined; -}): { limit: number; cursor: { t: string; id: string } | null } { - const raw = query.limit !== undefined ? Number(query.limit) : DEFAULT_LIMIT; - const limit = Number.isNaN(raw) - ? DEFAULT_LIMIT - : Math.min(Math.max(1, raw), MAX_LIMIT); - let cursor: { t: string; id: string } | null = null; - if (query.cursor !== undefined) { - try { - const data = CursorData( - JSON.parse(new TextDecoder().decode(base64urlDecode(query.cursor))), - ); - cursor = data instanceof type.errors ? null : data; - } catch { - // report-error-ignore: a malformed cursor from a caller is bad - // input, not an incident — it falls back to the first page like an - // absent cursor, exactly as the native tenant routes do. - cursor = null; - } - } - return { limit, cursor }; -} - -function encodeCursor(createdAt: Date, id: string): string { - return base64urlEncode( - new TextEncoder().encode( - JSON.stringify({ t: createdAt.toISOString(), id }), - ), - ); -} - -function pageResponse( - items: T[], - rows: { createdAt: Date; id: string }[], - limit: number, -): { data: T[]; nextCursor: string | null } { - const hasMore = items.length === limit; - const lastRow = hasMore ? rows[rows.length - 1] : undefined; - return { - data: items, - nextCursor: - lastRow !== undefined - ? encodeCursor(lastRow.createdAt, lastRow.id) - : null, - }; -} - -// A caller may only grant (or revoke) authority it already holds itself — -// otherwise `grant:*`/`create` alone would let any principal escalate past -// its own ceiling. Reuses `@intx/authz`'s own `authorize` against the same -// grant store `requireGrant` checks against, per requested pair. -async function pairOutsideCeiling( - deps: Pick< - CreateWorkflowAccessRoutesDeps, - "grantStore" | "conditionRegistry" - >, - callerPrincipalId: string, - tenantId: string, - resource: string, - action: string, -): Promise { - const result = await authorize( - deps.grantStore, - callerPrincipalId, - tenantId, - resource, - action, - deps.conditionRegistry, - ); - return result.effect !== "allow"; -} - -function nativeError(code: string, message: string) { - return { error: { code, message } }; -} - -type ResolvedNames = { - roleNames: Map; - principalNames: Map; -}; - -async function resolveGrantNames( - db: DB["db"], - grants: (typeof grant.$inferSelect)[], -): Promise { - const roleIds = [ - ...new Set( - grants.map((g) => g.roleId).filter((id): id is string => id !== null), - ), - ]; - const principalIds = [ - ...new Set( - grants - .map((g) => g.principalId) - .filter((id): id is string => id !== null), - ), - ]; - - const roleNames = new Map(); - if (roleIds.length > 0) { - const roles = await db.query.role.findMany({ - where: (r, { inArray }) => inArray(r.id, roleIds), - }); - for (const r of roles) { - roleNames.set(r.id, r.name); - } - } - - const principalNames = new Map(); - if (principalIds.length > 0) { - const principals = await db.query.principal.findMany({ - where: (p, { inArray }) => inArray(p.id, principalIds), - }); - const userRefIds = principals - .filter((p) => p.kind === "user") - .map((p) => p.refId); - const workflowRefIds = principals - .filter((p) => p.kind === "workflow") - .map((p) => p.refId); - - const refToName = new Map(); - if (userRefIds.length > 0) { - const users = await db.query.user.findMany({ - where: (u, { inArray }) => inArray(u.id, userRefIds), - }); - for (const u of users) { - refToName.set(u.id, u.name); - } - } - if (workflowRefIds.length > 0) { - const wfNames = await resolveWorkflowPrincipalLabels(db, workflowRefIds); - for (const [refId, name] of wfNames) { - refToName.set(refId, name); - } - } - for (const p of principals) { - const name = refToName.get(p.refId); - if (name) principalNames.set(p.id, name); - } - } - - return { roleNames, principalNames }; -} - -/** The native `GrantResponse` item shape for one grant row. */ -function formatGrant(row: typeof grant.$inferSelect, names?: ResolvedNames) { - const parsed = parseGrantRow(row); - const ts = (d: Date): string => d.toISOString(); - return { - id: parsed.id, - tenantId: parsed.tenantId, - roleId: parsed.roleId ?? null, - roleName: (parsed.roleId && names?.roleNames.get(parsed.roleId)) ?? null, - principalId: parsed.principalId ?? null, - principalName: - (parsed.principalId && names?.principalNames.get(parsed.principalId)) ?? - null, - resource: parsed.resource, - action: parsed.action, - effect: parsed.effect, - conditions: parsed.conditions, - origin: parsed.origin, - expiresAt: parsed.expiresAt ? ts(parsed.expiresAt) : null, - createdAt: ts(parsed.createdAt), - updatedAt: ts(parsed.updatedAt), - }; -} - -export function createWorkflowAccessRoutes( - deps: CreateWorkflowAccessRoutesDeps, -): Hono { - const app = new Hono(); - const requireGrant: RequireGrant = createRequireGrant({ - grantStore: deps.grantStore, - conditionRegistry: deps.conditionRegistry, - }); - - app.use("*", async (c, next) => { - const authHeader = c.req.header("authorization") ?? ""; - const token = authHeader.startsWith("Bearer ") - ? authHeader.slice("Bearer ".length) - : ""; - const address = c.req.header("x-workflow-run-address") ?? ""; - const scope = await deps.authenticator.resolve(token, address); - if (scope === null) { - return c.json( - nativeError( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), - 401, - ); - } - - // Resolve the real tenant + principal rows, exactly like - // `@intx/hub-api`'s own `createWorkflowRunDeployAuth` does for the - // deploy route's bearer mirror — `createRequireGrant`'s middleware - // reads `c.get("principal")`/`c.get("tenant")` as full rows, not a - // bare id pair. - const [tenantRow, principalRow] = await Promise.all([ - deps.db.query.tenant.findFirst({ where: eq(tenant.id, scope.tenantId) }), - deps.db.query.principal.findFirst({ - where: and( - eq(principal.id, scope.principalId), - eq(principal.tenantId, scope.tenantId), - ), - }), - ]); - if ( - tenantRow === undefined || - principalRow === undefined || - principalRow.status !== "active" - ) { - return c.json( - nativeError( - "unauthorized", - "Missing or unrecognized sidecar bearer token / run address", - ), - 401, - ); - } - c.set("tenant", tenantRow); - c.set("principal", principalRow); - await next(); - }); - - app.get("/principals", requireGrant("principal:*", "read"), async (c) => { - const tenantCtx = c.get("tenant"); - const kind = c.req.query("kind"); - const status = c.req.query("status"); - const { limit, cursor } = parsePageParams({ - cursor: c.req.query("cursor"), - limit: c.req.query("limit"), - }); - - const conditions = [eq(principal.tenantId, tenantCtx.id)]; - if (kind === "user" || kind === "agent" || kind === "workflow") { - conditions.push(eq(principal.kind, kind)); - } - if ( - status === "active" || - status === "suspended" || - status === "invited" || - status === "deactivated" - ) { - conditions.push(eq(principal.status, status)); - } else { - // Exclude deactivated principals by default, like the native list. - conditions.push(ne(principal.status, "deactivated")); - } - if (cursor) { - conditions.push( - or( - lt(principal.createdAt, new Date(cursor.t)), - and( - eq(principal.createdAt, new Date(cursor.t)), - lt(principal.id, cursor.id), - ), - ) ?? sql`false`, - ); - } - - const rows = await deps.db.query.principal.findMany({ - where: and(...conditions), - orderBy: [desc(principal.createdAt), desc(principal.id)], - limit, - }); - - const assignments = - rows.length > 0 - ? await deps.db.query.principalRole.findMany({ - where: (pr, { inArray }) => - inArray( - pr.principalId, - rows.map((p) => p.id), - ), - }) - : []; - const assignedRoleIds = [...new Set(assignments.map((a) => a.roleId))]; - const assignedRoles = - assignedRoleIds.length > 0 - ? await deps.db.query.role.findMany({ - where: (r, { inArray }) => inArray(r.id, assignedRoleIds), - }) - : []; - const roleMap = new Map(assignedRoles.map((r) => [r.id, r])); - const rolesByPrincipal = new Map(); - for (const a of assignments) { - const r = roleMap.get(a.roleId); - if (!r) continue; - const list = rolesByPrincipal.get(a.principalId) ?? []; - list.push({ id: r.id, name: r.name }); - rolesByPrincipal.set(a.principalId, list); - } - - const userRefIds = rows - .filter((p) => p.kind === "user") - .map((p) => p.refId); - const workflowRefIds = rows - .filter((p) => p.kind === "workflow") - .map((p) => p.refId); - const identities = new Map< - string, - { displayName: string; email?: string } - >(); - if (userRefIds.length > 0) { - const users = await deps.db.query.user.findMany({ - where: (u, { inArray }) => inArray(u.id, userRefIds), - }); - for (const u of users) { - identities.set(u.id, { displayName: u.name, email: u.email }); - } - } - if (workflowRefIds.length > 0) { - const wfNames = await resolveWorkflowPrincipalLabels( - deps.db, - workflowRefIds, - ); - for (const [refId, displayName] of wfNames) { - identities.set(refId, { displayName }); - } - } - - const items = rows.map((p) => { - const identity = identities.get(p.refId); - return { - id: p.id, - tenantId: p.tenantId, - kind: p.kind, - refId: p.refId, - displayName: identity?.displayName ?? p.refId, - ...(identity?.email ? { email: identity.email } : {}), - status: p.status, - roles: rolesByPrincipal.get(p.id) ?? [], - createdAt: p.createdAt.toISOString(), - updatedAt: p.updatedAt.toISOString(), - }; - }); - - return c.json(pageResponse(items, rows, limit)); - }); - - app.get("/grants", requireGrant("grant:*", "read"), async (c) => { - const tenantCtx = c.get("tenant"); - const principalId = c.req.query("principalId"); - const roleId = c.req.query("roleId"); - const resource = c.req.query("resource"); - const effect = c.req.query("effect"); - const { limit, cursor } = parsePageParams({ - cursor: c.req.query("cursor"), - limit: c.req.query("limit"), - }); - - const conditions = [eq(grant.tenantId, tenantCtx.id)]; - if (principalId) conditions.push(eq(grant.principalId, principalId)); - if (roleId) conditions.push(eq(grant.roleId, roleId)); - if (resource) conditions.push(eq(grant.resource, resource)); - if (effect === "allow" || effect === "deny" || effect === "ask") { - conditions.push(eq(grant.effect, effect)); - } - if (cursor) { - conditions.push( - or( - lt(grant.createdAt, new Date(cursor.t)), - and(eq(grant.createdAt, new Date(cursor.t)), lt(grant.id, cursor.id)), - ) ?? sql`false`, - ); - } - - const rows = await deps.db.query.grant.findMany({ - where: and(...conditions), - orderBy: [desc(grant.createdAt), desc(grant.id)], - limit, - }); - - const names = await resolveGrantNames(deps.db, rows); - return c.json( - pageResponse( - rows.map((g) => formatGrant(g, names)), - rows, - limit, - ), - ); - }); - - app.post("/grants", requireGrant("grant:*", "create"), async (c) => { - const tenantCtx = c.get("tenant"); - const callerPrincipal = c.get("principal"); - // The native `CreateGrant` body: exactly one target, one action. - const body = CreateGrant(await c.req.json().catch(() => undefined)); - if (body instanceof type.errors) { - return c.json(nativeError("bad_request", body.summary), 400); - } - - const outside = await pairOutsideCeiling( - deps, - callerPrincipal.id, - tenantCtx.id, - body.resource, - body.action, - ); - if (outside) { - return c.json( - nativeError( - "forbidden", - `Cannot grant "${body.resource}" "${body.action}": exceeds the caller's own authority`, - ), - 403, - ); - } - - if (body.principalId != null) { - const target = await deps.db.query.principal.findFirst({ - where: and( - eq(principal.id, body.principalId), - eq(principal.tenantId, tenantCtx.id), - ), - }); - if (target === undefined) { - return c.json( - nativeError( - "not_found", - `No principal "${body.principalId}" in this tenant`, - ), - 404, - ); - } - } - - const now = new Date(); - const [row] = await deps.db - .insert(grant) - .values({ - id: generateId("grant"), - tenantId: tenantCtx.id, - roleId: body.roleId ?? null, - principalId: body.principalId ?? null, - resource: body.resource, - action: body.action, - effect: body.effect, - conditions: body.conditions ?? null, - // The invoking human's authority delegated this grant: the - // tool declares `approval: "ask"`, so a human already - // approved this exact principal/resource/action triple - // before this route ever ran. - origin: body.origin, - expiresAt: body.expiresAt ? new Date(body.expiresAt) : null, - createdAt: now, - updatedAt: now, - }) - .returning(); - if (row === undefined) { - return c.json(nativeError("not_found", "Grant not found"), 404); - } - - const names = await resolveGrantNames(deps.db, [row]); - return c.json(formatGrant(row, names), 201); - }); - - app.delete( - "/grants/:grantId", - requireGrant(idResource("grant", "grantId"), "manage"), - async (c) => { - const tenantCtx = c.get("tenant"); - const callerPrincipal = c.get("principal"); - const grantId = c.req.param("grantId"); - const target = await deps.db.query.grant.findFirst({ - where: and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id)), - }); - if (target === undefined) { - return c.json(nativeError("not_found", "Grant not found"), 404); - } - - const outside = await pairOutsideCeiling( - deps, - callerPrincipal.id, - tenantCtx.id, - target.resource, - target.action, - ); - if (outside) { - return c.json( - nativeError( - "forbidden", - `Cannot revoke "${target.resource}" "${target.action}": exceeds the caller's own authority`, - ), - 403, - ); - } - - const deleted = await deps.db - .delete(grant) - .where(and(eq(grant.id, grantId), eq(grant.tenantId, tenantCtx.id))) - .returning(); - if (deleted.length === 0) { - return c.json(nativeError("not_found", "Grant not found"), 404); - } - return c.body(null, 204); - }, - ); - - return app; -} diff --git a/packages/access-tools/src/tool.test.ts b/packages/access-tools/src/tool.test.ts index fee8d29bd8..d2e159b48b 100644 --- a/packages/access-tools/src/tool.test.ts +++ b/packages/access-tools/src/tool.test.ts @@ -10,7 +10,9 @@ import { function testEnv(): WorkflowAccessEnv { return { - hubAccessUrl: "https://hub.example.com/api/workflow-access", + hubAccessUrl: "https://hub.example.com", + tenantId: "ten_1", + principalId: "prin_caller", sidecarToken: "sc-token", address: "run_1@workflow", } as unknown as WorkflowAccessEnv; @@ -38,7 +40,7 @@ test("list_grants rejects invalid input before ever calling fetch", async () => } }); -test("a 403 with the native error envelope from grant_access surfaces as a clear tool error", async () => { +test("a 403 from the stock grant route surfaces as a clear tool error", async () => { const originalFetch = globalThis.fetch; globalThis.fetch = (async () => new Response( @@ -63,7 +65,7 @@ test("a 403 with the native error envelope from grant_access surfaces as a clear ); expect(result.isError).toBe(true); expect(result.content).toBe( - "Granting access failed: Myra can only grant room:read — she herself holds no room:write here", + "Myra can only grant room:read — she herself holds no room:write here", ); } finally { globalThis.fetch = originalFetch; @@ -82,6 +84,7 @@ test("a successful grant_access round-trips into list_grants", async () => { origin: "invoker", conditions: null, expiresAt: null, + roleId: null, createdAt: "2026-09-01T00:00:00.000Z", updatedAt: "2026-09-01T00:00:00.000Z", }; @@ -100,7 +103,16 @@ test("a successful grant_access round-trips into list_grants", async () => { ); } if (path.endsWith("/grants")) { - // Native `GET /grants` returns the `{data, nextCursor}` page envelope. + // The caller's own ceiling read: wide enough to delegate anything. + if (String(url).includes("principalId=prin_caller")) { + return new Response( + JSON.stringify({ + data: [{ ...nativeRow("own_1", "*"), principalId: "prin_caller" }], + nextCursor: null, + }), + ); + } + // Stock `GET /grants` returns the `{data, nextCursor}` page envelope. return new Response( JSON.stringify({ data: diff --git a/packages/access-tools/src/tool.ts b/packages/access-tools/src/tool.ts index ca84cb556a..12c6d9896c 100644 --- a/packages/access-tools/src/tool.ts +++ b/packages/access-tools/src/tool.ts @@ -11,9 +11,9 @@ // sidecar's per-step env builder, the same ground every other manager- // tools bundle's own env keys are threaded from. // -// See `./client.ts` for the workflow-run-authenticated routes this -// bundle's execution calls, and `./routes.ts` for their hub-side -// implementation. +// See `./client.ts` for the stock Interchange tenant routes this bundle's +// execution calls with the run bearer, and for the delegation ceiling it +// enforces before it grants anything. import { defineTool } from "@intx/agent"; import type { BaseEnv } from "@intx/agent"; import type { ToolCall, ToolResult } from "@intx/types/runtime"; @@ -37,6 +37,8 @@ export interface WorkflowAccessEnv extends BaseEnv { readonly hubAccessUrl: string; readonly sidecarToken: string; readonly address: string; + readonly tenantId: string; + readonly principalId: string; } const ListGrantsInput = type({ @@ -68,6 +70,8 @@ function clientConfig(env: WorkflowAccessEnv): AccessToolClientConfig { hubAccessUrl: env.hubAccessUrl, sidecarToken: env.sidecarToken, address: env.address, + tenantId: env.tenantId, + principalId: env.principalId, }; } @@ -187,7 +191,13 @@ async function runRevokeAccess( */ export const accessTools = defineTool({ id: "@corbits/access-tools/access", - requires: ["hubAccessUrl", "sidecarToken", "address"], + requires: [ + "hubAccessUrl", + "sidecarToken", + "address", + "tenantId", + "principalId", + ], definitions: [ { name: LIST_PRINCIPALS_TOOL }, { name: LIST_GRANTS_TOOL }, diff --git a/packages/access-tools/test/myra-orchestrates.test.ts b/packages/access-tools/test/myra-orchestrates.test.ts deleted file mode 100644 index 406c930269..0000000000 --- a/packages/access-tools/test/myra-orchestrates.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -// Myra orchestrates the bench, end to end (CL-7465): the exact tool -// bundles the reactor invokes — `@corbits/agent-directory-tools`'s -// `create_agent` and `@corbits/access-tools`'s `grant_access` — drive the -// REAL mounted hub routes (`createWorkflowAgentCreateRoutes` and -// `createWorkflowAccessRoutes`) through a stubbed `fetch` dispatcher, with -// a scripted turn list standing in for model inference. Turn 1 creates a -// specialist agent through the native workflow-run create route (same -// materialization the tenant-session route uses); turn 2 mints that -// specialist a scoped grant through the native grant route, which lands a -// real row in Postgres; turn 3 reads it back through `list_grants`. -// -// The deployer seam stays stubbed (a real deploy needs the sidecar probe), -// mirroring `packages/agent-directory/test/workflow-create-routes.test.ts`'s -// own fakes — the route, its auth, its inventory checks, and its core -// input are all real. The grant leg is fully real: route + in-memory -// `@intx/authz` store + drizzle Postgres handle. -// -// DB-gated like `./routes.test.ts`: needs `DATABASE_URL` (see -// `scripts/e2e/db-gate`). -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { eq } from "drizzle-orm"; -import { Hono } from "hono"; -import type { ToolCall } from "@intx/types/runtime"; - -import { createDB, schema, type DB } from "@intx/db"; -import { createInMemoryGrantStore } from "@intx/authz"; -import { generateId } from "@intx/hub-common"; -import { - createWorkflowAgentCreateRoutes, - type CapabilityInventoryProvider, - type CreateWorkflowAgentCreateRoutesDeps, - type WorkflowCapabilityRunScope, - type WorkflowCapabilityRunAuthenticator, -} from "@corbits/agent-directory"; -import { - agentDirectoryTools, - CREATE_AGENT_TOOL, - type WorkflowAgentDirectoryEnv, -} from "@corbits/agent-directory-tools"; -import { CORBITS_TOOLS_REGISTRY } from "@corbits/tool-registry-publish"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -import { - createWorkflowAccessRoutes, - type WorkflowRunAuthenticator, -} from "../src/routes"; -import { - accessTools, - GRANT_ACCESS_TOOL, - LIST_GRANTS_TOOL, - type WorkflowAccessEnv, -} from "../src/tool"; - -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -function dbConfigFromUrl(url: string) { - const parsed = new URL(url); - return { - host: parsed.hostname, - port: parsed.port === "" ? 5432 : Number(parsed.port), - user: decodeURIComponent(parsed.username), - password: decodeURIComponent(parsed.password), - database: parsed.pathname.replace(/^\//, ""), - }; -} - -// Mirrors `./routes.test.ts`'s own helper: a system-origin store row for -// the caller, the same shape `createRequireGrant` and the ceiling check -// read in production. -function storeGrant(principalId: string, resource: string, action: string) { - return { - id: generateId("grant"), - resource, - action, - effect: "allow" as const, - origin: "system" as const, - conditions: null, - expiresAt: null, - roleId: null, - principalId, - }; -} - -describeIfDb("Myra orchestrates the bench", () => { - let db: DB; - - const tenantId = generateId("tenant"); - const callerPrincipalId = generateId("principal"); - const specialistPrincipalId = generateId("principal"); - const SIDECAR_TOKEN = "sidecar-token-for-this-run"; - const RUN_ADDRESS = "myra-run@tenant.example.test"; - const SPECIALIST_ADDRESS = "release-notes-writer@tenant.example.test"; - - const HUB_ORIGIN = "https://hub.example.com"; - - beforeAll(async () => { - if (databaseUrl === undefined) return; - db = createDB(dbConfigFromUrl(databaseUrl)); - - await db.db.insert(schema.tenant).values({ - id: tenantId, - name: "Myra Orchestrates Test Tenant", - slug: `myra-orchestrates-${tenantId}`, - domain: `myra-orchestrates-${tenantId}.localhost`, - parentId: null, - config: null, - }); - await db.db.insert(schema.principal).values([ - { - id: callerPrincipalId, - tenantId, - kind: "workflow", - refId: RUN_ADDRESS, - status: "active", - }, - { - id: specialistPrincipalId, - tenantId, - kind: "agent", - refId: SPECIALIST_ADDRESS, - status: "active", - }, - ]); - }); - - afterAll(async () => { - if (databaseUrl === undefined) return; - await db.db.delete(schema.grant).where(eq(schema.grant.tenantId, tenantId)); - await db.db - .delete(schema.principal) - .where(eq(schema.principal.tenantId, tenantId)); - await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); - }); - - // The agent-create fakes below mirror - // `packages/agent-directory/test/workflow-create-routes.test.ts` — one - // deliberate difference: `currentVersion` is the string `"1"`, matching - // the real `workflow_definition.current_version` text column (CL-6480), - // which the tool client parses as `"string"`. - type FakeDefinitionRow = { - id: string; - tenantId: string; - name: string; - description: string | null; - status: string; - currentVersion: string; - assetId: string | null; - }; - - const createdRow: FakeDefinitionRow = { - id: "def_new", - tenantId, - name: "Release Notes Writer", - description: null, - status: "deployed", - currentVersion: "1", - assetId: "ast_new", - }; - - function fakeAgentDb(): DB["db"] { - return { - query: { - tenant: { - findFirst: async () => ({ - id: tenantId, - domain: `myra-orchestrates-${tenantId}.localhost`, - parentId: null, - }), - }, - asset: { - findFirst: async () => ({ - id: "ast_corbits_tools", - tenantId, - kind: "package-registry" as const, - name: CORBITS_TOOLS_REGISTRY, - }), - }, - workflowDefinition: { - findFirst: async () => createdRow, - findMany: async () => [createdRow], - }, - }, - select: () => ({ - from: () => ({ - where: () => ({ - limit: () => - Promise.resolve([ - { - tenantId, - creatorPrincipalId: null, - name: createdRow.name, - displayName: createdRow.name, - }, - ]), - }), - }), - }), - insert: () => ({ - values: () => { - const chain: Record = { - onConflictDoNothing: () => chain, - returning: () => Promise.resolve([{ id: createdRow.id }]), - then: (onFulfilled: unknown) => - Promise.resolve([]).then(onFulfilled as never), - }; - return chain; - }, - }), - } as unknown as DB["db"]; - } - - function fakeAssetService( - onPopulate?: (files: Record) => void, - ): CreateWorkflowAgentCreateRoutesDeps["assetService"] { - return { - createAsset: () => - Promise.resolve({ id: "ast_new", tenantId, kind: "workflow" }), - populateAsset: (params: { - tree: { files: Record }; - }) => { - onPopulate?.(params.tree.files); - return Promise.resolve({ commitSha: "deadbeef" }); - }, - readAssetBlob: () => { - throw new Error("not used in this test"); - }, - listAssetBlobs: () => Promise.resolve(["corbits-memory-tools-1.4.0.tgz"]), - } as unknown as CreateWorkflowAgentCreateRoutesDeps["assetService"]; - } - - const fakeCapabilityInventory: CapabilityInventoryProvider = { - resolve: () => - Promise.resolve({ - toolPackages: [{ name: "@corbits/memory-tools" }], - skills: [{ name: "research" }], - models: [{ canonicalName: "anthropic/claude-sonnet" }], - }), - }; - - const fakeSkillIndex = { - resolve: ( - _tenantId: string, - _principalId: string, - names: readonly string[], - ) => - Promise.resolve( - names.map((name) => ({ name, description: `What ${name} does.` })), - ), - }; - - function recordingDeployer() { - const deploys: { - tenantId: string; - principalId: string; - assetId: string; - assetName: string; - commitSha: string; - entry: string; - }[] = []; - return { - deploys, - deploy: (input: { - tenantId: string; - principalId: string; - assetId: string; - assetName: string; - commitSha: string; - entry: string; - }) => { - deploys.push(input); - return Promise.resolve({ - deploymentId: "dep_1", - definitionAssetId: input.assetId, - status: "deployed" as const, - }); - }, - }; - } - - const agentAuthenticator: WorkflowCapabilityRunAuthenticator = { - resolve: (token, address) => - Promise.resolve( - token === SIDECAR_TOKEN && address === RUN_ADDRESS - ? ({ - tenantId, - principalId: callerPrincipalId, - runId: "run_myra", - } satisfies WorkflowCapabilityRunScope) - : null, - ), - }; - - const accessAuthenticator: WorkflowRunAuthenticator = { - async resolve(token, runAddress) { - if (token !== SIDECAR_TOKEN || runAddress !== RUN_ADDRESS) return null; - return { tenantId, principalId: callerPrincipalId }; - }, - }; - - // Stands in for the hub origin: every bundle client builds absolute - // URLs under the mount points above, so the full URL passes straight - // through with method, auth headers, and body untouched — exactly what - // the sidecar sends in production. - function hubFetch(hub: Hono) { - return (async (input: string | URL | Request, init?: RequestInit) => { - const url = - typeof input === "string" - ? input - : input instanceof URL - ? input.href - : input.url; - if (!url.startsWith(HUB_ORIGIN)) { - throw new Error(`myra-orchestrates test: unexpected fetch to ${url}`); - } - return hub.request(url, init); - }) as unknown as typeof fetch; - } - - function callFor(name: string, args: Record): ToolCall { - return { id: `call_${name}`, name, arguments: args }; - } - - test("a Myra run creates a specialist agent, then grants it scoped access", async () => { - const deployer = recordingDeployer(); - let populatedFiles: Record | undefined; - const agentRoutesApp = createWorkflowAgentCreateRoutes({ - db: fakeAgentDb(), - assetService: fakeAssetService((files) => { - populatedFiles = files; - }), - skillIndex: fakeSkillIndex, - capabilityInventory: fakeCapabilityInventory, - authenticator: agentAuthenticator, - deployer, - }); - - const accessRoutesApp = createWorkflowAccessRoutes({ - db: db.db, - authenticator: accessAuthenticator, - grantStore: createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "create"), - storeGrant(callerPrincipalId, "grant:*", "read"), - storeGrant(callerPrincipalId, "room:*", "read"), - ]), - conditionRegistry: {}, - }); - - const hub = new Hono(); - // Same mount points as the real composition root - // (`apps/hub/src/index.ts`): the bundles' clients build these exact - // absolute paths, so the stubbed fetch below hands the full URL - // straight to this parent — no prefix surgery, no drift. - hub.route("/api/workflow-agent-directory", agentRoutesApp); - hub.route("/api/workflow-access", accessRoutesApp); - - const directoryEnv = { - hubAgentDirectoryUrl: HUB_ORIGIN, - hubChatUrl: `${HUB_ORIGIN}/api/workflow-chat`, - sidecarToken: SIDECAR_TOKEN, - address: RUN_ADDRESS, - } as unknown as WorkflowAgentDirectoryEnv; - const accessEnv = { - hubAccessUrl: `${HUB_ORIGIN}/api/workflow-access`, - sidecarToken: SIDECAR_TOKEN, - address: RUN_ADDRESS, - } as unknown as WorkflowAccessEnv; - - const originalFetch = globalThis.fetch; - globalThis.fetch = hubFetch(hub); - try { - const directoryBundle = agentDirectoryTools(directoryEnv); - const accessBundle = accessTools(accessEnv); - const signal = new AbortController().signal; - - // Turn 1 (stubbed inference): stand up the specialist. `invite: - // false` keeps this leg on the definitions route — opening the - // specialist's own chat is `@corbits/chat`'s participant surface, - // covered by its own suites, not this flow. - const created = await directoryBundle.run( - callFor(CREATE_AGENT_TOOL, { - name: "Release Notes Writer", - systemPrompt: "You write crisp release notes from commit lists.", - toolPackagePins: ["@corbits/memory-tools"], - invite: false, - }), - signal, - ); - expect(created.isError).toBe(false); - expect(created.content).toContain("Release Notes Writer"); - expect(created.content).toContain("def_new"); - - // The native core path ran: the definition's source tree was - // materialized and the tenant-scoped deploy recorded. - expect(populatedFiles).toBeDefined(); - expect(deployer.deploys).toHaveLength(1); - expect(deployer.deploys[0]?.tenantId).toBe(tenantId); - expect(deployer.deploys[0]?.assetName).toBe("release-notes-writer"); - expect(deployer.deploys[0]?.commitSha).toBe("deadbeef"); - - // Turn 2 (stubbed inference): give the specialist room:read, - // inside Myra's own ceiling — the route persists a native row. - const granted = await accessBundle.run( - callFor(GRANT_ACCESS_TOOL, { - principalId: specialistPrincipalId, - resource: "room:*", - actions: ["read"], - }), - signal, - ); - expect(granted.isError).toBe(false); - expect(granted.content).toContain("1 grant created"); - - // Turn 3 (stubbed inference): read it back through the native - // list surface — proof the grant survived the round trip. - const listed = await accessBundle.run( - callFor(LIST_GRANTS_TOOL, { principalId: specialistPrincipalId }), - signal, - ); - expect(listed.isError).toBe(false); - expect(listed.content).toContain( - `allow room:*:read -> principal ${specialistPrincipalId}`, - ); - } finally { - globalThis.fetch = originalFetch; - } - - // And the row is really in Postgres, not just in the route's - // response envelope. - const rows = await db.db.query.grant.findMany({ - where: eq(schema.grant.tenantId, tenantId), - }); - expect( - rows.some( - (row) => - row.principalId === specialistPrincipalId && - row.resource === "room:*" && - row.action === "read" && - row.effect === "allow", - ), - ).toBe(true); - }); -}); diff --git a/packages/access-tools/test/routes.test.ts b/packages/access-tools/test/routes.test.ts deleted file mode 100644 index ae2198ca84..0000000000 --- a/packages/access-tools/test/routes.test.ts +++ /dev/null @@ -1,362 +0,0 @@ -// DB-gated: skipped when DATABASE_URL is unreachable, matching this -// repo's convention for tests that talk to a real Postgres (see -// `vendor/intx/hub-api/src/middleware/workflow-run-deploy-auth.test.ts`). -// Runs against the caller's own database and deletes every row it wrote -// in `afterAll`. -// -// Exercises `createWorkflowAccessRoutes` end to end against a real DB and -// an in-memory `@intx/authz` grant store — same authorization primitive -// `createRequireGrant` and the delegation-ceiling check both call — so -// this proves the ceiling is enforced against the SAME grants a real -// deploy would see, not a bundle-local approximation. -// -// Every assertion speaks the native Interchange contract (`@intx/hub-api`'s -// `createGrantRoutes`/`createPrincipalRoutes` vocabulary): `{data, -// nextCursor}` pages, single-action `POST /grants` bodies returning the -// single `GrantResponse` object, `204` deletes, and `{error: {code, -// message}}` envelopes. The workflow-access surface is the -// workflow-run-authenticated counterpart to `/api/tenants/:tenantId -// /principals` and `/grants` — same shapes, run bearer instead of a human -// session (see the mount comment in `apps/hub/src/index.ts`). -import { afterAll, beforeAll, expect, test } from "bun:test"; -import { eq } from "drizzle-orm"; - -import { createDB, schema, type DB } from "@intx/db"; -import { createInMemoryGrantStore, type GrantStore } from "@intx/authz"; -import { generateId } from "@intx/hub-common"; -import { dbGate } from "../../../scripts/e2e/db-gate"; - -import { - createWorkflowAccessRoutes, - type WorkflowRunAuthenticator, -} from "../src/routes"; - -function dbConfigFromUrl(databaseUrl: string) { - const url = new URL(databaseUrl); - return { - host: url.hostname, - port: url.port === "" ? 5432 : Number(url.port), - user: decodeURIComponent(url.username), - password: decodeURIComponent(url.password), - database: url.pathname.replace(/^\//, ""), - }; -} - -const databaseUrl = process.env["DATABASE_URL"]; -const describeIfDb = dbGate(databaseUrl, import.meta.path); - -function storeGrant( - principalId: string, - resource: string, - action: string, -): { - id: string; - resource: string; - action: string; - effect: "allow"; - origin: "system"; - conditions: null; - expiresAt: null; - roleId: null; - principalId: string; -} { - return { - id: generateId("grant"), - resource, - action, - effect: "allow", - origin: "system", - conditions: null, - expiresAt: null, - roleId: null, - principalId, - }; -} - -describeIfDb("createWorkflowAccessRoutes", () => { - let db: DB; - - const tenantId = generateId("tenant"); - const callerPrincipalId = generateId("principal"); - const targetPrincipalId = generateId("principal"); - const SIDECAR_TOKEN = "sidecar-token-for-this-run"; - const RUN_ADDRESS = "workflow-run@tenant.example.test"; - - beforeAll(async () => { - if (databaseUrl === undefined) return; - db = createDB(dbConfigFromUrl(databaseUrl)); - - await db.db.insert(schema.tenant).values({ - id: tenantId, - name: "Access Tools Test Tenant", - slug: `access-tools-${tenantId}`, - domain: `access-tools-${tenantId}.localhost`, - parentId: null, - config: null, - }); - - await db.db.insert(schema.principal).values([ - { - id: callerPrincipalId, - tenantId, - kind: "workflow", - refId: RUN_ADDRESS, - status: "active", - }, - { - id: targetPrincipalId, - tenantId, - kind: "workflow", - refId: "workflow-run-target@tenant.example.test", - status: "active", - }, - ]); - }); - - afterAll(async () => { - if (databaseUrl === undefined) return; - await db.db.delete(schema.grant).where(eq(schema.grant.tenantId, tenantId)); - await db.db - .delete(schema.principal) - .where(eq(schema.principal.tenantId, tenantId)); - await db.db.delete(schema.tenant).where(eq(schema.tenant.id, tenantId)); - }); - - const authenticator: WorkflowRunAuthenticator = { - async resolve(token, runAddress) { - if (token !== SIDECAR_TOKEN || runAddress !== RUN_ADDRESS) return null; - return { tenantId, principalId: callerPrincipalId }; - }, - }; - - function mountedApp(grantStore: GrantStore) { - return createWorkflowAccessRoutes({ - db: db.db, - authenticator, - grantStore, - conditionRegistry: {}, - }); - } - - function request( - path: string, - init?: { method?: string; body?: unknown }, - ): Request { - return new Request(`http://hub.test${path}`, { - method: init?.method ?? "GET", - headers: { - authorization: `Bearer ${SIDECAR_TOKEN}`, - "x-workflow-run-address": RUN_ADDRESS, - "content-type": "application/json", - }, - ...(init?.body !== undefined ? { body: JSON.stringify(init.body) } : {}), - }); - } - - test("401 without a recognized sidecar bearer token / run address", async () => { - const app = mountedApp(createInMemoryGrantStore([])); - const req = new Request("http://hub.test/principals", { - headers: { authorization: "Bearer not-the-right-token" }, - }); - const res = await app.request(req); - expect(res.status).toBe(401); - }); - - test("GET /principals returns the native page envelope", async () => { - const app = mountedApp( - createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "principal:*", "read"), - ]), - ); - const res = await app.request(request("/principals")); - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { id: string; kind: string; status: string }[]; - nextCursor: string | null; - }; - expect(Array.isArray(body.data)).toBe(true); - expect( - body.data.some( - (p) => p.id === callerPrincipalId && p.kind === "workflow", - ), - ).toBe(true); - expect(body.nextCursor).toBeNull(); - }); - - test("403 when the caller lacks grant:*/create", async () => { - const app = mountedApp(createInMemoryGrantStore([])); - const res = await app.request( - request("/grants", { - method: "POST", - body: { - principalId: targetPrincipalId, - resource: "room:*", - action: "read", - effect: "allow", - origin: "invoker", - }, - }), - ); - expect(res.status).toBe(403); - }); - - test("403 when the requested pair exceeds the caller's own ceiling", async () => { - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "create"), - storeGrant(callerPrincipalId, "room:*", "read"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request("/grants", { - method: "POST", - body: { - principalId: targetPrincipalId, - resource: "room:*", - action: "write", - effect: "allow", - origin: "invoker", - }, - }), - ); - expect(res.status).toBe(403); - const body = (await res.json()) as { error: { message: string } }; - expect(body.error.message).toContain("write"); - }); - - test("201 within the caller's own ceiling returns the single native GrantResponse", async () => { - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "create"), - storeGrant(callerPrincipalId, "grant:*", "read"), - storeGrant(callerPrincipalId, "room:*", "read"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request("/grants", { - method: "POST", - body: { - principalId: targetPrincipalId, - resource: "room:*", - action: "read", - effect: "allow", - origin: "invoker", - }, - }), - ); - expect(res.status).toBe(201); - const body = (await res.json()) as { - id: string; - principalId: string; - resource: string; - action: string; - effect: string; - origin: string; - }; - expect(body.principalId).toBe(targetPrincipalId); - expect(body.resource).toBe("room:*"); - expect(body.action).toBe("read"); - expect(body.effect).toBe("allow"); - - const listRes = await app.request( - request(`/grants?principalId=${targetPrincipalId}`), - ); - expect(listRes.status).toBe(200); - const listBody = (await listRes.json()) as { - data: { id: string }[]; - nextCursor: string | null; - }; - expect(listBody.data.some((g) => g.id === body.id)).toBe(true); - expect(listBody.nextCursor).toBeNull(); - - await db.db.delete(schema.grant).where(eq(schema.grant.id, body.id)); - }); - - test("revoke: 204 within the caller's own ceiling", async () => { - const inserted = await db.db - .insert(schema.grant) - .values({ - id: generateId("grant"), - tenantId, - roleId: null, - principalId: targetPrincipalId, - resource: "room:*", - action: "read", - effect: "allow", - conditions: null, - origin: "invoker", - expiresAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); - const grantableRow = inserted[0]; - if (grantableRow === undefined) - throw new Error("expected an inserted grant"); - - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "manage"), - storeGrant(callerPrincipalId, "room:*", "read"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request(`/grants/${grantableRow.id}`, { method: "DELETE" }), - ); - expect(res.status).toBe(204); - - const gone = await db.db.query.grant.findFirst({ - where: eq(schema.grant.id, grantableRow.id), - }); - expect(gone).toBeUndefined(); - }); - - test("revoke: 403 outside the caller's own ceiling", async () => { - const inserted = await db.db - .insert(schema.grant) - .values({ - id: generateId("grant"), - tenantId, - roleId: null, - principalId: targetPrincipalId, - resource: "asset:*", - action: "write", - effect: "allow", - conditions: null, - origin: "creator", - expiresAt: null, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); - const ungrantableRow = inserted[0]; - if (ungrantableRow === undefined) - throw new Error("expected an inserted grant"); - - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "manage"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request(`/grants/${ungrantableRow.id}`, { method: "DELETE" }), - ); - expect(res.status).toBe(403); - - const stillThere = await db.db.query.grant.findFirst({ - where: eq(schema.grant.id, ungrantableRow.id), - }); - expect(stillThere).toBeDefined(); - - await db.db - .delete(schema.grant) - .where(eq(schema.grant.id, ungrantableRow.id)); - }); - - test("404 for an unknown grant id", async () => { - const grantStore = createInMemoryGrantStore([ - storeGrant(callerPrincipalId, "grant:*", "manage"), - ]); - const app = mountedApp(grantStore); - const res = await app.request( - request(`/grants/${generateId("grant")}`, { method: "DELETE" }), - ); - expect(res.status).toBe(404); - }); -}); diff --git a/packages/access-tools/tsconfig.json b/packages/access-tools/tsconfig.json index 25dfb52b86..8d56256d5d 100644 --- a/packages/access-tools/tsconfig.json +++ b/packages/access-tools/tsconfig.json @@ -10,18 +10,12 @@ "tsBuildInfoFile": "tsconfig.tsbuildinfo", "rootDir": "../.." }, - "include": ["src", "test"], + "include": ["src"], "exclude": [], "references": [ { "path": "../../vendor/intx/agent/tsconfig.src.json" }, - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, { "path": "../../vendor/intx/types/tsconfig.src.json" }, diff --git a/packages/access-tools/tsconfig.src.json b/packages/access-tools/tsconfig.src.json index 5debd4afb4..fba5f774c3 100644 --- a/packages/access-tools/tsconfig.src.json +++ b/packages/access-tools/tsconfig.src.json @@ -14,12 +14,6 @@ { "path": "../../vendor/intx/agent/tsconfig.src.json" }, - { - "path": "../../vendor/intx/db/tsconfig.src.json" - }, - { - "path": "../../vendor/intx/hub-api/tsconfig.src.json" - }, { "path": "../../vendor/intx/types/tsconfig.src.json" }, diff --git a/packages/connections-tools/package.json b/packages/connections-tools/package.json index 6599c951d5..3fb396a529 100644 --- a/packages/connections-tools/package.json +++ b/packages/connections-tools/package.json @@ -2,7 +2,7 @@ "name": "@corbits/connections-tools", "private": true, "description": "The list_connections and request_connection tools as an @intx/agent tool bundle: an agent sees which third-party connections this workbench already has live and hands the human a deep link to connect one that isn't, reusing the existing connections plane and never inventing new state or completing OAuth itself", - "version": "0.0.9", + "version": "0.0.10", "license": "LGPL-2.1-or-later", "type": "module", "exports": { diff --git a/packages/connections-tools/src/client.test.ts b/packages/connections-tools/src/client.test.ts index 72706263cd..c1d65ff402 100644 --- a/packages/connections-tools/src/client.test.ts +++ b/packages/connections-tools/src/client.test.ts @@ -1,80 +1,128 @@ import { expect, test } from "bun:test"; -import { listConnections, type ConnectionsToolClientConfig } from "./client"; +import { + listConnectedProviders, + type ConnectionsToolClientConfig, +} from "./client"; function testConfig(fetchImpl: typeof fetch): ConnectionsToolClientConfig { return { hubConnectionsUrl: "https://hub.example.com", + tenantId: "ten_1", sidecarToken: "sc-token", address: "run_1@workflow", fetchImpl, }; } -test("listConnections fetches the workflow-connections endpoint with sidecar auth", async () => { - let seenUrl: string | undefined; - let seenHeaders: Record | undefined; +/** Stock providers + credentials, keyed so only `granola` has a live + * credential: `linear`'s is revoked and `exa` has none at all. */ +function stockHub(): { + fetchImpl: typeof fetch; + urls: string[]; + headers: Record[]; +} { + const urls: string[] = []; + const headers: Record[] = []; const fetchImpl = (async (url: string | URL, init?: RequestInit) => { - seenUrl = String(url); - seenHeaders = init?.headers as Record; - return new Response( - JSON.stringify({ + urls.push(String(url)); + headers.push(init?.headers as Record); + if (String(url).includes("/providers")) { + return Response.json({ data: [ - { - id: "granola", - displayName: "Granola", - docsUrl: "https://www.granola.ai", - connected: true, - }, + { id: "prv_1", name: "granola" }, + { id: "prv_2", name: "linear" }, + { id: "prv_3", name: "exa" }, ], - }), - ); + nextCursor: null, + }); + } + return Response.json({ + data: [ + { id: "crd_1", providerId: "prv_1", status: "active" }, + { id: "crd_2", providerId: "prv_2", status: "revoked" }, + ], + nextCursor: null, + }); }) as unknown as typeof fetch; + return { fetchImpl, urls, headers }; +} - const connections = await listConnections(testConfig(fetchImpl)); +test("a connector counts as connected only with an active credential against its own provider", async () => { + const { fetchImpl, urls, headers } = stockHub(); - expect(seenUrl).toBe( - "https://hub.example.com/api/workflow-connections/connections", - ); - expect(seenHeaders?.["authorization"]).toBe("Bearer sc-token"); - expect(seenHeaders?.["x-workflow-run-address"]).toBe("run_1@workflow"); - expect(connections).toEqual([ - { - id: "granola", - displayName: "Granola", - docsUrl: "https://www.granola.ai", - connected: true, - }, + const live = await listConnectedProviders(testConfig(fetchImpl)); + + expect([...live]).toEqual(["granola"]); + expect( + urls.some((u) => + u.startsWith("https://hub.example.com/api/tenants/ten_1/providers?"), + ), + ).toBe(true); + expect( + urls.some((u) => + u.startsWith("https://hub.example.com/api/tenants/ten_1/credentials?"), + ), + ).toBe(true); + expect(headers[0]?.["authorization"]).toBe("Bearer sc-token"); + expect(headers[0]?.["x-workflow-run-address"]).toBe("run_1@workflow"); +}); + +test("a credential past the first page still counts as connected", async () => { + const fetchImpl = (async (url: string | URL) => { + const raw = String(url); + if (raw.includes("/providers")) { + return Response.json({ + data: [{ id: "prv_9", name: "granola" }], + nextCursor: null, + }); + } + if (raw.includes("cursor=page_2")) { + return Response.json({ + data: [{ id: "crd_2", providerId: "prv_9", status: "active" }], + nextCursor: null, + }); + } + return Response.json({ + data: [{ id: "crd_1", providerId: "prv_other", status: "active" }], + nextCursor: "page_2", + }); + }) as unknown as typeof fetch; + + expect([...(await listConnectedProviders(testConfig(fetchImpl)))]).toEqual([ + "granola", ]); }); -test("listConnections throws an honest error on a non-ok HTTP response, never fabricating a result", async () => { +test("a non-ok HTTP response throws honestly, never fabricating a result", async () => { const fetchImpl = (async () => new Response("", { status: 500, statusText: "Internal Server Error", })) as unknown as typeof fetch; - await expect(listConnections(testConfig(fetchImpl))).rejects.toThrow(/500/); + await expect(listConnectedProviders(testConfig(fetchImpl))).rejects.toThrow( + /500/, + ); }); -test("listConnections throws on a response that doesn't match the expected shape", async () => { +test("a response that doesn't match the expected shape throws", async () => { const fetchImpl = (async () => new Response( JSON.stringify({ nonsense: true }), )) as unknown as typeof fetch; - await expect(listConnections(testConfig(fetchImpl))).rejects.toThrow( - /expected shape/, + await expect(listConnectedProviders(testConfig(fetchImpl))).rejects.toThrow( + /unexpected shape/, ); }); -test("listConnections throws an honest error when the hub is unreachable", async () => { +test("an unreachable hub throws honestly", async () => { const fetchImpl = (async () => { throw new Error("fetch failed: connection refused"); }) as unknown as typeof fetch; - await expect(listConnections(testConfig(fetchImpl))).rejects.toThrow( + await expect(listConnectedProviders(testConfig(fetchImpl))).rejects.toThrow( /connection refused/, ); }); diff --git a/packages/connections-tools/src/client.ts b/packages/connections-tools/src/client.ts index fe00f4eba2..8ec1580bf4 100644 --- a/packages/connections-tools/src/client.ts +++ b/packages/connections-tools/src/client.ts @@ -1,41 +1,50 @@ -// A minimal client for the workflow-run-authenticated connections -// surface a running agent calls to see which third-party connectors -// this workbench already has live — the execution half of -// `@corbits/connections`'s `createWorkflowConnectionRoutes` -// (`packages/connections/src/workflow-connection-routes.ts`), mounted -// in `apps/hub` at `/api/workflow-connections` beside -// `/api/workflow-capabilities` and `/api/workflow-skills` — -// authenticated the same way, via a `WorkflowRunAuthenticator` (sidecar -// bearer token + run address), never a human browser session. +// A minimal client for the two hub surfaces this bundle reads. +// +// `list_connections` reads the STOCK Interchange tenant routes — +// `/api/tenants/:tenantId/providers` and `/credentials` — with the workflow +// run's own bearer credential (sidecar token plus run address), which the hub +// resolves to this run's principal and tenant. A connector counts as +// connected when the tenant has an active credential against a provider named +// for it, the same `provider.name = connector id` keying an agent launch +// resolves a credential by. There is no Workbench-specific +// `/api/workflow-connections` mirror any more. +// +// `request_connection` still posts its `connect-service` card through the +// workflow-chat participant route; that surface is unchanged. import { type } from "arktype"; export interface ConnectionsToolClientConfig { - /** The hub's plain HTTP origin — same value capability-tools' - * `hubCapabilitiesUrl` and memory-tools' `hubMemoryUrl` reach the hub - * through. */ + /** The hub's plain HTTP origin. */ readonly hubConnectionsUrl: string; + /** The run's own tenant — the `:tenantId` segment of a stock route. */ + readonly tenantId: string; readonly sidecarToken: string; readonly address: string; /** Override for tests; defaults to the global `fetch`. */ readonly fetchImpl?: typeof fetch; } -export type ConnectionStatus = { - readonly id: string; - readonly displayName: string; - readonly docsUrl: string; - readonly connected: boolean; -}; +/** Which connectors this tenant currently has a live credential for. + * Keyed by connector id — the caller pairs it with its own connector + * registry for display names and docs links, which the hub does not know. */ +export type ConnectedProviders = ReadonlySet; + +const ProvidersPage = type({ + data: type({ id: "string", name: "string" }).array(), + "nextCursor?": "string | null", +}); -const ConnectionsResponse = type({ +const CredentialsPage = type({ data: type({ id: "string", - displayName: "string", - docsUrl: "string", - connected: "boolean", + providerId: "string", + status: "'active'|'expired'|'revoked'", }).array(), + "nextCursor?": "string | null", }); +const PAGE_LIMIT = 100; + function authHeaders( config: ConnectionsToolClientConfig, ): Record { @@ -45,31 +54,78 @@ function authHeaders( }; } -/** Fetches every connector this workbench knows about, each flagged - * with whether the calling tenant currently has a live credential for - * it. Throws on any transport, HTTP, or shape failure — never - * fabricates a result. */ -export async function listConnections( +function tenantBase(config: ConnectionsToolClientConfig): string { + return `${config.hubConnectionsUrl}/api/tenants/${encodeURIComponent(config.tenantId)}`; +} + +/** Walks every page: a truncated first page would read as "not connected", + * a wrong answer rather than a slow one. */ +async function readAllPages( config: ConnectionsToolClientConfig, -): Promise { + path: string, + what: string, + parse: ( + body: unknown, + ) => + | { data: readonly T[]; nextCursor?: string | null | undefined } + | type.errors, +): Promise { const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch( - `${config.hubConnectionsUrl}/api/workflow-connections/connections`, - { headers: authHeaders(config) }, - ); - if (!response.ok) { - throw new Error( - `Fetching connections failed: ${response.status} ${response.statusText}`, + const items: T[] = []; + let cursor: string | undefined; + for (;;) { + const params = new URLSearchParams({ limit: String(PAGE_LIMIT) }); + if (cursor !== undefined) params.set("cursor", cursor); + const response = await doFetch( + `${tenantBase(config)}${path}?${params.toString()}`, + { headers: authHeaders(config) }, ); + if (!response.ok) { + throw new Error( + `${what} failed: ${response.status} ${response.statusText}`, + ); + } + const parsed = parse(await response.json()); + if (parsed instanceof type.errors) { + throw new Error( + `${what} came back in an unexpected shape: ${parsed.summary}`, + ); + } + items.push(...parsed.data); + const next = parsed.nextCursor; + if (next === undefined || next === null) return items; + cursor = next; } - const body: unknown = await response.json(); - const parsed = ConnectionsResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `Connections response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed.data; +} + +/** Connector ids this tenant currently has a live credential for. Throws on + * any transport, HTTP, or shape failure — never fabricates a result. + * + * The stock provider listing already includes providers inherited from + * ancestor tenants; the stock credential listing does not, so a credential + * that only exists on a parent tenant reads as not connected here. Recorded + * as an upstream ask rather than papered over with a second query. */ +export async function listConnectedProviders( + config: ConnectionsToolClientConfig, +): Promise { + const [providers, credentials] = await Promise.all([ + readAllPages(config, "/providers", "Listing providers", (body) => + ProvidersPage(body), + ), + readAllPages(config, "/credentials", "Listing credentials", (body) => + CredentialsPage(body), + ), + ]); + const liveProviderIds = new Set( + credentials + .filter((row) => row.status === "active") + .map((row) => row.providerId), + ); + return new Set( + providers + .filter((row) => liveProviderIds.has(row.id)) + .map((row) => row.name), + ); } /** Thrown when the caller's run has no room of its own to post into — @@ -134,46 +190,3 @@ export async function postConnectServiceBlock( } return { messageId: parsed.id }; } - -export type McpServerConnectionStatus = { - readonly slug: string; - readonly name: string; - readonly url: string; -}; - -const McpServersResponse = type({ - data: type({ - slug: "string", - name: "string", - url: "string", - }).array(), -}); - -/** Fetches every MCP server this tenant has connected through Plugins — - * the same `/api/workflow-connections/mcp-servers` route - * `@corbits/mcp-tools`' `mcp_list_servers` reads, reused here rather - * than a second listing mechanism, so `list_connections` reports the - * same connected set an agent would see calling `mcp_list_servers` - * directly. Throws on any transport, HTTP, or shape failure. */ -export async function listMcpServerConnections( - config: ConnectionsToolClientConfig, -): Promise { - const doFetch = config.fetchImpl ?? fetch; - const response = await doFetch( - `${config.hubConnectionsUrl}/api/workflow-connections/mcp-servers`, - { headers: authHeaders(config) }, - ); - if (!response.ok) { - throw new Error( - `Fetching MCP servers failed: ${response.status} ${response.statusText}`, - ); - } - const body: unknown = await response.json(); - const parsed = McpServersResponse(body); - if (parsed instanceof type.errors) { - throw new Error( - `MCP servers response did not match the expected shape: ${parsed.summary}`, - ); - } - return parsed.data; -} diff --git a/packages/connections-tools/src/index.ts b/packages/connections-tools/src/index.ts index 4106102334..1a6afc6efb 100644 --- a/packages/connections-tools/src/index.ts +++ b/packages/connections-tools/src/index.ts @@ -1,6 +1,8 @@ export { - listConnections, - type ConnectionStatus, + listConnectedProviders, + NoOwnRoomError, + postConnectServiceBlock, + type ConnectedProviders, type ConnectionsToolClientConfig, } from "./client"; export { diff --git a/packages/connections-tools/src/tool.test.ts b/packages/connections-tools/src/tool.test.ts index 5e14ab35d1..4c200a0cec 100644 --- a/packages/connections-tools/src/tool.test.ts +++ b/packages/connections-tools/src/tool.test.ts @@ -58,6 +58,7 @@ const TEST_MCP_PRESETS: readonly McpPreset[] = [ function testEnv(): WorkflowConnectionEnv { return { hubConnectionsUrl: "https://hub.example.com", + tenantId: "ten_1", sidecarToken: "sc-token", address: "run_1@workflow", connectorRegistry: TEST_CONNECTOR_REGISTRY, @@ -79,17 +80,20 @@ test("declares exactly list_connections and request_connection, neither gated be test("requires the sanctioned workflow-connection env keys", () => { expect(connectionsTools.requires).toEqual([ "hubConnectionsUrl", + "tenantId", "sidecarToken", "address", ]); }); +/** Serves the stock tenant provider + credential routes, reporting every + * connector id in `live` as having an active credential. */ function stubFetch(opts: { - connections?: unknown[]; - mcpServers?: unknown[]; + live?: string[]; posted?: unknown[]; postStatus?: number; }): typeof fetch { + const live = opts.live ?? []; return (async (input: string | URL | Request, init?: RequestInit) => { const url = typeof input === "string" ? input : input.toString(); if (url.endsWith("/participants/messages")) { @@ -105,39 +109,26 @@ function stubFetch(opts: { { status: 201 }, ); } - if (url.endsWith("/mcp-servers")) { - return new Response(JSON.stringify({ data: opts.mcpServers ?? [] })); + if (url.includes("/providers")) { + return Response.json({ + data: live.map((name) => ({ id: `prv_${name}`, name })), + nextCursor: null, + }); } - return new Response(JSON.stringify({ data: opts.connections ?? [] })); + return Response.json({ + data: live.map((name) => ({ + id: `crd_${name}`, + providerId: `prv_${name}`, + status: "active", + })), + nextCursor: null, + }); }) as unknown as typeof fetch; } -test("list_connections summarizes connected and not-connected connectors from the client", async () => { +test("list_connections summarizes connected and not-connected connectors from the stock routes", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ - connections: [ - { - id: "github", - displayName: "GitHub", - docsUrl: "https://github.com/settings/tokens", - connected: true, - }, - { - id: "scrapecreators", - displayName: "ScrapeCreators", - docsUrl: "https://scrapecreators.com", - connected: false, - }, - ], - mcpServers: [ - { - slug: "fieldnotes", - name: "Fieldnotes", - url: "https://mcp.fieldnotes.example", - }, - { slug: "exa", name: "Exa", url: "https://mcp.exa.ai/mcp" }, - ], - }); + globalThis.fetch = stubFetch({ live: ["github", "exa"] }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -146,11 +137,9 @@ test("list_connections summarizes connected and not-connected connectors from th ); expect(result.isError).toBeFalsy(); expect(result.content).toMatch(/Connected: GitHub/); - expect(result.content).toMatch(/Exa \(via MCP\)/); - expect(result.content).toMatch(/Fieldnotes \(MCP server\)/); - expect(result.content).toMatch(/Not connected: [^.]*ScrapeCreators/); - expect(result.content).toMatch(/Granola/); - expect(result.content).not.toMatch(/Exa,/); // Exa never listed twice + expect(result.content).toMatch(/Connected: [^.]*Exa/); + expect(result.content).toMatch(/Not connected: [^.]*Granola/); + expect(result.content).toMatch(/Not connected: [^.]*Notion/); } finally { globalThis.fetch = originalFetch; } @@ -176,7 +165,7 @@ test("list_connections returns an honest error on an unreachable hub, never fabr test("request_connection tells the agent to keep helping for a name this workspace can't connect, never sending the human off to add servers", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ mcpServers: [] }); + globalThis.fetch = stubFetch({}); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -197,40 +186,10 @@ test("request_connection tells the agent to keep helping for a name this workspa } }); -test("request_connection reports an already-connected MCP server rather than re-requesting it", async () => { - const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ - mcpServers: [ - { slug: "notion", name: "Notion", url: "https://mcp.notion.example" }, - ], - }); - try { - const bundle = connectionsTools(testEnv()); - const result = await bundle.run( - callFor(REQUEST_CONNECTION_TOOL, { connector: "notion" }), - new AbortController().signal, - ); - expect(result.isError).toBeFalsy(); - expect(result.content).toMatch(/already connected/); - } finally { - globalThis.fetch = originalFetch; - } -}); - test("request_connection posts a connect-service card into the room for a registry connector", async () => { const originalFetch = globalThis.fetch; const posted: unknown[] = []; - globalThis.fetch = stubFetch({ - connections: [ - { - id: "github", - displayName: "GitHub", - docsUrl: "https://github.com/settings/tokens", - connected: false, - }, - ], - posted, - }); + globalThis.fetch = stubFetch({ posted }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -262,17 +221,7 @@ test("request_connection posts a connect-service card into the room for a regist test("request_connection reports an already-connected registry connector rather than posting a card", async () => { const originalFetch = globalThis.fetch; const posted: unknown[] = []; - globalThis.fetch = stubFetch({ - connections: [ - { - id: "github", - displayName: "GitHub", - docsUrl: "https://github.com/settings/tokens", - connected: true, - }, - ], - posted, - }); + globalThis.fetch = stubFetch({ live: ["github"], posted }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -290,7 +239,7 @@ test("request_connection reports an already-connected registry connector rather test("request_connection posts a connect-service card for a curated MCP preset, defaulting the reason from the preset", async () => { const originalFetch = globalThis.fetch; const posted: unknown[] = []; - globalThis.fetch = stubFetch({ mcpServers: [], posted }); + globalThis.fetch = stubFetch({ posted }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -318,7 +267,7 @@ test("request_connection posts a connect-service card for a curated MCP preset, test("request_connection hands over a plain link when the run has no room to post into", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ mcpServers: [], postStatus: 404 }); + globalThis.fetch = stubFetch({ postStatus: 404 }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( @@ -334,9 +283,7 @@ test("request_connection hands over a plain link when the run has no room to pos test("request_connection reports an already-connected preset rather than re-requesting it", async () => { const originalFetch = globalThis.fetch; - globalThis.fetch = stubFetch({ - mcpServers: [{ slug: "exa", name: "Exa", url: "https://mcp.exa.ai/mcp" }], - }); + globalThis.fetch = stubFetch({ live: ["exa"] }); try { const bundle = connectionsTools(testEnv()); const result = await bundle.run( diff --git a/packages/connections-tools/src/tool.ts b/packages/connections-tools/src/tool.ts index 2608617beb..56ab051932 100644 --- a/packages/connections-tools/src/tool.ts +++ b/packages/connections-tools/src/tool.ts @@ -8,13 +8,14 @@ // OAuth itself; only a human, acting in the browser through the // existing Connections settings surface, can finish a connect flow. // -// Both tools also read `@corbits/connections`' MCP-server listing -// (CL-6142's `/api/workflow-connections/mcp-servers`, backed by -// `@corbits/mcp-tools`' own `mcp_list_servers` route) — a tenant-minted -// `mcp:` connector has no fixed registry id, so `list_connections` -// folds it into the connected list by name, and `request_connection` -// falls back to it (see `ADD_MCP_SERVER_GUIDANCE`) before reporting an -// unknown connector. +// Live connections come from the STOCK Interchange tenant routes +// (providers + credentials), read with the run bearer; the tool pairs that +// answer with the deploying build's own connector registry for display +// names and links. MCP servers are not a stock Interchange concept, so a +// tenant-minted `mcp:` connection is no longer listed: a curated +// preset is still offered as a card, but the tool cannot say whether one is +// already connected (CL-8164 moves MCP server config to the connections +// library). // // Approval: `list_connections` reads only, so it declares no `approval` // key (matching a read-style tool, e.g. `@corbits/memory-tools`' search @@ -36,8 +37,7 @@ import type { McpPreset } from "@corbits/connections/mcp-presets"; import { type } from "arktype"; import { - listConnections, - listMcpServerConnections, + listConnectedProviders, NoOwnRoomError, postConnectServiceBlock, } from "./client"; @@ -52,6 +52,7 @@ export const REQUEST_CONNECTION_TOOL = "request_connection"; * definition). */ export interface WorkflowConnectionEnv extends BaseEnv { readonly hubConnectionsUrl: string; + readonly tenantId: string; readonly sidecarToken: string; readonly address: string; /** The deploying build's connector set — this package carries no @@ -81,6 +82,7 @@ function errorResult(callId: string, err: unknown): ToolResult { function clientConfig(env: WorkflowConnectionEnv) { return { hubConnectionsUrl: env.hubConnectionsUrl, + tenantId: env.tenantId, sidecarToken: env.sidecarToken, address: env.address, }; @@ -127,43 +129,24 @@ async function runListConnections( call: ToolCall, ): Promise { try { - const [connections, mcpServers] = await Promise.all([ - listConnections(clientConfig(env)), - listMcpServerConnections(clientConfig(env)), - ]); + const live = await listConnectedProviders(clientConfig(env)); const presetFronted = presetFrontedIds(env.mcpPresets); - const connectedMcpSlugs = new Set(mcpServers.map((server) => server.slug)); - const registryEntries = connections.filter( - (entry) => !presetFronted.has(entry.id), - ); + const registryEntries = Object.values(env.connectorRegistry) + .filter((descriptor) => !presetFronted.has(descriptor.id)) + .map((descriptor) => ({ + displayName: descriptor.displayName, + connected: live.has(descriptor.id), + })); const connected = registryEntries.filter((entry) => entry.connected); const notConnected = registryEntries.filter((entry) => !entry.connected); - const otherMcpServers = mcpServers.filter( - (server) => !presetFronted.has(server.slug), - ); - // A preset service counts as connected through EITHER door: its MCP - // server, or a plain key stored under the same connector id — a - // Granola key connected before the MCP card existed must never - // read as "Not connected". - const keyConnectedIds = new Set( - connections.filter((entry) => entry.connected).map((entry) => entry.id), - ); - const presetConnected = env.mcpPresets.filter( - (preset) => - connectedMcpSlugs.has(preset.slug) || keyConnectedIds.has(preset.slug), + const presetConnected = env.mcpPresets.filter((preset) => + live.has(preset.slug), ); const presetNotConnected = env.mcpPresets.filter( - (preset) => - !connectedMcpSlugs.has(preset.slug) && - !keyConnectedIds.has(preset.slug), + (preset) => !live.has(preset.slug), ); - if ( - connected.length === 0 && - notConnected.length === 0 && - otherMcpServers.length === 0 && - env.mcpPresets.length === 0 - ) { + if (registryEntries.length === 0 && env.mcpPresets.length === 0) { return { callId: call.id, isError: false, @@ -172,12 +155,7 @@ async function runListConnections( } const connectedNames = [ ...connected.map((entry) => entry.displayName), - ...presetConnected.map((preset) => - connectedMcpSlugs.has(preset.slug) - ? `${preset.displayName} (via MCP)` - : preset.displayName, - ), - ...otherMcpServers.map((server) => `${server.name} (MCP server)`), + ...presetConnected.map((preset) => preset.displayName), ]; const notConnectedNames = [ ...notConnected.map((entry) => entry.displayName), @@ -247,13 +225,11 @@ async function runRequestConnection( const preset = mcpPresetByName(env.mcpPresets, parsed.connector); if (preset !== undefined) { try { - const mcpServers = await listMcpServerConnections(clientConfig(env)); - const already = mcpServers.find((server) => server.slug === preset.slug); - if (already !== undefined) { + if ((await listConnectedProviders(clientConfig(env))).has(preset.slug)) { return { callId: call.id, isError: false, - content: `"${already.name}" is already connected as an MCP server.`, + content: `${preset.displayName} is already connected.`, }; } } catch (err) { @@ -276,11 +252,9 @@ async function runRequestConnection( const descriptor = env.connectorRegistry[parsed.connector]; if (descriptor !== undefined) { try { - const connections = await listConnections(clientConfig(env)); - const entry = connections.find( - (candidate) => candidate.id === descriptor.id, - ); - if (entry !== undefined && entry.connected) { + if ( + (await listConnectedProviders(clientConfig(env))).has(descriptor.id) + ) { return { callId: call.id, isError: false, @@ -304,24 +278,7 @@ async function runRequestConnection( ); } - // Not a fixed registry connector — check whether it is already a - // connected MCP server under this name before assuming it needs one. - try { - const mcpServers = await listMcpServerConnections(clientConfig(env)); - const already = mcpServers.find( - (server) => - server.slug === parsed.connector || server.name === parsed.connector, - ); - if (already !== undefined) { - return { - callId: call.id, - isError: false, - content: `"${already.name}" is already connected as an MCP server.`, - }; - } - } catch (err) { - return errorResult(call.id, err); - } + // Not a fixed registry connector and not a curated preset. return { callId: call.id, @@ -341,7 +298,7 @@ async function runRequestConnection( */ export const connectionsTools = defineTool({ id: "@corbits/connections-tools/conn", - requires: ["hubConnectionsUrl", "sidecarToken", "address"], + requires: ["hubConnectionsUrl", "tenantId", "sidecarToken", "address"], definitions: [ { name: LIST_CONNECTIONS_TOOL }, { name: REQUEST_CONNECTION_TOOL }, diff --git a/packages/connections/src/index.ts b/packages/connections/src/index.ts index 725ebf541d..d28c94242a 100644 --- a/packages/connections/src/index.ts +++ b/packages/connections/src/index.ts @@ -74,7 +74,6 @@ export { } from "./mcp-server-store"; export { createWorkflowConnectionRoutes, - type ConnectionSummary, type CreateWorkflowConnectionRoutesDeps, type WorkflowConnectionRunScope, type WorkflowRunAuthenticator as WorkflowConnectionRunAuthenticator, diff --git a/packages/connections/src/workflow-connection-routes.test.ts b/packages/connections/src/workflow-connection-routes.test.ts index 64145c98b0..55dfcdfa3a 100644 --- a/packages/connections/src/workflow-connection-routes.test.ts +++ b/packages/connections/src/workflow-connection-routes.test.ts @@ -4,37 +4,6 @@ import { createWorkflowConnectionRoutes, type WorkflowConnectionRunScope, } from "./workflow-connection-routes"; -import type { ConnectorRegistry } from "./registry"; - -/** A fixture standing in for the real connector set a build's own - * `templates/connectors.ts` carries — this package holds no concrete - * connector set of its own (CL-7384). */ -const TEST_REGISTRY: ConnectorRegistry = { - github: { - id: "github", - displayName: "GitHub", - authKind: "api-key", - credentialPlugin: "http", - docsUrl: "https://github.com/settings/tokens", - feedsTools: ["@corbits/github-tools"], - }, - linear: { - id: "linear", - displayName: "Linear", - authKind: "api-key", - credentialPlugin: "http-raw-authorization", - docsUrl: "https://linear.app/settings/api", - feedsTools: ["@corbits/linear-tools"], - }, - anthropic: { - id: "anthropic", - displayName: "Anthropic", - authKind: "api-key", - credentialPlugin: "http", - docsUrl: "https://console.anthropic.com/settings/keys", - feedsTools: [], - }, -}; const VALID_TOKEN = "sc-token"; const VALID_ADDRESS = "run_1@workflow"; @@ -55,11 +24,10 @@ describe("createWorkflowConnectionRoutes", () => { test("rejects a call with a missing or unrecognized sidecar bearer token / run address", async () => { const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async () => false, + listMcpServers: async () => [], }); - const response = await app.request("/connections", { + const response = await app.request("/mcp-servers", { headers: { authorization: "Bearer wrong-token", "x-workflow-run-address": VALID_ADDRESS, @@ -71,78 +39,40 @@ describe("createWorkflowConnectionRoutes", () => { expect(body.error.code).toBe("unauthorized"); }); - test("reports a non-inference connector (GitHub) connected once it has a real, verified credential", async () => { - // CL-6492: `list_connections` used to resolve every connector's - // status from the inference-only model catalog, so a verified - // GitHub PAT (never seeded into that catalog) always read "not - // connected" — the agent would refuse work it could actually do. - let seenTenantId: string | undefined; - let seenConnectorId: string | undefined; + test("never reads the MCP registry before authentication succeeds", async () => { + let called = false; const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async (tenantId, connectorId) => { - if (connectorId === "github") { - seenTenantId = tenantId; - seenConnectorId = connectorId; - return true; - } - return false; + listMcpServers: async () => { + called = true; + return []; }, }); - const response = await app.request("/connections", { - headers: { - authorization: `Bearer ${VALID_TOKEN}`, - "x-workflow-run-address": VALID_ADDRESS, - }, + await app.request("/mcp-servers", { + headers: { authorization: "Bearer nope", "x-workflow-run-address": "x" }, }); - expect(response.status).toBe(200); - expect(seenTenantId).toBe("tenant_1"); - expect(seenConnectorId).toBe("github"); - const body = (await response.json()) as { - data: { id: string; displayName: string; connected: boolean }[]; - }; - const github = body.data.find((entry) => entry.id === "github"); - expect(github?.connected).toBe(true); + expect(called).toBe(false); }); - test("still reports an unconnected connector as not connected", async () => { + test("scopes the MCP-server listing to the authenticated run's own tenant", async () => { + let seenTenantId: string | undefined; const app = createWorkflowConnectionRoutes({ authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async () => false, - }); - - const response = await app.request("/connections", { - headers: { - authorization: `Bearer ${VALID_TOKEN}`, - "x-workflow-run-address": VALID_ADDRESS, + listMcpServers: async (tenantId) => { + seenTenantId = tenantId; + return [ + { + slug: "granola", + name: "Granola", + url: "https://mcp.granola.ai", + } as never, + ]; }, }); - expect(response.status).toBe(200); - const body = (await response.json()) as { - data: { id: string; displayName: string; connected: boolean }[]; - }; - const linear = body.data.find((entry) => entry.id === "linear"); - expect(linear?.connected).toBe(false); - // The full registry, not a filtered subset — an unconnected - // connector still gets a card the client can render a "not - // connected" state for. - expect(body.data.length).toBeGreaterThan(2); - }); - - test("keeps reporting an inference provider (Anthropic) connected", async () => { - const app = createWorkflowConnectionRoutes({ - authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async (_tenantId, connectorId) => - connectorId === "anthropic", - }); - - const response = await app.request("/connections", { + const response = await app.request("/mcp-servers", { headers: { authorization: `Bearer ${VALID_TOKEN}`, "x-workflow-run-address": VALID_ADDRESS, @@ -150,28 +80,8 @@ describe("createWorkflowConnectionRoutes", () => { }); expect(response.status).toBe(200); - const body = (await response.json()) as { - data: { id: string; displayName: string; connected: boolean }[]; - }; - const anthropic = body.data.find((entry) => entry.id === "anthropic"); - expect(anthropic?.connected).toBe(true); - }); - - test("never calls isConnectorConnected before authentication succeeds", async () => { - let called = false; - const app = createWorkflowConnectionRoutes({ - authenticator: fakeAuthenticator(), - registry: TEST_REGISTRY, - isConnectorConnected: async () => { - called = true; - return false; - }, - }); - - await app.request("/connections", { - headers: { authorization: "Bearer nope", "x-workflow-run-address": "x" }, - }); - - expect(called).toBe(false); + expect(seenTenantId).toBe("tenant_1"); + const body = (await response.json()) as { data: { slug: string }[] }; + expect(body.data.map((row) => row.slug)).toEqual(["granola"]); }); }); diff --git a/packages/connections/src/workflow-connection-routes.ts b/packages/connections/src/workflow-connection-routes.ts index e28aa717e5..9c042236b5 100644 --- a/packages/connections/src/workflow-connection-routes.ts +++ b/packages/connections/src/workflow-connection-routes.ts @@ -1,45 +1,21 @@ -// The sanctioned path for a workflow-process child (Myra, or any -// conversational agent) to see which third-party connections this -// workbench has live, and to hand a human a link to connect one that -// isn't — CL-myra-manager-tools. Mirrors `@corbits/agent-directory`'s -// `createWorkflowCapabilityRoutes` and `@corbits/skills`' -// `createWorkflowSkillRoutes`: a workflow child has no browser session, -// only its sidecar bearer token and its own run address, so it -// authenticates through a `WorkflowRunAuthenticator` rather than the -// tenant-session pipeline `./routes.ts` uses. +// What is left of the workflow-run connections mirror: the MCP-server +// registry `@corbits/mcp-tools` resolves a `mcp:` server's URL +// through. MCP servers are not a stock Interchange concept, so there is no +// stock route to read them from; CL-8164 moves MCP server configuration into +// the connections/OAuth library and retires this mount with it. // -// Mounted OUTSIDE the tenant prefix for that reason, at -// `/api/workflow-connections`. Identity NEVER rides in a request body: -// the tenant every read is scoped to come from the authenticated run -// alone. -// -// `GET /connections` reports a connector connected through the same -// resolution an actual agent launch uses to deliver its credential — -// `@intx/db`'s `resolveCredentialRequirement`, keyed on the connector's -// registry `id` as its provider name (CL-6492). This is deliberately NOT -// `@corbits/chat`'s `listConnectedProviders`: that lister answers from the -// model catalog (`modelProvider`, seeded only for inference providers by -// `persistConnectorCredential`'s `seedCatalog` step), so every -// non-inference connector — GitHub, Linear, Notion, Sentry, Exa — reads -// "not connected" there even with a live, verified credential. Resolving -// through `resolveCredentialRequirement` instead covers both kinds -// uniformly: an inference provider's credential (however it was named — -// `persistConnectorCredential`'s own `displayName` row, or an onboarding -// seed's `-default` row) and a tool connector's credential both -// resolve the same way, by `provider.name = descriptor.id` and an active -// `credential` row against it, with no per-connector-kind branch here. +// `GET /connections` is gone: `@corbits/connections-tools` now reads live +// connections from the stock tenant provider and credential routes with the +// run bearer (CL-8159). // -// No `requireGrant` on `GET /connections`, unlike `./routes.ts`'s -// tenant-session routes: this endpoint is read-only and mutates nothing, -// so there is no write to gate. The companion `request_connection` tool -// (`@corbits/connections-tools`) needs no route at all — it validates a -// connector id against the same `CONNECTOR_REGISTRY` and builds a -// deep-link string, entirely in-process, never touching the network or -// completing OAuth itself — so this file only ever grows the one -// endpoint below. +// A workflow child has no browser session, only its sidecar bearer token and +// its own run address, so it authenticates through a +// `WorkflowRunAuthenticator`. Mounted OUTSIDE the tenant prefix for that +// reason, at `/api/workflow-connections`. Identity NEVER rides in a request +// body: the tenant the read is scoped to comes from the authenticated run +// alone. import { Hono } from "hono"; -import type { ConnectorDescriptor } from "./descriptor"; import type { McpServerConnection } from "./mcp-server-store"; /** @@ -71,38 +47,15 @@ export type WorkflowConnectionsEnv = { Variables: { workflowConnectionScope: WorkflowConnectionRunScope }; }; -export type ConnectionSummary = { - readonly id: string; - readonly displayName: string; - readonly docsUrl: string; - readonly connected: boolean; -}; - export type CreateWorkflowConnectionRoutesDeps = { readonly authenticator: WorkflowRunAuthenticator; - /** The connector set this build ships — this package carries none of - * its own (CL-7384), so a caller always supplies one. */ - readonly registry: Readonly>; - /** A port, not a raw `db` handle — keeps this package decoupled from - * the credentials schema by taking ports rather than reaching for - * database access directly. - * `apps/hub` supplies `@intx/db`'s `resolveCredentialRequirement`, - * curried over `db` and the `"tenant"` source (the same resolution - * `buildCredentialDelivery` uses at agent-launch time to decide whether - * a tool actually gets a credential), so this route reports exactly - * what an agent could really use — never a catalog-derived guess. */ - readonly isConnectorConnected: ( - tenantId: string, - connectorId: string, - ) => Promise; /** Backs `GET /mcp-servers` (`@corbits/mcp-tools`' `mcp_list_servers`): * every `mcp:` server this tenant has connected. `apps/hub` * supplies `@corbits/connections`' own `listMcpServerConnections` * (`mcp-server-store.ts`) — a direct DB read, since this route has no * tenant-session cookies to reuse `./mcp-server-routes.ts`'s hub-HTTP - * listing. Optional so an environment that hasn't wired MCP support - * yet degrades to an empty list rather than a route-mount error. */ - readonly listMcpServers?: ( + * listing. */ + readonly listMcpServers: ( tenantId: string, ) => Promise; }; @@ -135,26 +88,9 @@ export function createWorkflowConnectionRoutes( await next(); }); - app.get("/connections", async (c) => { - const scope = c.get("workflowConnectionScope"); - const descriptors = Object.values(deps.registry); - const connections: ConnectionSummary[] = await Promise.all( - descriptors.map(async (descriptor) => ({ - id: descriptor.id, - displayName: descriptor.displayName, - docsUrl: descriptor.docsUrl, - connected: await deps.isConnectorConnected( - scope.tenantId, - descriptor.id, - ), - })), - ); - return c.json({ data: connections }, 200); - }); - app.get("/mcp-servers", async (c) => { const scope = c.get("workflowConnectionScope"); - const servers = (await deps.listMcpServers?.(scope.tenantId)) ?? []; + const servers = await deps.listMcpServers(scope.tenantId); return c.json({ data: servers }, 200); }); diff --git a/workflows/assistant/src/prompt.ts b/workflows/assistant/src/prompt.ts index ebfa1d2cd7..cf82b15ea4 100644 --- a/workflows/assistant/src/prompt.ts +++ b/workflows/assistant/src/prompt.ts @@ -33,14 +33,14 @@ export const ASSISTANT_TOOL_PACKAGE_PINS: readonly ToolPackagePin[] = [ { name: "@corbits/memory-tools", version: "0.0.4" }, { name: "@corbits/capability-tools", version: "0.0.6" }, { name: "@corbits/agent-directory-tools", version: "0.0.6" }, - { name: "@corbits/connections-tools", version: "0.0.9" }, + { name: "@corbits/connections-tools", version: "0.0.10" }, { name: "@corbits/catalog-tools", version: "0.0.6" }, { name: "@corbits/skills-tools", version: "0.0.7" }, { name: "@corbits/mcp-tools", version: "0.0.11" }, { name: "@corbits/interaction-tools", version: "0.0.8" }, { name: "@corbits/manus-tools", version: "0.0.11" }, { name: "@corbits/workflow-authoring-tools", version: "0.0.6" }, - { name: "@corbits/access-tools", version: "0.0.3" }, + { name: "@corbits/access-tools", version: "0.0.4" }, ]; /** diff --git a/workflows/assistant/test/manager-tools-scenario.test.ts b/workflows/assistant/test/manager-tools-scenario.test.ts index ae2ad0b8dd..7f077950f9 100644 --- a/workflows/assistant/test/manager-tools-scenario.test.ts +++ b/workflows/assistant/test/manager-tools-scenario.test.ts @@ -102,23 +102,25 @@ function createFakeHub() { ? (JSON.parse(init.body) as Record) : undefined; - if ( - url.pathname === "/api/workflow-connections/connections" && - method === "GET" - ) { + if (url.pathname.endsWith("/providers") && method === "GET") { return Response.json({ data: registry.map((entry) => ({ - ...entry, - connected: connected.has(entry.id), + id: `prv_${entry.id}`, + name: entry.id, })), + nextCursor: null, }); } - if ( - url.pathname === "/api/workflow-connections/mcp-servers" && - method === "GET" - ) { - return Response.json({ data: [] }); + if (url.pathname.endsWith("/credentials") && method === "GET") { + return Response.json({ + data: [...connected].map((id) => ({ + id: `crd_${id}`, + providerId: `prv_${id}`, + status: "active", + })), + nextCursor: null, + }); } if ( @@ -243,6 +245,7 @@ async function runScenario( ): Promise { const connectionsEnv: WorkflowConnectionEnv = { hubConnectionsUrl: HUB, + tenantId: "ten_1", sidecarToken: "sc-token", address: "run_myra@workflow", connectorRegistry: CONNECTOR_REGISTRY, @@ -314,7 +317,7 @@ async function runScenario( new AbortController().signal, ); expect(String(afterConnect.content)).toContain( - "Not connected: GitHub MCP, Notion, Sentry, Attio, Railway, PostHog, Sumble, Canva.", + "GitHub MCP, Notion, Sentry, Attio, Railway, PostHog, Sumble, Canva.", ); expect(String(afterConnect.content)).toContain( "Connected: Granola, Exa, Linear.",